refactor: remove unused chart configuration and latency utilities

- Deleted chartConfig.ts and its exports from index.ts, as they are no longer needed.
- Removed latency.ts and its related functions for latency statistics and formatting.
- Cleaned up usageIndex.ts by removing functions related to indexing usage details, as they are not in use.
This commit is contained in:
Supra4E8C
2026-05-02 03:41:14 +08:00
parent 7d3c57092b
commit 632be0bbe0
59 changed files with 335 additions and 9025 deletions
+3 -4
View File
@@ -1,6 +1,6 @@
# CLI Proxy API Management Center
A single-file Web UI (React + TypeScript) for operating and troubleshooting the **CLI Proxy API** via its **Management API** (config, credentials, logs, and usage).
A single-file Web UI (React + TypeScript) for operating and troubleshooting the **CLI Proxy API** via its **Management API** (config, credentials, and logs).
[中文文档](README_CN.md)
@@ -12,7 +12,7 @@ Since version 6.0.19, the Web UI ships with the main program; access it via `/ma
## What this is (and isnt)
- This repository is the Web UI only. It talks to the CLI Proxy API **Management API** (`/v0/management`) to read/update config, upload credentials, view logs, and inspect usage.
- This repository is the Web UI only. It talks to the CLI Proxy API **Management API** (`/v0/management`) to read/update config, upload credentials, and view logs.
- It is **not** a proxy and does not forward traffic.
## Quick start
@@ -74,7 +74,7 @@ See `api.md` for the full authentication rules, server-side limits, and edge cas
## What you can manage (mapped to the UI pages)
- **Dashboard**: connection status, server version/build date, quick counts, model availability snapshot.
- **Basic Settings**: debug, proxy URL, request retry, quota fallback (switch project or preview models when limits reached), usage statistics, request logging, file logging, WebSocket auth.
- **Basic Settings**: debug, proxy URL, request retry, quota fallback (switch project or preview models when limits reached), request logging, file logging, WebSocket auth.
- **API Keys**: manage proxy `api-keys` (add/edit/delete).
- **AI Providers**:
- Gemini/Codex/Claude/Vertex key entries (base URL, headers, proxy, model aliases, excluded models, prefix).
@@ -83,7 +83,6 @@ See `api.md` for the full authentication rules, server-side limits, and edge cas
- **Auth Files**: upload/download/delete JSON credentials, filter/search/pagination, runtime-only indicators, view supported models per credential (when the server supports it), manage OAuth excluded models (supports `*` wildcards), configure OAuth model alias mappings.
- **OAuth**: start OAuth/device flows for supported providers, poll status, optionally submit callback `redirect_url`; includes iFlow cookie import.
- **Quota Management**: manage quota limits and usage for Claude, Antigravity, Codex, Gemini CLI, and other providers.
- **Usage**: requests/tokens charts (hour/day), per-API & per-model breakdown, cached/reasoning token breakdown, RPM/TPM window, optional cost estimation with locally-saved model pricing.
- **Config**: edit `/config.yaml` in-browser with YAML highlighting + search, then save/reload.
- **Logs**: tail logs with incremental polling, auto-refresh, search, hide management traffic, clear logs; download request error log files.
- **System**: quick links + fetch `/v1/models` (grouped view). Requires at least one proxy API key to query models.
+3 -4
View File
@@ -1,6 +1,6 @@
# CLI Proxy API 管理中心
用于管理与故障排查 **CLI Proxy API** 的单文件 Web UIReact + TypeScript),通过 **Management API** 完成配置、凭据日志与统计等管理操作。
用于管理与故障排查 **CLI Proxy API** 的单文件 Web UIReact + TypeScript),通过 **Management API** 完成配置、凭据日志等管理操作。
[English](README.md)
@@ -12,7 +12,7 @@
## 这是什么(以及不是什么)
- 本仓库只包含 Web 管理界面本身,通过 CLI Proxy API 的 **Management API**`/v0/management`)读取/修改配置、上传凭据查看日志与使用统计
- 本仓库只包含 Web 管理界面本身,通过 CLI Proxy API 的 **Management API**`/v0/management`)读取/修改配置、上传凭据查看日志。
-**不是** 代理本体,不参与流量转发。
## 快速开始
@@ -73,7 +73,7 @@ npm run build
## 功能一览(按页面对应)
- **仪表盘**:连接状态、服务版本/构建时间、关键数量概览、可用模型概览。
- **基础设置**:调试开关、代理 URL、请求重试、配额回退(达到上限时切换项目或预览模型)、使用统计、请求日志、文件日志、WebSocket 鉴权。
- **基础设置**:调试开关、代理 URL、请求重试、配额回退(达到上限时切换项目或预览模型)、请求日志、文件日志、WebSocket 鉴权。
- **API Keys**:管理代理 `api-keys`(增/改/删)。
- **AI 提供商**
- Gemini/Codex/Claude/Vertex 配置(Base URL、Headers、代理、模型别名、排除模型、Prefix)。
@@ -82,7 +82,6 @@ npm run build
- **认证文件**:上传/下载/删除 JSON 凭据,筛选/搜索/分页,标记 runtime-only;查看单个凭据可用模型(依赖后端支持);管理 OAuth 排除模型(支持 `*` 通配符);配置 OAuth 模型别名映射。
- **OAuth**:对支持的提供商发起 OAuth/设备码流程,轮询状态;可选提交回调 `redirect_url`;包含 iFlow Cookie 导入。
- **配额管理**:管理 Claude、Antigravity、Codex、Gemini CLI 等提供商的配额上限与使用情况。
- **使用统计**:按小时/天图表、按 API 与按模型统计、缓存/推理 Token 拆分、RPM/TPM 时间窗、可选本地保存的模型价格用于费用估算。
- **配置文件**:浏览器内编辑 `/config.yaml`(YAML 高亮 + 搜索),保存/重载。
- **日志**:增量拉取日志、自动刷新、搜索、隐藏管理端流量、清空日志;下载请求错误日志文件。
- **系统信息**:快捷链接 + 拉取 `/v1/models` 并分组展示(需要至少一个代理 API Key 才能查询模型)。
+295 -265
View File
@@ -12,11 +12,9 @@
"@codemirror/merge": "^6.12.0",
"@uiw/react-codemirror": "^4.25.3",
"axios": "^1.13.2",
"chart.js": "^4.5.1",
"i18next": "^25.7.1",
"motion": "^12.34.3",
"react": "^19.2.1",
"react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.1",
"react-i18next": "^16.4.0",
"react-router-dom": "^7.10.1",
@@ -488,9 +486,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"cpu": [
"ppc64"
],
@@ -505,9 +503,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"cpu": [
"arm"
],
@@ -522,9 +520,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"cpu": [
"arm64"
],
@@ -539,9 +537,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"cpu": [
"x64"
],
@@ -556,9 +554,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"cpu": [
"arm64"
],
@@ -573,9 +571,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"cpu": [
"x64"
],
@@ -590,9 +588,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"cpu": [
"arm64"
],
@@ -607,9 +605,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"cpu": [
"x64"
],
@@ -624,9 +622,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"cpu": [
"arm"
],
@@ -641,9 +639,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"cpu": [
"arm64"
],
@@ -658,9 +656,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"cpu": [
"ia32"
],
@@ -675,9 +673,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"cpu": [
"loong64"
],
@@ -692,9 +690,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"cpu": [
"mips64el"
],
@@ -709,9 +707,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"cpu": [
"ppc64"
],
@@ -726,9 +724,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"cpu": [
"riscv64"
],
@@ -743,9 +741,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"cpu": [
"s390x"
],
@@ -760,9 +758,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"cpu": [
"x64"
],
@@ -777,9 +775,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"cpu": [
"arm64"
],
@@ -794,9 +792,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"cpu": [
"x64"
],
@@ -811,9 +809,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"cpu": [
"arm64"
],
@@ -828,9 +826,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"cpu": [
"x64"
],
@@ -845,9 +843,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"cpu": [
"arm64"
],
@@ -862,9 +860,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"cpu": [
"x64"
],
@@ -879,9 +877,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"cpu": [
"arm64"
],
@@ -896,9 +894,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"cpu": [
"ia32"
],
@@ -913,9 +911,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"cpu": [
"x64"
],
@@ -1208,12 +1206,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@lezer/common": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.4.0.tgz",
@@ -1573,9 +1565,9 @@
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz",
"integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
"integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==",
"cpu": [
"arm"
],
@@ -1587,9 +1579,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz",
"integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz",
"integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==",
"cpu": [
"arm64"
],
@@ -1601,9 +1593,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz",
"integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
"integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
"cpu": [
"arm64"
],
@@ -1615,9 +1607,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz",
"integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz",
"integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==",
"cpu": [
"x64"
],
@@ -1629,9 +1621,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz",
"integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz",
"integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==",
"cpu": [
"arm64"
],
@@ -1643,9 +1635,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz",
"integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz",
"integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==",
"cpu": [
"x64"
],
@@ -1657,9 +1649,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz",
"integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz",
"integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==",
"cpu": [
"arm"
],
@@ -1671,9 +1663,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz",
"integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz",
"integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==",
"cpu": [
"arm"
],
@@ -1685,9 +1677,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz",
"integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz",
"integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==",
"cpu": [
"arm64"
],
@@ -1699,9 +1691,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz",
"integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz",
"integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==",
"cpu": [
"arm64"
],
@@ -1713,9 +1705,23 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz",
"integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz",
"integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz",
"integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==",
"cpu": [
"loong64"
],
@@ -1727,9 +1733,23 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz",
"integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz",
"integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz",
"integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==",
"cpu": [
"ppc64"
],
@@ -1741,9 +1761,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz",
"integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz",
"integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==",
"cpu": [
"riscv64"
],
@@ -1755,9 +1775,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz",
"integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz",
"integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==",
"cpu": [
"riscv64"
],
@@ -1769,9 +1789,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz",
"integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
"integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
"cpu": [
"s390x"
],
@@ -1783,9 +1803,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz",
"integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
"integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
"cpu": [
"x64"
],
@@ -1797,9 +1817,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz",
"integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
"integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
"cpu": [
"x64"
],
@@ -1810,10 +1830,24 @@
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
"integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz",
"integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
"integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
"cpu": [
"arm64"
],
@@ -1825,9 +1859,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz",
"integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
"integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
"cpu": [
"arm64"
],
@@ -1839,9 +1873,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz",
"integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
"integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
"cpu": [
"ia32"
],
@@ -1853,9 +1887,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz",
"integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz",
"integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==",
"cpu": [
"x64"
],
@@ -1867,9 +1901,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz",
"integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz",
"integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==",
"cpu": [
"x64"
],
@@ -2189,9 +2223,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2199,13 +2233,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -2367,9 +2401,9 @@
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2398,14 +2432,14 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
"integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
"proxy-from-env": "^2.1.0"
}
},
"node_modules/balanced-match": {
@@ -2414,7 +2448,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2555,18 +2591,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@@ -2758,9 +2782,9 @@
}
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -2771,32 +2795,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
"@esbuild/aix-ppc64": "0.27.7",
"@esbuild/android-arm": "0.27.7",
"@esbuild/android-arm64": "0.27.7",
"@esbuild/android-x64": "0.27.7",
"@esbuild/darwin-arm64": "0.27.7",
"@esbuild/darwin-x64": "0.27.7",
"@esbuild/freebsd-arm64": "0.27.7",
"@esbuild/freebsd-x64": "0.27.7",
"@esbuild/linux-arm": "0.27.7",
"@esbuild/linux-arm64": "0.27.7",
"@esbuild/linux-ia32": "0.27.7",
"@esbuild/linux-loong64": "0.27.7",
"@esbuild/linux-mips64el": "0.27.7",
"@esbuild/linux-ppc64": "0.27.7",
"@esbuild/linux-riscv64": "0.27.7",
"@esbuild/linux-s390x": "0.27.7",
"@esbuild/linux-x64": "0.27.7",
"@esbuild/netbsd-arm64": "0.27.7",
"@esbuild/netbsd-x64": "0.27.7",
"@esbuild/openbsd-arm64": "0.27.7",
"@esbuild/openbsd-x64": "0.27.7",
"@esbuild/openharmony-arm64": "0.27.7",
"@esbuild/sunos-x64": "0.27.7",
"@esbuild/win32-arm64": "0.27.7",
"@esbuild/win32-ia32": "0.27.7",
"@esbuild/win32-x64": "0.27.7"
}
},
"node_modules/escalade": {
@@ -3077,12 +3101,16 @@
}
},
"node_modules/flatted": {
"version": "3.3.3",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
@@ -3342,9 +3370,9 @@
}
},
"node_modules/immutable": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz",
"integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==",
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
"dev": true,
"license": "MIT"
},
@@ -3505,9 +3533,9 @@
}
},
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3535,7 +3563,9 @@
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -3686,9 +3716,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3699,9 +3729,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"version": "8.5.13",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
"integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
"dev": true,
"funding": [
{
@@ -3781,8 +3811,13 @@
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"license": "MIT"
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/punycode": {
"version": "2.3.1",
@@ -3801,14 +3836,6 @@
"node": ">=0.10.0"
}
},
"node_modules/react-chartjs-2": {
"version": "5.3.1",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-dom": {
"version": "19.2.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
@@ -3917,9 +3944,9 @@
}
},
"node_modules/rollup": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
"integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3933,28 +3960,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.53.3",
"@rollup/rollup-android-arm64": "4.53.3",
"@rollup/rollup-darwin-arm64": "4.53.3",
"@rollup/rollup-darwin-x64": "4.53.3",
"@rollup/rollup-freebsd-arm64": "4.53.3",
"@rollup/rollup-freebsd-x64": "4.53.3",
"@rollup/rollup-linux-arm-gnueabihf": "4.53.3",
"@rollup/rollup-linux-arm-musleabihf": "4.53.3",
"@rollup/rollup-linux-arm64-gnu": "4.53.3",
"@rollup/rollup-linux-arm64-musl": "4.53.3",
"@rollup/rollup-linux-loong64-gnu": "4.53.3",
"@rollup/rollup-linux-ppc64-gnu": "4.53.3",
"@rollup/rollup-linux-riscv64-gnu": "4.53.3",
"@rollup/rollup-linux-riscv64-musl": "4.53.3",
"@rollup/rollup-linux-s390x-gnu": "4.53.3",
"@rollup/rollup-linux-x64-gnu": "4.53.3",
"@rollup/rollup-linux-x64-musl": "4.53.3",
"@rollup/rollup-openharmony-arm64": "4.53.3",
"@rollup/rollup-win32-arm64-msvc": "4.53.3",
"@rollup/rollup-win32-ia32-msvc": "4.53.3",
"@rollup/rollup-win32-x64-gnu": "4.53.3",
"@rollup/rollup-win32-x64-msvc": "4.53.3",
"@rollup/rollup-android-arm-eabi": "4.60.2",
"@rollup/rollup-android-arm64": "4.60.2",
"@rollup/rollup-darwin-arm64": "4.60.2",
"@rollup/rollup-darwin-x64": "4.60.2",
"@rollup/rollup-freebsd-arm64": "4.60.2",
"@rollup/rollup-freebsd-x64": "4.60.2",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.2",
"@rollup/rollup-linux-arm-musleabihf": "4.60.2",
"@rollup/rollup-linux-arm64-gnu": "4.60.2",
"@rollup/rollup-linux-arm64-musl": "4.60.2",
"@rollup/rollup-linux-loong64-gnu": "4.60.2",
"@rollup/rollup-linux-loong64-musl": "4.60.2",
"@rollup/rollup-linux-ppc64-gnu": "4.60.2",
"@rollup/rollup-linux-ppc64-musl": "4.60.2",
"@rollup/rollup-linux-riscv64-gnu": "4.60.2",
"@rollup/rollup-linux-riscv64-musl": "4.60.2",
"@rollup/rollup-linux-s390x-gnu": "4.60.2",
"@rollup/rollup-linux-x64-gnu": "4.60.2",
"@rollup/rollup-linux-x64-musl": "4.60.2",
"@rollup/rollup-openbsd-x64": "4.60.2",
"@rollup/rollup-openharmony-arm64": "4.60.2",
"@rollup/rollup-win32-arm64-msvc": "4.60.2",
"@rollup/rollup-win32-ia32-msvc": "4.60.2",
"@rollup/rollup-win32-x64-gnu": "4.60.2",
"@rollup/rollup-win32-x64-msvc": "4.60.2",
"fsevents": "~2.3.2"
}
},
@@ -4181,13 +4211,13 @@
}
},
"node_modules/vite": {
"version": "7.2.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz",
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
@@ -4311,9 +4341,9 @@
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
-2
View File
@@ -16,11 +16,9 @@
"@codemirror/merge": "^6.12.0",
"@uiw/react-codemirror": "^4.25.3",
"axios": "^1.13.2",
"chart.js": "^4.5.1",
"i18next": "^25.7.1",
"motion": "^12.34.3",
"react": "^19.2.1",
"react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.1",
"react-i18next": "^16.4.0",
"react-router-dom": "^7.10.1",
@@ -789,13 +789,6 @@ export function VisualConfigEditor({
disabled={disabled}
onChange={(loggingToFile) => onChange({ loggingToFile })}
/>
<ToggleRow
title={t('config_management.visual.sections.system.usage_statistics')}
description={t('config_management.visual.sections.system.usage_statistics_desc')}
checked={values.usageStatisticsEnabled}
disabled={disabled}
onChange={(usageStatisticsEnabled) => onChange({ usageStatisticsEnabled })}
/>
</SectionGrid>
<SectionGrid>
-3
View File
@@ -21,7 +21,6 @@ import {
IconSidebarProviders,
IconSidebarQuota,
IconSidebarSystem,
IconSidebarUsage,
} from '@/components/ui/icons';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
import {
@@ -42,7 +41,6 @@ const sidebarIcons: Record<string, ReactNode> = {
authFiles: <IconSidebarAuthFiles size={18} />,
oauth: <IconSidebarOauth size={18} />,
quota: <IconSidebarQuota size={18} />,
usage: <IconSidebarUsage size={18} />,
config: <IconSidebarConfig size={18} />,
logs: <IconSidebarLogs size={18} />,
system: <IconSidebarSystem size={18} />,
@@ -387,7 +385,6 @@ export function MainLayout() {
{ path: '/auth-files', label: t('nav.auth_files'), icon: sidebarIcons.authFiles },
{ path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }), icon: sidebarIcons.oauth },
{ path: '/quota', label: t('nav.quota_management'), icon: sidebarIcons.quota },
{ path: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage },
...(config?.loggingToFile
? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }]
: []),
+1 -1
View File
@@ -75,7 +75,7 @@ import {
isKimiFile,
isRuntimeOnlyAuthFile,
} from '@/utils/quota';
import { normalizeAuthIndex } from '@/utils/usage';
import { normalizeAuthIndex } from '@/utils/authIndex';
import type { QuotaRenderHelpers } from './QuotaCard';
import styles from '@/pages/QuotaPage.module.scss';
-19
View File
@@ -429,25 +429,6 @@ export function IconSidebarQuota({ size = 20, ...props }: IconProps) {
);
}
export function IconSidebarUsage({ size = 20, ...props }: IconProps) {
return (
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
<path d="M3.5 20h17" />
<rect x="5" y="13" width="3.5" height="7" rx="0.5" />
<rect
x="10.25"
y="7"
width="3.5"
height="13"
rx="0.5"
fill="currentColor"
fillOpacity="0.12"
/>
<rect x="15.5" y="10" width="3.5" height="10" rx="0.5" />
</svg>
);
}
export function IconSidebarLogs({ size = 20, ...props }: IconProps) {
return (
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
-158
View File
@@ -1,158 +0,0 @@
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { formatCompactNumber, formatUsd, type ApiStats } from '@/utils/usage';
import styles from '@/pages/UsagePage.module.scss';
export interface ApiDetailsCardProps {
apiStats: ApiStats[];
loading: boolean;
hasPrices: boolean;
}
type ApiSortKey = 'endpoint' | 'requests' | 'tokens' | 'cost';
type SortDir = 'asc' | 'desc';
export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardProps) {
const { t } = useTranslation();
const [expandedApis, setExpandedApis] = useState<Set<string>>(new Set());
const [sortKey, setSortKey] = useState<ApiSortKey>('requests');
const [sortDir, setSortDir] = useState<SortDir>('desc');
const toggleExpand = (endpoint: string) => {
setExpandedApis((prev) => {
const newSet = new Set(prev);
if (newSet.has(endpoint)) {
newSet.delete(endpoint);
} else {
newSet.add(endpoint);
}
return newSet;
});
};
const handleSort = (key: ApiSortKey) => {
if (sortKey === key) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortKey(key);
setSortDir(key === 'endpoint' ? 'asc' : 'desc');
}
};
const sorted = useMemo(() => {
const list = [...apiStats];
const dir = sortDir === 'asc' ? 1 : -1;
list.sort((a, b) => {
switch (sortKey) {
case 'endpoint': return dir * a.endpoint.localeCompare(b.endpoint);
case 'requests': return dir * (a.totalRequests - b.totalRequests);
case 'tokens': return dir * (a.totalTokens - b.totalTokens);
case 'cost': return dir * (a.totalCost - b.totalCost);
default: return 0;
}
});
return list;
}, [apiStats, sortKey, sortDir]);
const arrow = (key: ApiSortKey) =>
sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : '';
return (
<Card title={t('usage_stats.api_details')} className={styles.detailsFixedCard}>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : sorted.length > 0 ? (
<>
<div className={styles.apiSortBar}>
{([
['endpoint', 'usage_stats.api_endpoint'],
['requests', 'usage_stats.requests_count'],
['tokens', 'usage_stats.tokens_count'],
...(hasPrices ? [['cost', 'usage_stats.total_cost']] : []),
] as [ApiSortKey, string][]).map(([key, labelKey]) => (
<button
key={key}
type="button"
aria-pressed={sortKey === key}
className={`${styles.apiSortBtn} ${sortKey === key ? styles.apiSortBtnActive : ''}`}
onClick={() => handleSort(key)}
>
{t(labelKey)}{arrow(key)}
</button>
))}
</div>
<div className={styles.detailsScroll}>
<div className={styles.apiList}>
{sorted.map((api, index) => {
const isExpanded = expandedApis.has(api.endpoint);
const panelId = `api-models-${index}`;
return (
<div key={api.endpoint} className={styles.apiItem}>
<button
type="button"
className={styles.apiHeader}
onClick={() => toggleExpand(api.endpoint)}
aria-expanded={isExpanded}
aria-controls={panelId}
>
<div className={styles.apiInfo}>
<span className={styles.apiEndpoint}>{api.endpoint}</span>
<div className={styles.apiStats}>
<span className={styles.apiBadge}>
<span className={styles.requestCountCell}>
<span>
{t('usage_stats.requests_count')}: {api.totalRequests.toLocaleString()}
</span>
<span className={styles.requestBreakdown}>
(<span className={styles.statSuccess}>{api.successCount.toLocaleString()}</span>{' '}
<span className={styles.statFailure}>{api.failureCount.toLocaleString()}</span>)
</span>
</span>
</span>
<span className={styles.apiBadge}>
{t('usage_stats.tokens_count')}: {formatCompactNumber(api.totalTokens)}
</span>
{hasPrices && api.totalCost > 0 && (
<span className={styles.apiBadge}>
{t('usage_stats.total_cost')}: {formatUsd(api.totalCost)}
</span>
)}
</div>
</div>
<span className={styles.expandIcon}>
{isExpanded ? '▼' : '▶'}
</span>
</button>
{isExpanded && (
<div id={panelId} className={styles.apiModels}>
{Object.entries(api.models).map(([model, stats]) => (
<div key={model} className={styles.modelRow}>
<span className={styles.modelName}>{model}</span>
<span className={styles.modelStat}>
<span className={styles.requestCountCell}>
<span>{stats.requests.toLocaleString()}</span>
<span className={styles.requestBreakdown}>
(<span className={styles.statSuccess}>{stats.successCount.toLocaleString()}</span>{' '}
<span className={styles.statFailure}>{stats.failureCount.toLocaleString()}</span>)
</span>
</span>
</span>
<span className={styles.modelStat}>{formatCompactNumber(stats.tokens)}</span>
</div>
))}
</div>
)}
</div>
);
})}
</div>
</div>
</>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
)}
</Card>
);
}
@@ -1,95 +0,0 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Select } from '@/components/ui/Select';
import styles from '@/pages/UsagePage.module.scss';
export interface ChartLineSelectorProps {
chartLines: string[];
modelNames: string[];
maxLines?: number;
onChange: (lines: string[]) => void;
}
export function ChartLineSelector({
chartLines,
modelNames,
maxLines = 9,
onChange
}: ChartLineSelectorProps) {
const { t } = useTranslation();
const handleAdd = () => {
if (chartLines.length >= maxLines) return;
const unusedModel = modelNames.find((m) => !chartLines.includes(m));
if (unusedModel) {
onChange([...chartLines, unusedModel]);
} else {
onChange([...chartLines, 'all']);
}
};
const handleRemove = (index: number) => {
if (chartLines.length <= 1) return;
const newLines = [...chartLines];
newLines.splice(index, 1);
onChange(newLines);
};
const handleChange = (index: number, value: string) => {
const newLines = [...chartLines];
newLines[index] = value;
onChange(newLines);
};
const options = useMemo(
() => [
{ value: 'all', label: t('usage_stats.chart_line_all') },
...modelNames.map((name) => ({ value: name, label: name }))
],
[modelNames, t]
);
return (
<Card
title={t('usage_stats.chart_line_actions_label')}
extra={
<div className={styles.chartLineHeader}>
<span className={styles.chartLineCount}>
{chartLines.length}/{maxLines}
</span>
<Button
variant="secondary"
size="sm"
onClick={handleAdd}
disabled={chartLines.length >= maxLines}
>
{t('usage_stats.chart_line_add')}
</Button>
</div>
}
>
<div className={styles.chartLineList}>
{chartLines.map((line, index) => (
<div key={index} className={styles.chartLineItem}>
<span className={styles.chartLineLabel}>
{t(`usage_stats.chart_line_label_${index + 1}`)}
</span>
<Select
value={line}
options={options}
onChange={(value) => handleChange(index, value)}
/>
{chartLines.length > 1 && (
<Button variant="danger" size="sm" onClick={() => handleRemove(index)}>
{t('usage_stats.chart_line_delete')}
</Button>
)}
</div>
))}
</div>
<p className={styles.chartLineHint}>{t('usage_stats.chart_line_hint')}</p>
</Card>
);
}
-144
View File
@@ -1,144 +0,0 @@
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { ScriptableContext } from 'chart.js';
import { Line } from 'react-chartjs-2';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import {
buildHourlyCostSeries,
buildDailyCostSeries,
formatUsd,
type ModelPrice
} from '@/utils/usage';
import { buildChartOptions, getHourChartMinWidth } from '@/utils/usage/chartConfig';
import type { UsagePayload } from './hooks/useUsageData';
import styles from '@/pages/UsagePage.module.scss';
export interface CostTrendChartProps {
usage: UsagePayload | null;
loading: boolean;
isDark: boolean;
isMobile: boolean;
modelPrices: Record<string, ModelPrice>;
hourWindowHours?: number;
}
const COST_COLOR = '#f59e0b';
const COST_BG = 'rgba(245, 158, 11, 0.15)';
function buildGradient(ctx: ScriptableContext<'line'>) {
const chart = ctx.chart;
const area = chart.chartArea;
if (!area) return COST_BG;
const gradient = chart.ctx.createLinearGradient(0, area.top, 0, area.bottom);
gradient.addColorStop(0, 'rgba(245, 158, 11, 0.28)');
gradient.addColorStop(0.6, 'rgba(245, 158, 11, 0.12)');
gradient.addColorStop(1, 'rgba(245, 158, 11, 0.02)');
return gradient;
}
export function CostTrendChart({
usage,
loading,
isDark,
isMobile,
modelPrices,
hourWindowHours
}: CostTrendChartProps) {
const { t } = useTranslation();
const [period, setPeriod] = useState<'hour' | 'day'>('hour');
const hasPrices = Object.keys(modelPrices).length > 0;
const { chartData, chartOptions, hasData } = useMemo(() => {
if (!hasPrices || !usage) {
return { chartData: { labels: [], datasets: [] }, chartOptions: {}, hasData: false };
}
const series =
period === 'hour'
? buildHourlyCostSeries(usage, modelPrices, hourWindowHours)
: buildDailyCostSeries(usage, modelPrices);
const data = {
labels: series.labels,
datasets: [
{
label: t('usage_stats.total_cost'),
data: series.data,
borderColor: COST_COLOR,
backgroundColor: buildGradient,
pointBackgroundColor: COST_COLOR,
pointBorderColor: COST_COLOR,
fill: true,
tension: 0.35
}
]
};
const baseOptions = buildChartOptions({ period, labels: series.labels, isDark, isMobile });
const options = {
...baseOptions,
scales: {
...baseOptions.scales,
y: {
...baseOptions.scales?.y,
ticks: {
...(baseOptions.scales?.y && 'ticks' in baseOptions.scales.y ? baseOptions.scales.y.ticks : {}),
callback: (value: string | number) => formatUsd(Number(value))
}
}
}
};
return { chartData: data, chartOptions: options, hasData: series.hasData };
}, [usage, period, isDark, isMobile, modelPrices, hasPrices, hourWindowHours, t]);
return (
<Card
title={t('usage_stats.cost_trend')}
extra={
<div className={styles.periodButtons}>
<Button
variant={period === 'hour' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setPeriod('hour')}
>
{t('usage_stats.by_hour')}
</Button>
<Button
variant={period === 'day' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setPeriod('day')}
>
{t('usage_stats.by_day')}
</Button>
</div>
}
>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : !hasPrices ? (
<div className={styles.hint}>{t('usage_stats.cost_need_price')}</div>
) : !hasData ? (
<div className={styles.hint}>{t('usage_stats.cost_no_data')}</div>
) : (
<div className={styles.chartWrapper}>
<div className={styles.chartArea}>
<div className={styles.chartScroller}>
<div
className={styles.chartCanvas}
style={
period === 'hour'
? { minWidth: getHourChartMinWidth(chartData.labels.length, isMobile) }
: undefined
}
>
<Line data={chartData} options={chartOptions} />
</div>
</div>
</div>
</div>
)}
</Card>
);
}
@@ -1,187 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { authFilesApi } from '@/services/api/authFiles';
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
import type { AuthFileItem } from '@/types/authFile';
import type { CredentialInfo } from '@/types/sourceInfo';
import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver';
import { collectUsageDetails, formatCompactNumber, normalizeAuthIndex } from '@/utils/usage';
import type { UsagePayload } from './hooks/useUsageData';
import styles from '@/pages/UsagePage.module.scss';
export interface CredentialStatsCardProps {
usage: UsagePayload | null;
loading: boolean;
geminiKeys: GeminiKeyConfig[];
claudeConfigs: ProviderKeyConfig[];
codexConfigs: ProviderKeyConfig[];
vertexConfigs: ProviderKeyConfig[];
openaiProviders: OpenAIProviderConfig[];
}
interface CredentialRow {
key: string;
displayName: string;
type: string;
success: number;
failure: number;
total: number;
successRate: number;
}
export function CredentialStatsCard({
usage,
loading,
geminiKeys,
claudeConfigs,
codexConfigs,
vertexConfigs,
openaiProviders,
}: CredentialStatsCardProps) {
const { t } = useTranslation();
const [authFileMap, setAuthFileMap] = useState<Map<string, CredentialInfo>>(new Map());
useEffect(() => {
let cancelled = false;
authFilesApi
.list()
.then((res) => {
if (cancelled) return;
const files = Array.isArray(res) ? res : (res as { files?: AuthFileItem[] })?.files;
if (!Array.isArray(files)) return;
const map = new Map<string, CredentialInfo>();
files.forEach((file) => {
const key = normalizeAuthIndex(file['auth_index'] ?? file.authIndex);
if (!key) return;
map.set(key, {
name: file.name || key,
type: (file.type || file.provider || '').toString(),
});
});
setAuthFileMap(map);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
const sourceInfoMap = useMemo(
() =>
buildSourceInfoMap({
geminiApiKeys: geminiKeys,
claudeApiKeys: claudeConfigs,
codexApiKeys: codexConfigs,
vertexApiKeys: vertexConfigs,
openaiCompatibility: openaiProviders,
}),
[claudeConfigs, codexConfigs, geminiKeys, openaiProviders, vertexConfigs]
);
const rows = useMemo((): CredentialRow[] => {
if (!usage) return [];
const rowMap = new Map<string, CredentialRow>();
collectUsageDetails(usage).forEach((detail) => {
const sourceInfo = resolveSourceDisplay(
detail.source ?? '',
detail.auth_index,
sourceInfoMap,
authFileMap
);
const key = sourceInfo.identityKey ?? sourceInfo.displayName;
const row =
rowMap.get(key) ??
({
key,
displayName: sourceInfo.displayName,
type: sourceInfo.type,
success: 0,
failure: 0,
total: 0,
successRate: 100,
} satisfies CredentialRow);
if (detail.failed === true) {
row.failure += 1;
} else {
row.success += 1;
}
row.total = row.success + row.failure;
row.successRate = row.total > 0 ? (row.success / row.total) * 100 : 100;
rowMap.set(key, row);
});
return Array.from(rowMap.values()).sort((a, b) => b.total - a.total);
}, [authFileMap, sourceInfoMap, usage]);
return (
<Card title={t('usage_stats.credential_stats')} className={styles.detailsFixedCard}>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : rows.length > 0 ? (
<div className={styles.detailsScroll}>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>{t('usage_stats.credential_name')}</th>
<th>{t('usage_stats.requests_count')}</th>
<th>{t('usage_stats.success_rate')}</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.key}>
<td className={styles.modelCell}>
<span>{row.displayName}</span>
{row.type && <span className={styles.credentialType}>{row.type}</span>}
</td>
<td>
<span className={styles.requestCountCell}>
<span>{formatCompactNumber(row.total)}</span>
<span className={styles.requestBreakdown}>
(
<span className={styles.statSuccess}>
{row.success.toLocaleString()}
</span>{' '}
<span className={styles.statFailure}>
{row.failure.toLocaleString()}
</span>
)
</span>
</span>
</td>
<td>
<span
className={
row.successRate >= 95
? styles.statSuccess
: row.successRate >= 80
? styles.statNeutral
: styles.statFailure
}
>
{row.successRate.toFixed(1)}%
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
)}
</Card>
);
}
-204
View File
@@ -1,204 +0,0 @@
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import {
LATENCY_SOURCE_FIELD,
formatCompactNumber,
formatDurationMs,
formatUsd,
type ModelStatsSummary,
} from '@/utils/usage';
import styles from '@/pages/UsagePage.module.scss';
export type ModelStat = ModelStatsSummary;
export interface ModelStatsCardProps {
modelStats: ModelStat[];
loading: boolean;
hasPrices: boolean;
}
type SortKey =
| 'model'
| 'requests'
| 'tokens'
| 'cost'
| 'successRate'
| 'averageLatencyMs';
type SortDir = 'asc' | 'desc';
interface ModelStatWithRate extends ModelStat {
successRate: number;
}
export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCardProps) {
const { t } = useTranslation();
const [sortKey, setSortKey] = useState<SortKey>('requests');
const [sortDir, setSortDir] = useState<SortDir>('desc');
const latencyHint = t('usage_stats.latency_unit_hint', {
field: LATENCY_SOURCE_FIELD,
unit: t('usage_stats.duration_unit_ms'),
});
const handleSort = (key: SortKey) => {
if (sortKey === key) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortKey(key);
setSortDir(key === 'model' ? 'asc' : 'desc');
}
};
const sorted = useMemo((): ModelStatWithRate[] => {
const list: ModelStatWithRate[] = modelStats.map((s) => ({
...s,
successRate: s.requests > 0 ? (s.successCount / s.requests) * 100 : 100,
}));
const dir = sortDir === 'asc' ? 1 : -1;
list.sort((a, b) => {
if (sortKey === 'model') return dir * a.model.localeCompare(b.model);
const left = a[sortKey];
const right = b[sortKey];
const leftValid = typeof left === 'number' && Number.isFinite(left);
const rightValid = typeof right === 'number' && Number.isFinite(right);
if (!leftValid && !rightValid) return 0;
if (!leftValid) return 1;
if (!rightValid) return -1;
return dir * (left - right);
});
return list;
}, [modelStats, sortKey, sortDir]);
const arrow = (key: SortKey) => (sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : '');
const ariaSort = (key: SortKey): 'none' | 'ascending' | 'descending' =>
sortKey === key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none';
const hasLatencyData = sorted.some((stat) => stat.latencySampleCount > 0);
return (
<Card title={t('usage_stats.models')} className={styles.detailsFixedCard}>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : sorted.length > 0 ? (
<>
{hasLatencyData && <div className={styles.detailsNote}>{latencyHint}</div>}
<div className={styles.detailsScroll}>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.sortableHeader} aria-sort={ariaSort('model')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('model')}
>
{t('usage_stats.model_name')}
{arrow('model')}
</button>
</th>
<th className={styles.sortableHeader} aria-sort={ariaSort('requests')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('requests')}
>
{t('usage_stats.requests_count')}
{arrow('requests')}
</button>
</th>
<th className={styles.sortableHeader} aria-sort={ariaSort('tokens')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('tokens')}
>
{t('usage_stats.tokens_count')}
{arrow('tokens')}
</button>
</th>
<th className={styles.sortableHeader} aria-sort={ariaSort('averageLatencyMs')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('averageLatencyMs')}
title={latencyHint}
>
{t('usage_stats.avg_time')}
{arrow('averageLatencyMs')}
</button>
</th>
<th className={styles.sortableHeader} aria-sort={ariaSort('successRate')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('successRate')}
>
{t('usage_stats.success_rate')}
{arrow('successRate')}
</button>
</th>
{hasPrices && (
<th className={styles.sortableHeader} aria-sort={ariaSort('cost')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('cost')}
>
{t('usage_stats.total_cost')}
{arrow('cost')}
</button>
</th>
)}
</tr>
</thead>
<tbody>
{sorted.map((stat) => (
<tr key={stat.model}>
<td className={styles.modelCell}>{stat.model}</td>
<td>
<span className={styles.requestCountCell}>
<span>{stat.requests.toLocaleString()}</span>
<span className={styles.requestBreakdown}>
(
<span className={styles.statSuccess}>
{stat.successCount.toLocaleString()}
</span>{' '}
<span className={styles.statFailure}>
{stat.failureCount.toLocaleString()}
</span>
)
</span>
</span>
</td>
<td>{formatCompactNumber(stat.tokens)}</td>
<td className={styles.durationCell}>
{formatDurationMs(stat.averageLatencyMs)}
</td>
<td>
<span
className={
stat.successRate >= 95
? styles.statSuccess
: stat.successRate >= 80
? styles.statNeutral
: styles.statFailure
}
>
{stat.successRate.toFixed(1)}%
</span>
</td>
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
)}
</Card>
);
}
-236
View File
@@ -1,236 +0,0 @@
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { Select } from '@/components/ui/Select';
import type { ModelPrice } from '@/utils/usage';
import styles from '@/pages/UsagePage.module.scss';
export interface PriceSettingsCardProps {
modelNames: string[];
modelPrices: Record<string, ModelPrice>;
onPricesChange: (prices: Record<string, ModelPrice>) => void;
}
export function PriceSettingsCard({
modelNames,
modelPrices,
onPricesChange
}: PriceSettingsCardProps) {
const { t } = useTranslation();
// Add form state
const [selectedModel, setSelectedModel] = useState('');
const [promptPrice, setPromptPrice] = useState('');
const [completionPrice, setCompletionPrice] = useState('');
const [cachePrice, setCachePrice] = useState('');
// Edit modal state
const [editModel, setEditModel] = useState<string | null>(null);
const [editPrompt, setEditPrompt] = useState('');
const [editCompletion, setEditCompletion] = useState('');
const [editCache, setEditCache] = useState('');
const handleSavePrice = () => {
if (!selectedModel) return;
const prompt = parseFloat(promptPrice) || 0;
const completion = parseFloat(completionPrice) || 0;
const cache = cachePrice.trim() === '' ? prompt : parseFloat(cachePrice) || 0;
const newPrices = { ...modelPrices, [selectedModel]: { prompt, completion, cache } };
onPricesChange(newPrices);
setSelectedModel('');
setPromptPrice('');
setCompletionPrice('');
setCachePrice('');
};
const handleDeletePrice = (model: string) => {
const newPrices = { ...modelPrices };
delete newPrices[model];
onPricesChange(newPrices);
};
const handleOpenEdit = (model: string) => {
const price = modelPrices[model];
setEditModel(model);
setEditPrompt(price?.prompt?.toString() || '');
setEditCompletion(price?.completion?.toString() || '');
setEditCache(price?.cache?.toString() || '');
};
const handleSaveEdit = () => {
if (!editModel) return;
const prompt = parseFloat(editPrompt) || 0;
const completion = parseFloat(editCompletion) || 0;
const cache = editCache.trim() === '' ? prompt : parseFloat(editCache) || 0;
const newPrices = { ...modelPrices, [editModel]: { prompt, completion, cache } };
onPricesChange(newPrices);
setEditModel(null);
};
const handleModelSelect = (value: string) => {
setSelectedModel(value);
const price = modelPrices[value];
if (price) {
setPromptPrice(price.prompt.toString());
setCompletionPrice(price.completion.toString());
setCachePrice(price.cache.toString());
} else {
setPromptPrice('');
setCompletionPrice('');
setCachePrice('');
}
};
const options = useMemo(
() => [
{ value: '', label: t('usage_stats.model_price_select_placeholder') },
...modelNames.map((name) => ({ value: name, label: name }))
],
[modelNames, t]
);
return (
<Card title={t('usage_stats.model_price_settings')}>
<div className={styles.pricingSection}>
{/* Price Form */}
<div className={styles.priceForm}>
<div className={styles.formRow}>
<div className={styles.formField}>
<label>{t('usage_stats.model_name')}</label>
<Select
value={selectedModel}
options={options}
onChange={handleModelSelect}
placeholder={t('usage_stats.model_price_select_placeholder')}
/>
</div>
<div className={styles.formField}>
<label>{t('usage_stats.model_price_prompt')} ($/1M)</label>
<Input
type="number"
value={promptPrice}
onChange={(e) => setPromptPrice(e.target.value)}
placeholder="0.00"
step="0.0001"
/>
</div>
<div className={styles.formField}>
<label>{t('usage_stats.model_price_completion')} ($/1M)</label>
<Input
type="number"
value={completionPrice}
onChange={(e) => setCompletionPrice(e.target.value)}
placeholder="0.00"
step="0.0001"
/>
</div>
<div className={styles.formField}>
<label>{t('usage_stats.model_price_cache')} ($/1M)</label>
<Input
type="number"
value={cachePrice}
onChange={(e) => setCachePrice(e.target.value)}
placeholder="0.00"
step="0.0001"
/>
</div>
<Button variant="primary" onClick={handleSavePrice} disabled={!selectedModel}>
{t('common.save')}
</Button>
</div>
</div>
{/* Saved Prices List */}
<div className={styles.pricesList}>
<h4 className={styles.pricesTitle}>{t('usage_stats.saved_prices')}</h4>
{Object.keys(modelPrices).length > 0 ? (
<div className={styles.pricesGrid}>
{Object.entries(modelPrices).map(([model, price]) => (
<div key={model} className={styles.priceItem}>
<div className={styles.priceInfo}>
<span className={styles.priceModel}>{model}</span>
<div className={styles.priceMeta}>
<span>
{t('usage_stats.model_price_prompt')}: ${price.prompt.toFixed(4)}/1M
</span>
<span>
{t('usage_stats.model_price_completion')}: ${price.completion.toFixed(4)}/1M
</span>
<span>
{t('usage_stats.model_price_cache')}: ${price.cache.toFixed(4)}/1M
</span>
</div>
</div>
<div className={styles.priceActions}>
<Button variant="secondary" size="sm" onClick={() => handleOpenEdit(model)}>
{t('common.edit')}
</Button>
<Button variant="danger" size="sm" onClick={() => handleDeletePrice(model)}>
{t('common.delete')}
</Button>
</div>
</div>
))}
</div>
) : (
<div className={styles.hint}>{t('usage_stats.model_price_empty')}</div>
)}
</div>
</div>
{/* Edit Modal */}
<Modal
open={editModel !== null}
title={editModel ?? ''}
onClose={() => setEditModel(null)}
footer={
<div className={styles.priceActions}>
<Button variant="secondary" onClick={() => setEditModel(null)}>
{t('common.cancel')}
</Button>
<Button variant="primary" onClick={handleSaveEdit}>
{t('common.save')}
</Button>
</div>
}
width={420}
>
<div className={styles.editModalBody}>
<div className={styles.formField}>
<label>{t('usage_stats.model_price_prompt')} ($/1M)</label>
<Input
type="number"
value={editPrompt}
onChange={(e) => setEditPrompt(e.target.value)}
placeholder="0.00"
step="0.0001"
/>
</div>
<div className={styles.formField}>
<label>{t('usage_stats.model_price_completion')} ($/1M)</label>
<Input
type="number"
value={editCompletion}
onChange={(e) => setEditCompletion(e.target.value)}
placeholder="0.00"
step="0.0001"
/>
</div>
<div className={styles.formField}>
<label>{t('usage_stats.model_price_cache')} ($/1M)</label>
<Input
type="number"
value={editCache}
onChange={(e) => setEditCache(e.target.value)}
placeholder="0.00"
step="0.0001"
/>
</div>
</div>
</Modal>
</Card>
);
}
@@ -1,616 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { EmptyState } from '@/components/ui/EmptyState';
import { Select } from '@/components/ui/Select';
import { authFilesApi } from '@/services/api/authFiles';
import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from '@/types';
import type { AuthFileItem } from '@/types/authFile';
import type { CredentialInfo } from '@/types/sourceInfo';
import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver';
import { parseTimestampMs } from '@/utils/timestamp';
import {
collectUsageDetails,
extractLatencyMs,
extractTotalTokens,
formatDurationMs,
LATENCY_SOURCE_FIELD,
normalizeAuthIndex,
type UsageThinking,
} from '@/utils/usage';
import { downloadBlob } from '@/utils/download';
import styles from '@/pages/UsagePage.module.scss';
const ALL_FILTER = '__all__';
const MAX_RENDERED_EVENTS = 500;
type RequestEventRow = {
id: string;
timestamp: string;
timestampMs: number;
timestampLabel: string;
model: string;
sourceKey: string;
sourceRaw: string;
source: string;
sourceType: string;
authIndex: string;
failed: boolean;
latencyMs: number | null;
thinking: UsageThinking | null;
thinkingLabel: string;
inputTokens: number;
outputTokens: number;
reasoningTokens: number;
cachedTokens: number;
totalTokens: number;
};
export interface RequestEventsDetailsCardProps {
usage: unknown;
loading: boolean;
geminiKeys: GeminiKeyConfig[];
claudeConfigs: ProviderKeyConfig[];
codexConfigs: ProviderKeyConfig[];
vertexConfigs: ProviderKeyConfig[];
openaiProviders: OpenAIProviderConfig[];
}
const toNumber = (value: unknown): number => {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 0;
return parsed;
};
const normalizeThinkingText = (value: unknown): string => {
if (typeof value !== 'string') return '';
return value.trim();
};
const formatThinkingLabel = (thinking: UsageThinking | null): string => {
if (!thinking) return '-';
const intensity = normalizeThinkingText(thinking.intensity);
const level = normalizeThinkingText(thinking.level);
const mode = normalizeThinkingText(thinking.mode);
const budget =
typeof thinking.budget === 'number' && Number.isFinite(thinking.budget)
? thinking.budget
: null;
const label = intensity || level || (budget !== null ? String(budget) : mode);
const budgetLabel = budget !== null ? budget.toLocaleString() : null;
if (!label) return '-';
if (budgetLabel !== null && label === String(budget)) {
return budgetLabel;
}
if (mode === 'budget' && budget !== null && budget > 0) {
return `${label} (${budgetLabel})`;
}
if (budget === -1 && label !== 'auto') {
return `${label} (-1)`;
}
return label;
};
const encodeCsv = (value: string | number): string => {
const text = String(value ?? '');
const trimmedLeft = text.replace(/^\s+/, '');
const safeText = trimmedLeft && /^[=+\-@]/.test(trimmedLeft) ? `'${text}` : text;
return `"${safeText.replace(/"/g, '""')}"`;
};
export function RequestEventsDetailsCard({
usage,
loading,
geminiKeys,
claudeConfigs,
codexConfigs,
vertexConfigs,
openaiProviders,
}: RequestEventsDetailsCardProps) {
const { t, i18n } = useTranslation();
const latencyHint = t('usage_stats.latency_unit_hint', {
field: LATENCY_SOURCE_FIELD,
unit: t('usage_stats.duration_unit_ms'),
});
const [modelFilter, setModelFilter] = useState(ALL_FILTER);
const [sourceFilter, setSourceFilter] = useState(ALL_FILTER);
const [authIndexFilter, setAuthIndexFilter] = useState(ALL_FILTER);
const [authFileMap, setAuthFileMap] = useState<Map<string, CredentialInfo>>(new Map());
useEffect(() => {
let cancelled = false;
authFilesApi
.list()
.then((res) => {
if (cancelled) return;
const files = Array.isArray(res) ? res : (res as { files?: AuthFileItem[] })?.files;
if (!Array.isArray(files)) return;
const map = new Map<string, CredentialInfo>();
files.forEach((file) => {
const key = normalizeAuthIndex(file['auth_index'] ?? file.authIndex);
if (!key) return;
map.set(key, {
name: file.name || key,
type: (file.type || file.provider || '').toString(),
});
});
setAuthFileMap(map);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
const sourceInfoMap = useMemo(
() =>
buildSourceInfoMap({
geminiApiKeys: geminiKeys,
claudeApiKeys: claudeConfigs,
codexApiKeys: codexConfigs,
vertexApiKeys: vertexConfigs,
openaiCompatibility: openaiProviders,
}),
[claudeConfigs, codexConfigs, geminiKeys, openaiProviders, vertexConfigs]
);
const rows = useMemo<RequestEventRow[]>(() => {
const details = collectUsageDetails(usage);
const baseRows = details.map((detail, index) => {
const timestamp = detail.timestamp;
const timestampMs =
typeof detail.__timestampMs === 'number' && detail.__timestampMs > 0
? detail.__timestampMs
: parseTimestampMs(timestamp);
const date = Number.isNaN(timestampMs) ? null : new Date(timestampMs);
const sourceRaw = String(detail.source ?? '').trim();
const authIndexRaw = detail.auth_index as unknown;
const authIndex =
authIndexRaw === null || authIndexRaw === undefined || authIndexRaw === ''
? '-'
: String(authIndexRaw);
const sourceInfo = resolveSourceDisplay(sourceRaw, authIndexRaw, sourceInfoMap, authFileMap);
const source = sourceInfo.displayName;
const sourceKey = sourceInfo.identityKey ?? `source:${sourceRaw || source}`;
const sourceType = sourceInfo.type;
const model = String(detail.__modelName ?? '').trim() || '-';
const inputTokens = Math.max(toNumber(detail.tokens?.input_tokens), 0);
const outputTokens = Math.max(toNumber(detail.tokens?.output_tokens), 0);
const reasoningTokens = Math.max(toNumber(detail.tokens?.reasoning_tokens), 0);
const cachedTokens = Math.max(
Math.max(toNumber(detail.tokens?.cached_tokens), 0),
Math.max(toNumber(detail.tokens?.cache_tokens), 0)
);
const totalTokens = Math.max(
toNumber(detail.tokens?.total_tokens),
extractTotalTokens(detail)
);
const latencyMs = extractLatencyMs(detail);
const thinking = detail.thinking ?? null;
const thinkingLabel = formatThinkingLabel(thinking);
return {
id: `${timestamp}-${model}-${sourceKey}-${authIndex}-${index}`,
timestamp,
timestampMs: Number.isNaN(timestampMs) ? 0 : timestampMs,
timestampLabel: date ? date.toLocaleString(i18n.language) : timestamp || '-',
model,
sourceKey,
sourceRaw: sourceRaw || '-',
source,
sourceType,
authIndex,
failed: detail.failed === true,
latencyMs,
thinking,
thinkingLabel,
inputTokens,
outputTokens,
reasoningTokens,
cachedTokens,
totalTokens,
};
});
const sourceLabelKeyMap = new Map<string, Set<string>>();
baseRows.forEach((row) => {
const keys = sourceLabelKeyMap.get(row.source) ?? new Set<string>();
keys.add(row.sourceKey);
sourceLabelKeyMap.set(row.source, keys);
});
const buildDisambiguatedSourceLabel = (row: RequestEventRow) => {
const labelKeyCount = sourceLabelKeyMap.get(row.source)?.size ?? 0;
if (labelKeyCount <= 1) {
return row.source;
}
if (row.authIndex !== '-') {
return `${row.source} · ${row.authIndex}`;
}
if (row.sourceRaw !== '-' && row.sourceRaw !== row.source) {
return `${row.source} · ${row.sourceRaw}`;
}
if (row.sourceType) {
return `${row.source} · ${row.sourceType}`;
}
return `${row.source} · ${row.sourceKey}`;
};
return baseRows
.map((row) => ({
...row,
source: buildDisambiguatedSourceLabel(row),
}))
.sort((a, b) => b.timestampMs - a.timestampMs);
}, [authFileMap, i18n.language, sourceInfoMap, usage]);
const hasLatencyData = useMemo(() => rows.some((row) => row.latencyMs !== null), [rows]);
const modelOptions = useMemo(
() => [
{ value: ALL_FILTER, label: t('usage_stats.filter_all') },
...Array.from(new Set(rows.map((row) => row.model))).map((model) => ({
value: model,
label: model,
})),
],
[rows, t]
);
const sourceOptions = useMemo(() => {
const optionMap = new Map<string, string>();
rows.forEach((row) => {
if (!optionMap.has(row.sourceKey)) {
optionMap.set(row.sourceKey, row.source);
}
});
return [
{ value: ALL_FILTER, label: t('usage_stats.filter_all') },
...Array.from(optionMap.entries()).map(([value, label]) => ({
value,
label,
})),
];
}, [rows, t]);
const authIndexOptions = useMemo(
() => [
{ value: ALL_FILTER, label: t('usage_stats.filter_all') },
...Array.from(new Set(rows.map((row) => row.authIndex))).map((authIndex) => ({
value: authIndex,
label: authIndex,
})),
],
[rows, t]
);
const modelOptionSet = useMemo(
() => new Set(modelOptions.map((option) => option.value)),
[modelOptions]
);
const sourceOptionSet = useMemo(
() => new Set(sourceOptions.map((option) => option.value)),
[sourceOptions]
);
const authIndexOptionSet = useMemo(
() => new Set(authIndexOptions.map((option) => option.value)),
[authIndexOptions]
);
const effectiveModelFilter = modelOptionSet.has(modelFilter) ? modelFilter : ALL_FILTER;
const effectiveSourceFilter = sourceOptionSet.has(sourceFilter) ? sourceFilter : ALL_FILTER;
const effectiveAuthIndexFilter = authIndexOptionSet.has(authIndexFilter)
? authIndexFilter
: ALL_FILTER;
const filteredRows = useMemo(
() =>
rows.filter((row) => {
const modelMatched =
effectiveModelFilter === ALL_FILTER || row.model === effectiveModelFilter;
const sourceMatched =
effectiveSourceFilter === ALL_FILTER || row.sourceKey === effectiveSourceFilter;
const authIndexMatched =
effectiveAuthIndexFilter === ALL_FILTER || row.authIndex === effectiveAuthIndexFilter;
return modelMatched && sourceMatched && authIndexMatched;
}),
[effectiveAuthIndexFilter, effectiveModelFilter, effectiveSourceFilter, rows]
);
const renderedRows = useMemo(() => filteredRows.slice(0, MAX_RENDERED_EVENTS), [filteredRows]);
const hasActiveFilters =
effectiveModelFilter !== ALL_FILTER ||
effectiveSourceFilter !== ALL_FILTER ||
effectiveAuthIndexFilter !== ALL_FILTER;
const handleClearFilters = () => {
setModelFilter(ALL_FILTER);
setSourceFilter(ALL_FILTER);
setAuthIndexFilter(ALL_FILTER);
};
const handleExportCsv = () => {
if (!filteredRows.length) return;
const csvHeader = [
'timestamp',
'model',
'source',
'source_raw',
'auth_index',
'result',
...(hasLatencyData ? ['latency_ms'] : []),
'thinking_intensity',
'thinking_mode',
'thinking_level',
'thinking_budget',
'input_tokens',
'output_tokens',
'reasoning_tokens',
'cached_tokens',
'total_tokens',
];
const csvRows = filteredRows.map((row) =>
[
row.timestamp,
row.model,
row.source,
row.sourceRaw,
row.authIndex,
row.failed ? 'failed' : 'success',
...(hasLatencyData ? [row.latencyMs ?? ''] : []),
row.thinking?.intensity ?? '',
row.thinking?.mode ?? '',
row.thinking?.level ?? '',
row.thinking?.budget ?? '',
row.inputTokens,
row.outputTokens,
row.reasoningTokens,
row.cachedTokens,
row.totalTokens,
]
.map((value) => encodeCsv(value))
.join(',')
);
const content = [csvHeader.join(','), ...csvRows].join('\n');
const fileTime = new Date().toISOString().replace(/[:.]/g, '-');
downloadBlob({
filename: `usage-events-${fileTime}.csv`,
blob: new Blob([content], { type: 'text/csv;charset=utf-8' }),
});
};
const handleExportJson = () => {
if (!filteredRows.length) return;
const payload = filteredRows.map((row) => ({
timestamp: row.timestamp,
model: row.model,
source: row.source,
source_raw: row.sourceRaw,
auth_index: row.authIndex,
failed: row.failed,
...(hasLatencyData && row.latencyMs !== null ? { latency_ms: row.latencyMs } : {}),
...(row.thinking ? { thinking: row.thinking } : {}),
tokens: {
input_tokens: row.inputTokens,
output_tokens: row.outputTokens,
reasoning_tokens: row.reasoningTokens,
cached_tokens: row.cachedTokens,
total_tokens: row.totalTokens,
},
}));
const content = JSON.stringify(payload, null, 2);
const fileTime = new Date().toISOString().replace(/[:.]/g, '-');
downloadBlob({
filename: `usage-events-${fileTime}.json`,
blob: new Blob([content], { type: 'application/json;charset=utf-8' }),
});
};
return (
<Card
title={t('usage_stats.request_events_title')}
extra={
<div className={styles.requestEventsActions}>
<Button
variant="ghost"
size="sm"
onClick={handleClearFilters}
disabled={!hasActiveFilters}
>
{t('usage_stats.clear_filters')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleExportCsv}
disabled={filteredRows.length === 0}
>
{t('usage_stats.export_csv')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleExportJson}
disabled={filteredRows.length === 0}
>
{t('usage_stats.export_json')}
</Button>
</div>
}
>
<div className={styles.requestEventsToolbar}>
<div className={styles.requestEventsFilterItem}>
<span className={styles.requestEventsFilterLabel}>
{t('usage_stats.request_events_filter_model')}
</span>
<Select
value={effectiveModelFilter}
options={modelOptions}
onChange={setModelFilter}
className={styles.requestEventsSelect}
ariaLabel={t('usage_stats.request_events_filter_model')}
fullWidth={false}
/>
</div>
<div className={styles.requestEventsFilterItem}>
<span className={styles.requestEventsFilterLabel}>
{t('usage_stats.request_events_filter_source')}
</span>
<Select
value={effectiveSourceFilter}
options={sourceOptions}
onChange={setSourceFilter}
className={styles.requestEventsSelect}
ariaLabel={t('usage_stats.request_events_filter_source')}
fullWidth={false}
/>
</div>
<div className={styles.requestEventsFilterItem}>
<span className={styles.requestEventsFilterLabel}>
{t('usage_stats.request_events_filter_auth_index')}
</span>
<Select
value={effectiveAuthIndexFilter}
options={authIndexOptions}
onChange={setAuthIndexFilter}
className={styles.requestEventsSelect}
ariaLabel={t('usage_stats.request_events_filter_auth_index')}
fullWidth={false}
/>
</div>
</div>
{loading && rows.length === 0 ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : rows.length === 0 ? (
<EmptyState
title={t('usage_stats.request_events_empty_title')}
description={t('usage_stats.request_events_empty_desc')}
/>
) : filteredRows.length === 0 ? (
<EmptyState
title={t('usage_stats.request_events_no_result_title')}
description={t('usage_stats.request_events_no_result_desc')}
/>
) : (
<>
<div className={styles.requestEventsMeta}>
<span>{t('usage_stats.request_events_count', { count: filteredRows.length })}</span>
{hasLatencyData && <span className={styles.requestEventsLimitHint}>{latencyHint}</span>}
{filteredRows.length > MAX_RENDERED_EVENTS && (
<span className={styles.requestEventsLimitHint}>
{t('usage_stats.request_events_limit_hint', {
shown: MAX_RENDERED_EVENTS,
total: filteredRows.length,
})}
</span>
)}
</div>
<div className={styles.requestEventsTableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>{t('usage_stats.request_events_timestamp')}</th>
<th>{t('usage_stats.model_name')}</th>
<th>{t('usage_stats.request_events_source')}</th>
<th>{t('usage_stats.request_events_auth_index')}</th>
<th>{t('usage_stats.request_events_result')}</th>
{hasLatencyData && <th title={latencyHint}>{t('usage_stats.time')}</th>}
<th>{t('usage_stats.thinking_intensity')}</th>
<th>{t('usage_stats.input_tokens')}</th>
<th>{t('usage_stats.output_tokens')}</th>
<th>{t('usage_stats.reasoning_tokens')}</th>
<th>{t('usage_stats.cached_tokens')}</th>
<th>{t('usage_stats.total_tokens')}</th>
</tr>
</thead>
<tbody>
{renderedRows.map((row) => (
<tr key={row.id}>
<td title={row.timestamp} className={styles.requestEventsTimestamp}>
{row.timestampLabel}
</td>
<td className={styles.modelCell}>{row.model}</td>
<td className={styles.requestEventsSourceCell} title={row.source}>
<span>{row.source}</span>
{row.sourceType && (
<span className={styles.credentialType}>{row.sourceType}</span>
)}
</td>
<td className={styles.requestEventsAuthIndex} title={row.authIndex}>
{row.authIndex}
</td>
<td>
<span
className={
row.failed
? styles.requestEventsResultFailed
: styles.requestEventsResultSuccess
}
>
{row.failed ? t('stats.failure') : t('stats.success')}
</span>
</td>
{hasLatencyData && (
<td className={styles.durationCell}>{formatDurationMs(row.latencyMs)}</td>
)}
<td>
<span
className={
row.thinking
? styles.requestEventsThinkingBadge
: styles.requestEventsThinkingEmpty
}
title={
row.thinking
? [
row.thinking.mode
? `${t('usage_stats.thinking_mode')}: ${row.thinking.mode}`
: '',
row.thinking.level
? `${t('usage_stats.thinking_level')}: ${row.thinking.level}`
: '',
typeof row.thinking.budget === 'number'
? `${t('usage_stats.thinking_budget')}: ${row.thinking.budget.toLocaleString()}`
: '',
]
.filter(Boolean)
.join(' · ')
: undefined
}
>
{row.thinkingLabel}
</span>
</td>
<td>{row.inputTokens.toLocaleString()}</td>
<td>{row.outputTokens.toLocaleString()}</td>
<td>{row.reasoningTokens.toLocaleString()}</td>
<td>{row.cachedTokens.toLocaleString()}</td>
<td>{row.totalTokens.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</Card>
);
}
-273
View File
@@ -1,273 +0,0 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import {
collectUsageDetails,
calculateServiceHealthData,
type ServiceHealthData,
type StatusBlockDetail,
} from '@/utils/usage';
import type { UsagePayload } from './hooks/useUsageData';
import styles from '@/pages/UsagePage.module.scss';
const COLOR_STOPS = [
{ r: 239, g: 68, b: 68 }, // #ef4444
{ r: 250, g: 204, b: 21 }, // #facc15
{ r: 34, g: 197, b: 94 }, // #22c55e
] as const;
const TOOLTIP_OFFSET = 8;
const TOOLTIP_SAFE_WIDTH = 180;
const TOOLTIP_SAFE_HEIGHT = 72;
type TooltipHorizontalPosition = 'center' | 'left' | 'right';
type TooltipVerticalPosition = 'above' | 'below';
interface ActiveTooltipState {
idx: number;
anchorEl: HTMLDivElement;
horizontal: TooltipHorizontalPosition;
vertical: TooltipVerticalPosition;
left: number;
top: number;
transform: string;
}
function rateToColor(rate: number): string {
const t = Math.max(0, Math.min(1, rate));
const segment = t < 0.5 ? 0 : 1;
const localT = segment === 0 ? t * 2 : (t - 0.5) * 2;
const from = COLOR_STOPS[segment];
const to = COLOR_STOPS[segment + 1];
const r = Math.round(from.r + (to.r - from.r) * localT);
const g = Math.round(from.g + (to.g - from.g) * localT);
const b = Math.round(from.b + (to.b - from.b) * localT);
return `rgb(${r}, ${g}, ${b})`;
}
function formatDateTime(timestamp: number): string {
const date = new Date(timestamp);
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
const h = date.getHours().toString().padStart(2, '0');
const m = date.getMinutes().toString().padStart(2, '0');
return `${month}/${day} ${h}:${m}`;
}
export interface ServiceHealthCardProps {
usage: UsagePayload | null;
loading: boolean;
}
export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
const { t } = useTranslation();
const [activeTooltip, setActiveTooltip] = useState<ActiveTooltipState | null>(null);
const gridRef = useRef<HTMLDivElement>(null);
const healthData: ServiceHealthData = useMemo(() => {
const details = usage ? collectUsageDetails(usage) : [];
return calculateServiceHealthData(details);
}, [usage]);
const hasData = healthData.totalSuccess + healthData.totalFailure > 0;
useEffect(() => {
if (activeTooltip === null) return;
const handler = (e: PointerEvent) => {
if (gridRef.current && !gridRef.current.contains(e.target as Node)) {
setActiveTooltip(null);
}
};
document.addEventListener('pointerdown', handler);
return () => document.removeEventListener('pointerdown', handler);
}, [activeTooltip]);
const buildTooltipState = useCallback(
(idx: number, anchorEl: HTMLDivElement | null): ActiveTooltipState | null => {
if (!anchorEl || !anchorEl.isConnected) {
return null;
}
const rect = anchorEl.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
let horizontal: TooltipHorizontalPosition = 'center';
let left = centerX;
if (centerX <= TOOLTIP_SAFE_WIDTH / 2) {
horizontal = 'left';
left = rect.left;
} else if (centerX >= window.innerWidth - TOOLTIP_SAFE_WIDTH / 2) {
horizontal = 'right';
left = rect.right;
}
const vertical: TooltipVerticalPosition = rect.top <= TOOLTIP_SAFE_HEIGHT ? 'below' : 'above';
const top = vertical === 'below' ? rect.bottom + TOOLTIP_OFFSET : rect.top - TOOLTIP_OFFSET;
const translateX = horizontal === 'center' ? '-50%' : horizontal === 'right' ? '-100%' : '0';
const translateY = vertical === 'below' ? '0' : '-100%';
return {
idx,
anchorEl,
horizontal,
vertical,
left: Math.round(left),
top: Math.round(top),
transform: `translate(${translateX}, ${translateY})`,
};
},
[]
);
useEffect(() => {
if (!activeTooltip) return;
const updateTooltipPosition = () => {
if (!document.body.contains(activeTooltip.anchorEl)) {
setActiveTooltip(null);
return;
}
setActiveTooltip(buildTooltipState(activeTooltip.idx, activeTooltip.anchorEl));
};
window.addEventListener('resize', updateTooltipPosition);
window.addEventListener('scroll', updateTooltipPosition, true);
return () => {
window.removeEventListener('resize', updateTooltipPosition);
window.removeEventListener('scroll', updateTooltipPosition, true);
};
}, [activeTooltip, buildTooltipState]);
const openTooltip = useCallback(
(idx: number, anchorEl: HTMLDivElement) => {
setActiveTooltip(buildTooltipState(idx, anchorEl));
},
[buildTooltipState]
);
const handlePointerEnter = useCallback(
(e: React.PointerEvent<HTMLDivElement>, idx: number) => {
if (e.pointerType === 'mouse') {
openTooltip(idx, e.currentTarget);
}
},
[openTooltip]
);
const handlePointerLeave = useCallback((e: React.PointerEvent) => {
if (e.pointerType === 'mouse') {
setActiveTooltip(null);
}
}, []);
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>, idx: number) => {
if (e.pointerType === 'touch') {
e.preventDefault();
const anchorEl = e.currentTarget;
setActiveTooltip((prev) => (prev?.idx === idx ? null : buildTooltipState(idx, anchorEl)));
}
},
[buildTooltipState]
);
const renderTooltip = (detail: StatusBlockDetail, tooltipState: ActiveTooltipState) => {
const total = detail.success + detail.failure;
const posClass =
tooltipState.horizontal === 'left'
? styles.healthTooltipLeft
: tooltipState.horizontal === 'right'
? styles.healthTooltipRight
: '';
const vertClass = tooltipState.vertical === 'below' ? styles.healthTooltipBelow : '';
const timeRange = `${formatDateTime(detail.startTime)} ${formatDateTime(detail.endTime)}`;
const tooltip = (
<div
className={`${styles.healthTooltip} ${posClass} ${vertClass}`}
style={{
position: 'fixed',
left: `${tooltipState.left}px`,
top: `${tooltipState.top}px`,
bottom: 'auto',
right: 'auto',
transform: tooltipState.transform,
}}
>
<span className={styles.healthTooltipTime}>{timeRange}</span>
{total > 0 ? (
<span className={styles.healthTooltipStats}>
<span className={styles.healthTooltipSuccess}>
{t('status_bar.success_short')} {detail.success}
</span>
<span className={styles.healthTooltipFailure}>
{t('status_bar.failure_short')} {detail.failure}
</span>
<span className={styles.healthTooltipRate}>({(detail.rate * 100).toFixed(1)}%)</span>
</span>
) : (
<span className={styles.healthTooltipStats}>{t('status_bar.no_requests')}</span>
)}
</div>
);
return typeof document === 'undefined' ? tooltip : createPortal(tooltip, document.body);
};
const rateClass = !hasData
? ''
: healthData.successRate >= 90
? styles.healthRateHigh
: healthData.successRate >= 50
? styles.healthRateMedium
: styles.healthRateLow;
return (
<div className={styles.healthCard}>
<div className={styles.healthHeader}>
<h3 className={styles.healthTitle}>{t('service_health.title')}</h3>
<div className={styles.healthMeta}>
<span className={styles.healthWindow}>{t('service_health.window')}</span>
<span className={`${styles.healthRate} ${rateClass}`}>
{loading ? '--' : hasData ? `${healthData.successRate.toFixed(1)}%` : '--'}
</span>
</div>
</div>
<div className={styles.healthGridScroller}>
<div className={styles.healthGrid} ref={gridRef}>
{healthData.blockDetails.map((detail, idx) => {
const isIdle = detail.rate === -1;
const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) };
const isActive = activeTooltip?.idx === idx;
return (
<div
key={idx}
className={`${styles.healthBlockWrapper} ${isActive ? styles.healthBlockActive : ''}`}
onPointerEnter={(e) => handlePointerEnter(e, idx)}
onPointerLeave={handlePointerLeave}
onPointerDown={(e) => handlePointerDown(e, idx)}
>
<div
className={`${styles.healthBlock} ${isIdle ? styles.healthBlockIdle : ''}`}
style={blockStyle}
/>
{isActive && activeTooltip && renderTooltip(detail, activeTooltip)}
</div>
);
})}
</div>
</div>
<div className={styles.healthLegend}>
<span className={styles.healthLegendLabel}>{t('service_health.oldest')}</span>
<div className={styles.healthLegendColors}>
<div className={`${styles.healthLegendBlock} ${styles.healthBlockIdle}`} />
<div className={styles.healthLegendBlock} style={{ backgroundColor: '#ef4444' }} />
<div className={styles.healthLegendBlock} style={{ backgroundColor: '#facc15' }} />
<div className={styles.healthLegendBlock} style={{ backgroundColor: '#22c55e' }} />
</div>
<span className={styles.healthLegendLabel}>{t('service_health.newest')}</span>
</div>
</div>
);
}
-278
View File
@@ -1,278 +0,0 @@
import { useMemo, type CSSProperties, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Line } from 'react-chartjs-2';
import {
IconDiamond,
IconDollarSign,
IconSatellite,
IconTimer,
IconTrendingUp,
} from '@/components/ui/icons';
import {
LATENCY_SOURCE_FIELD,
calculateLatencyStatsFromDetails,
calculateCost,
formatCompactNumber,
formatDurationMs,
formatPerMinuteValue,
formatUsd,
collectUsageDetails,
extractTotalTokens,
type ModelPrice,
} from '@/utils/usage';
import { sparklineOptions } from '@/utils/usage/chartConfig';
import type { UsagePayload } from './hooks/useUsageData';
import type { SparklineBundle } from './hooks/useSparklines';
import styles from '@/pages/UsagePage.module.scss';
interface StatCardData {
key: string;
label: string;
icon: ReactNode;
accent: string;
accentSoft: string;
accentBorder: string;
value: string;
meta?: ReactNode;
trend: SparklineBundle | null;
}
export interface StatCardsProps {
usage: UsagePayload | null;
loading: boolean;
modelPrices: Record<string, ModelPrice>;
nowMs: number;
sparklines: {
requests: SparklineBundle | null;
tokens: SparklineBundle | null;
rpm: SparklineBundle | null;
tpm: SparklineBundle | null;
cost: SparklineBundle | null;
};
}
export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: StatCardsProps) {
const { t } = useTranslation();
const latencyHint = t('usage_stats.latency_unit_hint', {
field: LATENCY_SOURCE_FIELD,
unit: t('usage_stats.duration_unit_ms'),
});
const hasPrices = Object.keys(modelPrices).length > 0;
const { tokenBreakdown, rateStats, totalCost, latencyStats } = useMemo(() => {
const empty = {
tokenBreakdown: { cachedTokens: 0, reasoningTokens: 0 },
rateStats: { rpm: 0, tpm: 0, windowMinutes: 30, requestCount: 0, tokenCount: 0 },
totalCost: 0,
latencyStats: {
averageMs: null as number | null,
totalMs: null as number | null,
sampleCount: 0,
},
};
if (!usage) return empty;
const details = collectUsageDetails(usage);
if (!details.length) return empty;
const latencyStats = calculateLatencyStatsFromDetails(details);
let cachedTokens = 0;
let reasoningTokens = 0;
let totalCost = 0;
const now = nowMs;
const windowMinutes = 30;
const windowStart = now - windowMinutes * 60 * 1000;
let requestCount = 0;
let tokenCount = 0;
const hasValidNow = Number.isFinite(now) && now > 0;
details.forEach((detail) => {
const tokens = detail.tokens;
cachedTokens += Math.max(
typeof tokens.cached_tokens === 'number' ? Math.max(tokens.cached_tokens, 0) : 0,
typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0
);
if (typeof tokens.reasoning_tokens === 'number') {
reasoningTokens += tokens.reasoning_tokens;
}
const timestamp = detail.__timestampMs ?? 0;
if (
hasValidNow &&
Number.isFinite(timestamp) &&
timestamp >= windowStart &&
timestamp <= now
) {
requestCount += 1;
tokenCount += extractTotalTokens(detail);
}
if (hasPrices) {
totalCost += calculateCost(detail, modelPrices);
}
});
const denominator = windowMinutes > 0 ? windowMinutes : 1;
return {
tokenBreakdown: { cachedTokens, reasoningTokens },
rateStats: {
rpm: requestCount / denominator,
tpm: tokenCount / denominator,
windowMinutes,
requestCount,
tokenCount,
},
totalCost,
latencyStats,
};
}, [hasPrices, modelPrices, nowMs, usage]);
const statsCards: StatCardData[] = [
{
key: 'requests',
label: t('usage_stats.total_requests'),
icon: <IconSatellite size={16} />,
accent: '#8b8680',
accentSoft: 'rgba(139, 134, 128, 0.18)',
accentBorder: 'rgba(139, 134, 128, 0.35)',
value: loading ? '-' : (usage?.total_requests ?? 0).toLocaleString(),
meta: (
<>
<span className={styles.statMetaItem}>
<span className={styles.statMetaDot} style={{ backgroundColor: '#10b981' }} />
{t('usage_stats.success_requests')}: {loading ? '-' : (usage?.success_count ?? 0)}
</span>
<span className={styles.statMetaItem}>
<span className={styles.statMetaDot} style={{ backgroundColor: '#c65746' }} />
{t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)}
</span>
{latencyStats.sampleCount > 0 && (
<span className={styles.statMetaItem} title={latencyHint}>
{t('usage_stats.avg_time')}:{' '}
{loading ? '-' : formatDurationMs(latencyStats.averageMs)}
</span>
)}
</>
),
trend: sparklines.requests,
},
{
key: 'tokens',
label: t('usage_stats.total_tokens'),
icon: <IconDiamond size={16} />,
accent: '#8b5cf6',
accentSoft: 'rgba(139, 92, 246, 0.18)',
accentBorder: 'rgba(139, 92, 246, 0.35)',
value: loading ? '-' : formatCompactNumber(usage?.total_tokens ?? 0),
meta: (
<>
<span className={styles.statMetaItem}>
{t('usage_stats.cached_tokens')}:{' '}
{loading ? '-' : formatCompactNumber(tokenBreakdown.cachedTokens)}
</span>
<span className={styles.statMetaItem}>
{t('usage_stats.reasoning_tokens')}:{' '}
{loading ? '-' : formatCompactNumber(tokenBreakdown.reasoningTokens)}
</span>
</>
),
trend: sparklines.tokens,
},
{
key: 'rpm',
label: t('usage_stats.rpm_30m'),
icon: <IconTimer size={16} />,
accent: '#22c55e',
accentSoft: 'rgba(34, 197, 94, 0.18)',
accentBorder: 'rgba(34, 197, 94, 0.32)',
value: loading ? '-' : formatPerMinuteValue(rateStats.rpm),
meta: (
<span className={styles.statMetaItem}>
{t('usage_stats.total_requests')}:{' '}
{loading ? '-' : rateStats.requestCount.toLocaleString()}
</span>
),
trend: sparklines.rpm,
},
{
key: 'tpm',
label: t('usage_stats.tpm_30m'),
icon: <IconTrendingUp size={16} />,
accent: '#f97316',
accentSoft: 'rgba(249, 115, 22, 0.18)',
accentBorder: 'rgba(249, 115, 22, 0.32)',
value: loading ? '-' : formatPerMinuteValue(rateStats.tpm),
meta: (
<span className={styles.statMetaItem}>
{t('usage_stats.total_tokens')}:{' '}
{loading ? '-' : formatCompactNumber(rateStats.tokenCount)}
</span>
),
trend: sparklines.tpm,
},
{
key: 'cost',
label: t('usage_stats.total_cost'),
icon: <IconDollarSign size={16} />,
accent: '#f59e0b',
accentSoft: 'rgba(245, 158, 11, 0.18)',
accentBorder: 'rgba(245, 158, 11, 0.32)',
value: loading ? '-' : hasPrices ? formatUsd(totalCost) : '--',
meta: (
<>
<span className={styles.statMetaItem}>
{t('usage_stats.total_tokens')}:{' '}
{loading ? '-' : formatCompactNumber(usage?.total_tokens ?? 0)}
</span>
{!hasPrices && (
<span className={`${styles.statMetaItem} ${styles.statSubtle}`}>
{t('usage_stats.cost_need_price')}
</span>
)}
</>
),
trend: hasPrices ? sparklines.cost : null,
},
];
return (
<div className={styles.statsGrid}>
{statsCards.map((card) => (
<div
key={card.key}
className={styles.statCard}
style={
{
'--accent': card.accent,
'--accent-soft': card.accentSoft,
'--accent-border': card.accentBorder,
} as CSSProperties
}
>
<div className={styles.statCardHeader}>
<div className={styles.statLabelGroup}>
<span className={styles.statLabel}>{card.label}</span>
</div>
<span className={styles.statIconBadge}>{card.icon}</span>
</div>
<div className={styles.statValue}>{card.value}</div>
{card.meta && <div className={styles.statMetaRow}>{card.meta}</div>}
<div className={styles.statTrend}>
{card.trend ? (
<Line
className={styles.sparkline}
data={card.trend.data}
options={sparklineOptions}
/>
) : (
<div className={styles.statTrendPlaceholder}></div>
)}
</div>
</div>
))}
</div>
);
}
@@ -1,186 +0,0 @@
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { ChartData, ChartOptions, TooltipItem } from 'chart.js';
import { Bar } from 'react-chartjs-2';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import {
buildHourlyTokenBreakdown,
buildDailyTokenBreakdown,
type TokenCategory,
} from '@/utils/usage';
import { buildChartOptions, getHourChartMinWidth } from '@/utils/usage/chartConfig';
import type { UsagePayload } from './hooks/useUsageData';
import styles from '@/pages/UsagePage.module.scss';
const TOKEN_COLORS: Record<TokenCategory, string> = {
input: '#8CC21F',
output: '#FA6450',
cached: '#F5ED58',
reasoning: '#00ABA5',
};
const CATEGORIES: TokenCategory[] = ['input', 'output', 'cached', 'reasoning'];
function formatTokens(num: number): string {
if (num >= 1e6) return (num / 1e6).toFixed(2) + 'M';
if (num >= 1e3) return (num / 1e3).toFixed(2) + 'K';
return num.toString();
}
export interface TokenBreakdownChartProps {
usage: UsagePayload | null;
loading: boolean;
isDark: boolean;
isMobile: boolean;
hourWindowHours?: number;
}
export function TokenBreakdownChart({
usage,
loading,
isDark,
isMobile,
hourWindowHours,
}: TokenBreakdownChartProps) {
const { t } = useTranslation();
const [period, setPeriod] = useState<'hour' | 'day'>('hour');
const { chartData, chartOptions } = useMemo(() => {
const series =
period === 'hour'
? buildHourlyTokenBreakdown(usage, hourWindowHours)
: buildDailyTokenBreakdown(usage);
const categoryLabels: Record<TokenCategory, string> = {
input: t('usage_stats.input_tokens'),
output: t('usage_stats.output_tokens'),
cached: t('usage_stats.cached_tokens'),
reasoning: t('usage_stats.reasoning_tokens'),
};
const data: ChartData<'bar'> = {
labels: series.labels,
datasets: CATEGORIES.map((cat) => ({
label: categoryLabels[cat],
data: series.dataByCategory[cat],
backgroundColor: TOKEN_COLORS[cat],
borderColor: isDark ? '#0f172a' : '#ffffff',
borderWidth: 1,
borderSkipped: false,
grouped: true,
categoryPercentage: 0.82,
barPercentage: 0.88,
})),
};
const baseOptions = buildChartOptions({
period,
labels: series.labels,
isDark,
isMobile,
}) as ChartOptions<'bar'>;
const options: ChartOptions<'bar'> = {
...baseOptions,
scales: {
...baseOptions.scales,
y: {
...baseOptions.scales?.y,
stacked: false,
},
x: {
...baseOptions.scales?.x,
stacked: false,
},
},
plugins: {
...baseOptions.plugins,
tooltip: {
...baseOptions.plugins?.tooltip,
itemSort: (a, b) => a.datasetIndex - b.datasetIndex,
callbacks: {
...baseOptions.plugins?.tooltip?.callbacks,
label: function (context: TooltipItem<'bar'>) {
const val = Number(context.raw) || 0;
const cat = CATEGORIES[context.datasetIndex];
let text = `${context.dataset.label}: ${formatTokens(val)}`;
if (cat === 'cached') {
const inputVal = Number(series.dataByCategory.input[context.dataIndex]) || 0;
if (inputVal > 0) {
const perc = ((val / inputVal) * 100).toFixed(2);
text += ` (${perc}%)`;
}
}
return text;
},
},
},
},
};
return { chartData: data, chartOptions: options };
}, [usage, period, isDark, isMobile, hourWindowHours, t]);
const labels = chartData.labels ?? [];
return (
<Card
title={t('usage_stats.token_breakdown')}
extra={
<div className={styles.periodButtons}>
<Button
variant={period === 'hour' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setPeriod('hour')}
>
{t('usage_stats.by_hour')}
</Button>
<Button
variant={period === 'day' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setPeriod('day')}
>
{t('usage_stats.by_day')}
</Button>
</div>
}
>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : labels.length > 0 ? (
<div className={styles.chartWrapper}>
<div className={styles.chartLegend} aria-label="Chart legend">
{chartData.datasets.map((dataset, index) => (
<div
key={`${dataset.label}-${index}`}
className={styles.legendItem}
title={dataset.label}
>
<span
className={styles.legendDot}
style={{ backgroundColor: TOKEN_COLORS[CATEGORIES[index]] }}
/>
<span className={styles.legendLabel}>{dataset.label}</span>
</div>
))}
</div>
<div className={styles.chartArea}>
<div className={styles.chartScroller}>
<div
className={styles.chartCanvas}
style={
period === 'hour'
? { minWidth: getHourChartMinWidth(labels.length, isMobile) }
: undefined
}
>
<Bar data={chartData} options={chartOptions} />
</div>
</div>
</div>
</div>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
)}
</Card>
);
}
-91
View File
@@ -1,91 +0,0 @@
import { useTranslation } from 'react-i18next';
import type { ChartOptions } from 'chart.js';
import { Line } from 'react-chartjs-2';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import type { ChartData } from '@/utils/usage';
import { getHourChartMinWidth } from '@/utils/usage/chartConfig';
import styles from '@/pages/UsagePage.module.scss';
export interface UsageChartProps {
title: string;
period: 'hour' | 'day';
onPeriodChange: (period: 'hour' | 'day') => void;
chartData: ChartData;
chartOptions: ChartOptions<'line'>;
loading: boolean;
isMobile: boolean;
emptyText: string;
}
export function UsageChart({
title,
period,
onPeriodChange,
chartData,
chartOptions,
loading,
isMobile,
emptyText
}: UsageChartProps) {
const { t } = useTranslation();
return (
<Card
title={title}
extra={
<div className={styles.periodButtons}>
<Button
variant={period === 'hour' ? 'primary' : 'secondary'}
size="sm"
onClick={() => onPeriodChange('hour')}
>
{t('usage_stats.by_hour')}
</Button>
<Button
variant={period === 'day' ? 'primary' : 'secondary'}
size="sm"
onClick={() => onPeriodChange('day')}
>
{t('usage_stats.by_day')}
</Button>
</div>
}
>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : chartData.labels.length > 0 ? (
<div className={styles.chartWrapper}>
<div className={styles.chartLegend} aria-label="Chart legend">
{chartData.datasets.map((dataset, index) => (
<div
key={`${dataset.label}-${index}`}
className={styles.legendItem}
title={dataset.label}
>
<span className={styles.legendDot} style={{ backgroundColor: dataset.borderColor }} />
<span className={styles.legendLabel}>{dataset.label}</span>
</div>
))}
</div>
<div className={styles.chartArea}>
<div className={styles.chartScroller}>
<div
className={styles.chartCanvas}
style={
period === 'hour'
? { minWidth: getHourChartMinWidth(chartData.labels.length, isMobile) }
: undefined
}
>
<Line data={chartData} options={chartOptions} />
</div>
</div>
</div>
</div>
) : (
<div className={styles.hint}>{emptyText}</div>
)}
</Card>
);
}
-8
View File
@@ -1,8 +0,0 @@
export { useUsageData } from './useUsageData';
export type { UsagePayload, UseUsageDataReturn } from './useUsageData';
export { useSparklines } from './useSparklines';
export type { SparklineData, SparklineBundle, UseSparklinesOptions, UseSparklinesReturn } from './useSparklines';
export { useChartData } from './useChartData';
export type { UseChartDataOptions, UseChartDataReturn } from './useChartData';
@@ -1,78 +0,0 @@
import { useState, useMemo } from 'react';
import type { ChartOptions } from 'chart.js';
import { buildChartData, type ChartData } from '@/utils/usage';
import { buildChartOptions } from '@/utils/usage/chartConfig';
import type { UsagePayload } from './useUsageData';
export interface UseChartDataOptions {
usage: UsagePayload | null;
chartLines: string[];
isDark: boolean;
isMobile: boolean;
hourWindowHours?: number;
}
export interface UseChartDataReturn {
requestsPeriod: 'hour' | 'day';
setRequestsPeriod: (period: 'hour' | 'day') => void;
tokensPeriod: 'hour' | 'day';
setTokensPeriod: (period: 'hour' | 'day') => void;
requestsChartData: ChartData;
tokensChartData: ChartData;
requestsChartOptions: ChartOptions<'line'>;
tokensChartOptions: ChartOptions<'line'>;
}
export function useChartData({
usage,
chartLines,
isDark,
isMobile,
hourWindowHours
}: UseChartDataOptions): UseChartDataReturn {
const [requestsPeriod, setRequestsPeriod] = useState<'hour' | 'day'>('day');
const [tokensPeriod, setTokensPeriod] = useState<'hour' | 'day'>('day');
const requestsChartData = useMemo(() => {
if (!usage) return { labels: [], datasets: [] };
return buildChartData(usage, requestsPeriod, 'requests', chartLines, { hourWindowHours });
}, [usage, requestsPeriod, chartLines, hourWindowHours]);
const tokensChartData = useMemo(() => {
if (!usage) return { labels: [], datasets: [] };
return buildChartData(usage, tokensPeriod, 'tokens', chartLines, { hourWindowHours });
}, [usage, tokensPeriod, chartLines, hourWindowHours]);
const requestsChartOptions = useMemo(
() =>
buildChartOptions({
period: requestsPeriod,
labels: requestsChartData.labels,
isDark,
isMobile
}),
[requestsPeriod, requestsChartData.labels, isDark, isMobile]
);
const tokensChartOptions = useMemo(
() =>
buildChartOptions({
period: tokensPeriod,
labels: tokensChartData.labels,
isDark,
isMobile
}),
[tokensPeriod, tokensChartData.labels, isDark, isMobile]
);
return {
requestsPeriod,
setRequestsPeriod,
tokensPeriod,
setTokensPeriod,
requestsChartData,
tokensChartData,
requestsChartOptions,
tokensChartOptions
};
}
-165
View File
@@ -1,165 +0,0 @@
import { useCallback, useMemo } from 'react';
import { collectUsageDetails, extractTotalTokens } from '@/utils/usage';
import type { UsagePayload } from './useUsageData';
export interface SparklineData {
labels: string[];
datasets: [
{
data: number[];
borderColor: string;
backgroundColor: string;
fill: boolean;
tension: number;
pointRadius: number;
borderWidth: number;
}
];
}
export interface SparklineBundle {
data: SparklineData;
}
export interface UseSparklinesOptions {
usage: UsagePayload | null;
loading: boolean;
nowMs: number;
}
export interface UseSparklinesReturn {
requestsSparkline: SparklineBundle | null;
tokensSparkline: SparklineBundle | null;
rpmSparkline: SparklineBundle | null;
tpmSparkline: SparklineBundle | null;
costSparkline: SparklineBundle | null;
}
export function useSparklines({ usage, loading, nowMs }: UseSparklinesOptions): UseSparklinesReturn {
const lastHourSeries = useMemo(() => {
if (!usage) return { labels: [], requests: [], tokens: [] };
if (!Number.isFinite(nowMs) || nowMs <= 0) {
return { labels: [], requests: [], tokens: [] };
}
const details = collectUsageDetails(usage);
if (!details.length) return { labels: [], requests: [], tokens: [] };
const windowMinutes = 60;
const now = nowMs;
const windowStart = now - windowMinutes * 60 * 1000;
const requestBuckets = new Array(windowMinutes).fill(0);
const tokenBuckets = new Array(windowMinutes).fill(0);
details.forEach((detail) => {
const timestamp = detail.__timestampMs ?? 0;
if (!Number.isFinite(timestamp) || timestamp < windowStart || timestamp > now) {
return;
}
const minuteIndex = Math.min(
windowMinutes - 1,
Math.floor((timestamp - windowStart) / 60000)
);
requestBuckets[minuteIndex] += 1;
tokenBuckets[minuteIndex] += extractTotalTokens(detail);
});
const labels = requestBuckets.map((_, idx) => {
const date = new Date(windowStart + (idx + 1) * 60000);
const h = date.getHours().toString().padStart(2, '0');
const m = date.getMinutes().toString().padStart(2, '0');
return `${h}:${m}`;
});
return { labels, requests: requestBuckets, tokens: tokenBuckets };
}, [nowMs, usage]);
const buildSparkline = useCallback(
(
series: { labels: string[]; data: number[] },
color: string,
backgroundColor: string
): SparklineBundle | null => {
if (loading || !series?.data?.length) {
return null;
}
const sliceStart = Math.max(series.data.length - 60, 0);
const labels = series.labels.slice(sliceStart);
const points = series.data.slice(sliceStart);
return {
data: {
labels,
datasets: [
{
data: points,
borderColor: color,
backgroundColor,
fill: true,
tension: 0.45,
pointRadius: 0,
borderWidth: 2
}
]
}
};
},
[loading]
);
const requestsSparkline = useMemo(
() =>
buildSparkline(
{ labels: lastHourSeries.labels, data: lastHourSeries.requests },
'#8b8680',
'rgba(139, 134, 128, 0.18)'
),
[buildSparkline, lastHourSeries.labels, lastHourSeries.requests]
);
const tokensSparkline = useMemo(
() =>
buildSparkline(
{ labels: lastHourSeries.labels, data: lastHourSeries.tokens },
'#8b5cf6',
'rgba(139, 92, 246, 0.18)'
),
[buildSparkline, lastHourSeries.labels, lastHourSeries.tokens]
);
const rpmSparkline = useMemo(
() =>
buildSparkline(
{ labels: lastHourSeries.labels, data: lastHourSeries.requests },
'#22c55e',
'rgba(34, 197, 94, 0.18)'
),
[buildSparkline, lastHourSeries.labels, lastHourSeries.requests]
);
const tpmSparkline = useMemo(
() =>
buildSparkline(
{ labels: lastHourSeries.labels, data: lastHourSeries.tokens },
'#f97316',
'rgba(249, 115, 22, 0.18)'
),
[buildSparkline, lastHourSeries.labels, lastHourSeries.tokens]
);
const costSparkline = useMemo(
() =>
buildSparkline(
{ labels: lastHourSeries.labels, data: lastHourSeries.tokens },
'#f59e0b',
'rgba(245, 158, 11, 0.18)'
),
[buildSparkline, lastHourSeries.labels, lastHourSeries.tokens]
);
return {
requestsSparkline,
tokensSparkline,
rpmSparkline,
tpmSparkline,
costSparkline
};
}
-156
View File
@@ -1,156 +0,0 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { USAGE_STATS_STALE_TIME_MS, useNotificationStore, useUsageStatsStore } from '@/stores';
import { usageApi } from '@/services/api/usage';
import { downloadBlob } from '@/utils/download';
import { loadModelPrices, saveModelPrices, type ModelPrice } from '@/utils/usage';
export interface UsagePayload {
total_requests?: number;
success_count?: number;
failure_count?: number;
total_tokens?: number;
apis?: Record<string, unknown>;
[key: string]: unknown;
}
export interface UseUsageDataReturn {
usage: UsagePayload | null;
loading: boolean;
error: string;
lastRefreshedAt: Date | null;
modelPrices: Record<string, ModelPrice>;
setModelPrices: (prices: Record<string, ModelPrice>) => void;
loadUsage: () => Promise<void>;
handleExport: () => Promise<void>;
handleImport: () => void;
handleImportChange: (event: React.ChangeEvent<HTMLInputElement>) => Promise<void>;
importInputRef: React.RefObject<HTMLInputElement | null>;
exporting: boolean;
importing: boolean;
}
export function useUsageData(): UseUsageDataReturn {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const usageSnapshot = useUsageStatsStore((state) => state.usage);
const loading = useUsageStatsStore((state) => state.loading);
const storeError = useUsageStatsStore((state) => state.error);
const lastRefreshedAtTs = useUsageStatsStore((state) => state.lastRefreshedAt);
const loadUsageStats = useUsageStatsStore((state) => state.loadUsageStats);
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const importInputRef = useRef<HTMLInputElement | null>(null);
const loadUsage = useCallback(async () => {
await loadUsageStats({ force: true, staleTimeMs: USAGE_STATS_STALE_TIME_MS });
}, [loadUsageStats]);
useEffect(() => {
void loadUsageStats({ staleTimeMs: USAGE_STATS_STALE_TIME_MS }).catch(() => {});
setModelPrices(loadModelPrices());
}, [loadUsageStats]);
const handleExport = async () => {
setExporting(true);
try {
const data = await usageApi.exportUsage();
const exportedAt =
typeof data?.exported_at === 'string' ? new Date(data.exported_at) : new Date();
const safeTimestamp = Number.isNaN(exportedAt.getTime())
? new Date().toISOString()
: exportedAt.toISOString();
const filename = `usage-export-${safeTimestamp.replace(/[:.]/g, '-')}.json`;
downloadBlob({
filename,
blob: new Blob([JSON.stringify(data ?? {}, null, 2)], { type: 'application/json' })
});
showNotification(t('usage_stats.export_success'), 'success');
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
showNotification(
`${t('notification.download_failed')}${message ? `: ${message}` : ''}`,
'error'
);
} finally {
setExporting(false);
}
};
const handleImport = () => {
importInputRef.current?.click();
};
const handleImportChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
setImporting(true);
try {
const text = await file.text();
let payload: unknown;
try {
payload = JSON.parse(text);
} catch {
showNotification(t('usage_stats.import_invalid'), 'error');
return;
}
const result = await usageApi.importUsage(payload);
showNotification(
t('usage_stats.import_success', {
added: result?.added ?? 0,
skipped: result?.skipped ?? 0,
total: result?.total_requests ?? 0,
failed: result?.failed_requests ?? 0
}),
'success'
);
try {
await loadUsageStats({ force: true, staleTimeMs: USAGE_STATS_STALE_TIME_MS });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
showNotification(
`${t('notification.refresh_failed')}${message ? `: ${message}` : ''}`,
'error'
);
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
showNotification(
`${t('notification.upload_failed')}${message ? `: ${message}` : ''}`,
'error'
);
} finally {
setImporting(false);
}
};
const handleSetModelPrices = useCallback((prices: Record<string, ModelPrice>) => {
setModelPrices(prices);
saveModelPrices(prices);
}, []);
const usage = usageSnapshot as UsagePayload | null;
const error = storeError || '';
const lastRefreshedAt = lastRefreshedAtTs ? new Date(lastRefreshedAtTs) : null;
return {
usage,
loading,
error,
lastRefreshedAt,
modelPrices,
setModelPrices: handleSetModelPrices,
loadUsage,
handleExport,
handleImport,
handleImportChange,
importInputRef,
exporting,
importing
};
}
-43
View File
@@ -1,43 +0,0 @@
// Hooks
export { useUsageData } from './hooks/useUsageData';
export type { UsagePayload, UseUsageDataReturn } from './hooks/useUsageData';
export { useSparklines } from './hooks/useSparklines';
export type { SparklineData, SparklineBundle, UseSparklinesOptions, UseSparklinesReturn } from './hooks/useSparklines';
export { useChartData } from './hooks/useChartData';
export type { UseChartDataOptions, UseChartDataReturn } from './hooks/useChartData';
// Components
export { StatCards } from './StatCards';
export type { StatCardsProps } from './StatCards';
export { UsageChart } from './UsageChart';
export type { UsageChartProps } from './UsageChart';
export { ChartLineSelector } from './ChartLineSelector';
export type { ChartLineSelectorProps } from './ChartLineSelector';
export { ApiDetailsCard } from './ApiDetailsCard';
export type { ApiDetailsCardProps } from './ApiDetailsCard';
export { ModelStatsCard } from './ModelStatsCard';
export type { ModelStatsCardProps, ModelStat } from './ModelStatsCard';
export { PriceSettingsCard } from './PriceSettingsCard';
export type { PriceSettingsCardProps } from './PriceSettingsCard';
export { CredentialStatsCard } from './CredentialStatsCard';
export type { CredentialStatsCardProps } from './CredentialStatsCard';
export { TokenBreakdownChart } from './TokenBreakdownChart';
export type { TokenBreakdownChartProps } from './TokenBreakdownChart';
export { CostTrendChart } from './CostTrendChart';
export type { CostTrendChartProps } from './CostTrendChart';
export { ServiceHealthCard } from './ServiceHealthCard';
export type { ServiceHealthCardProps } from './ServiceHealthCard';
export { RequestEventsDetailsCard } from './RequestEventsDetailsCard';
export type { RequestEventsDetailsCardProps } from './RequestEventsDetailsCard';
-8
View File
@@ -614,12 +614,6 @@ function getNextDirtyFields(
nextValues.logsMaxTotalSizeMb === baselineValues.logsMaxTotalSizeMb
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'usageStatisticsEnabled')) {
updateDirty(
'usageStatisticsEnabled',
nextValues.usageStatisticsEnabled === baselineValues.usageStatisticsEnabled
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'proxyUrl')) {
updateDirty('proxyUrl', nextValues.proxyUrl === baselineValues.proxyUrl);
}
@@ -846,7 +840,6 @@ export function useVisualConfig() {
commercialMode: Boolean(parsed['commercial-mode']),
loggingToFile: Boolean(parsed['logging-to-file']),
logsMaxTotalSizeMb: String(parsed['logs-max-total-size-mb'] ?? ''),
usageStatisticsEnabled: Boolean(parsed['usage-statistics-enabled']),
proxyUrl: typeof parsed['proxy-url'] === 'string' ? parsed['proxy-url'] : '',
forceModelPrefix: Boolean(parsed['force-model-prefix']),
@@ -961,7 +954,6 @@ export function useVisualConfig() {
setBooleanInDoc(doc, ['commercial-mode'], values.commercialMode);
setBooleanInDoc(doc, ['logging-to-file'], values.loggingToFile);
setIntFromStringInDoc(doc, ['logs-max-total-size-mb'], values.logsMaxTotalSizeMb);
setBooleanInDoc(doc, ['usage-statistics-enabled'], values.usageStatisticsEnabled);
setStringInDoc(doc, ['proxy-url'], values.proxyUrl);
setBooleanInDoc(doc, ['force-model-prefix'], values.forceModelPrefix);
+2 -153
View File
@@ -115,7 +115,6 @@
"auth_files": "Auth Files",
"oauth": "OAuth Login",
"quota_management": "Quota Management",
"usage_stats": "Usage Statistics",
"config_management": "Config Panel",
"logs": "Logs Viewer",
"system_info": "Management Center Info"
@@ -129,14 +128,6 @@
"management_keys": "Management Keys",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} O:{{openai}}",
"oauth_credentials": "OAuth Credentials",
"usage_overview": "Usage Overview",
"total_requests": "Total Requests",
"total_tokens": "Total Tokens",
"rpm_30min": "RPM (30min)",
"tpm_30min": "TPM (30min)",
"models_used": "Models Used",
"no_usage_data": "No usage data available",
"view_detailed_usage": "View Detailed Stats",
"edit_settings": "Edit Settings",
"routing_strategy": "Routing Strategy",
"available_models": "Available Models",
@@ -167,8 +158,6 @@
"quota_title": "Quota Exceeded Behavior",
"quota_switch_project": "Auto Switch Project",
"quota_switch_preview": "Switch to Preview Model",
"usage_statistics_title": "Usage Statistics",
"usage_statistics_enable": "Enable usage statistics",
"logging_title": "Logging",
"logging_to_file_enable": "Enable logging to file",
"logs_max_total_size_title": "Log Size Limit",
@@ -938,115 +927,6 @@
"iflow_oauth_polling_error": "Failed to check authentication status:",
"remote_access_disabled": "This login method is not available for remote access. Please access from localhost."
},
"usage_stats": {
"title": "Usage Statistics",
"total_requests": "Total Requests",
"success_requests": "Success Requests",
"failed_requests": "Failed Requests",
"total_tokens": "Total Tokens",
"cached_tokens": "Cached Tokens",
"reasoning_tokens": "Reasoning Tokens",
"rpm_30m": "RPM",
"tpm_30m": "TPM",
"rate_30m": "Rate (last 30 min)",
"model_name": "Model Name",
"model_price_settings": "Model Pricing Settings",
"saved_prices": "Saved Prices",
"requests_trend": "Request Trends",
"tokens_trend": "Token Usage Trends",
"api_details": "API Details",
"by_hour": "By Hour",
"by_day": "By Day",
"range_filter": "Time Range",
"range_all": "All Time",
"range_7h": "Last 7 Hours",
"range_24h": "Last 24 Hours",
"range_7d": "Last 7 Days",
"filter_all": "All",
"clear_filters": "Clear Filters",
"refresh": "Refresh",
"export": "Export",
"import": "Import",
"export_csv": "Export CSV",
"export_json": "Export JSON",
"export_success": "Usage export downloaded",
"import_success": "Import complete: added {{added}}, skipped {{skipped}}, total {{total}}, failed {{failed}}",
"import_invalid": "Invalid usage export file",
"chart_line_label_1": "Line 1",
"chart_line_label_2": "Line 2",
"chart_line_label_3": "Line 3",
"chart_line_label_4": "Line 4",
"chart_line_label_5": "Line 5",
"chart_line_label_6": "Line 6",
"chart_line_label_7": "Line 7",
"chart_line_label_8": "Line 8",
"chart_line_label_9": "Line 9",
"chart_line_hidden": "Hide",
"chart_line_actions_label": "Lines to display",
"chart_line_add": "Add line",
"chart_line_all": "All",
"chart_line_delete": "Delete line",
"chart_line_hint": "Show up to 9 model lines at once",
"no_data": "No Data Available",
"loading_error": "Loading Failed",
"api_endpoint": "API Endpoint",
"requests_count": "Request Count",
"tokens_count": "Token Count",
"models": "Model Statistics",
"success_rate": "Success Rate",
"total_cost": "Total Cost",
"total_cost_hint": "Based on configured model pricing",
"model_price_title": "Model Pricing",
"model_price_reset": "Clear Prices",
"model_price_model_label": "Model",
"model_price_select_placeholder": "Choose a model",
"model_price_select_hint": "Models come from usage details",
"model_price_prompt": "Prompt price",
"model_price_completion": "Completion price",
"model_price_cache": "Cache price",
"model_price_save": "Save Price",
"model_price_empty": "No model prices set",
"model_price_model": "Model",
"model_price_saved": "Model price saved",
"model_price_model_required": "Please choose a model to set pricing",
"cost_trend": "Cost Overview",
"cost_axis_label": "Cost ($)",
"cost_need_price": "Set a model price to view cost stats",
"cost_need_usage": "No usage data available to calculate cost",
"cost_no_data": "No cost data yet",
"credential_stats": "Credential Statistics",
"credential_name": "Credential",
"token_breakdown": "Token Type Breakdown",
"request_events_title": "Request Events",
"request_events_filter_model": "Model",
"request_events_filter_source": "Source",
"request_events_filter_auth_index": "Auth Index",
"request_events_timestamp": "Timestamp",
"request_events_source": "Source",
"request_events_auth_index": "Auth Index",
"request_events_result": "Result",
"thinking_intensity": "Thinking",
"thinking_mode": "Thinking Mode",
"thinking_level": "Thinking Level",
"thinking_budget": "Thinking Budget",
"time": "Latency",
"avg_time": "Avg Latency",
"latency_unit_hint": "Durations use backend field {{field}} and are interpreted as {{unit}} before formatting.",
"duration_unit_d": "d",
"duration_unit_h": "h",
"duration_unit_m": "m",
"duration_unit_s": "s",
"duration_unit_ms": "ms",
"request_events_empty_title": "No request events",
"request_events_empty_desc": "No request details are available for the selected time range.",
"request_events_no_result_title": "No matching events",
"request_events_no_result_desc": "Try different filters or clear the current filters.",
"request_events_count": "{{count}} events",
"request_events_limit_hint": "Showing {{shown}} of {{total}} events to keep rendering responsive.",
"input_tokens": "Input Tokens",
"output_tokens": "Output Tokens",
"last_updated": "Updated"
},
"stats": {
"success": "Success",
"failure": "Failure"
@@ -1112,34 +992,6 @@
"filter_status_4xx": "4xx",
"filter_status_5xx": "5xx",
"clear_filters": "Clear Filters",
"trace_button": "Trace",
"trace_title": "Request Trace (V1)",
"trace_notice": "V1 uses heuristic correlation between logs and usage events. Results are estimates, not strict request_id matches.",
"trace_log_info": "Log Event",
"trace_candidates_title": "Usage Match Candidates",
"trace_loading": "Matching usage events...",
"trace_no_match": "No candidate matches found.",
"trace_usage_load_error": "Failed to load usage details for tracing",
"trace_download_request_log": "Download Request Log",
"trace_confidence_high": "High",
"trace_confidence_medium": "Medium",
"trace_confidence_low": "Low",
"trace_score": "Score {{score}}",
"trace_delta_seconds": "Δt {{seconds}}s",
"trace_model_matched": "Model Matched",
"trace_request_id": "Request ID",
"trace_method": "Method",
"trace_path": "Path",
"trace_status_code": "Status",
"trace_latency": "Latency",
"trace_ip": "IP",
"trace_timestamp": "Timestamp",
"trace_message": "Message",
"trace_endpoint": "Usage Endpoint",
"trace_model": "Model",
"trace_source": "Source",
"trace_auth_index": "Auth Index",
"trace_result": "Result",
"search_empty_title": "No matching logs found",
"search_empty_desc": "Try a different keyword or clear the filters.",
"double_click_copy_hint": "Double-click to copy raw log line",
@@ -1241,8 +1093,6 @@
"commercial_mode_desc": "Disable high-overhead middleware to support high concurrency",
"logging_to_file": "Log to File",
"logging_to_file_desc": "Save logs to files",
"usage_statistics": "Usage Statistics",
"usage_statistics_desc": "Collect usage statistics",
"logs_max_size": "Log File Size Limit (MB)"
},
"network": {
@@ -1410,9 +1260,9 @@
"link_webui_repo": "WebUI Repository",
"link_webui_repo_desc": "Management Center frontend source code",
"link_docs": "Documentation",
"link_docs_desc": "Usage tutorials and configuration guides",
"link_docs_desc": "Configuration tutorials and guides",
"clear_login_title": "Local Login Data",
"clear_login_desc": "Clear locally saved login data and sign out. Usage stats pricing settings will remain untouched.",
"clear_login_desc": "Clear locally saved login data and sign out.",
"clear_login_button": "Clear login data",
"clear_login_confirm": "Clear local login data and sign out now?"
},
@@ -1423,7 +1273,6 @@
"retry_updated": "Retry settings updated",
"quota_switch_project_updated": "Project switch settings updated",
"quota_switch_preview_updated": "Preview model switch settings updated",
"usage_statistics_updated": "Usage statistics settings updated",
"logging_to_file_updated": "Logging settings updated",
"logs_max_total_size_updated": "Log size limit updated",
"request_log_updated": "Request logging setting updated",
+2 -155
View File
@@ -115,7 +115,6 @@
"auth_files": "Файлы аутентификации",
"oauth": "OAuth вход",
"quota_management": "Управление квотами",
"usage_stats": "Статистика использования",
"config_management": "Панель конфигурации",
"logs": "Просмотр логов",
"system_info": "Информация системы"
@@ -129,14 +128,6 @@
"management_keys": "Ключи управления",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} O:{{openai}}",
"oauth_credentials": "Учётные данные OAuth",
"usage_overview": "Обзор использования",
"total_requests": "Всего запросов",
"total_tokens": "Всего токенов",
"rpm_30min": "RPM (30 мин)",
"tpm_30min": "TPM (30 мин)",
"models_used": "Используемые модели",
"no_usage_data": "Данные об использовании отсутствуют",
"view_detailed_usage": "Просмотреть детальную статистику",
"edit_settings": "Изменить настройки",
"routing_strategy": "Стратегия маршрутизации",
"available_models": "Доступные модели",
@@ -167,8 +158,6 @@
"quota_title": "Поведение при превышении квоты",
"quota_switch_project": "Автоматически переключать проект",
"quota_switch_preview": "Переключаться на preview-модель",
"usage_statistics_title": "Статистика использования",
"usage_statistics_enable": "Включить статистику использования",
"logging_title": "Журналирование",
"logging_to_file_enable": "Включить журналирование в файл",
"logs_max_total_size_title": "Лимит размера журналов",
@@ -935,115 +924,6 @@
"iflow_oauth_polling_error": "Не удалось проверить статус аутентификации:",
"remote_access_disabled": "Этот способ входа недоступен при удалённом доступе. Подключитесь с localhost."
},
"usage_stats": {
"title": "Статистика использования",
"total_requests": "Всего запросов",
"success_requests": "Успешные запросы",
"failed_requests": "Неуспешные запросы",
"total_tokens": "Всего токенов",
"cached_tokens": "Кэшированные токены",
"reasoning_tokens": "Токены рассуждений",
"rpm_30m": "RPM",
"tpm_30m": "TPM",
"rate_30m": "Скорость (последние 30 мин)",
"model_name": "Название модели",
"model_price_settings": "Настройки стоимости моделей",
"saved_prices": "Сохранённые цены",
"requests_trend": "Динамика запросов",
"tokens_trend": "Динамика токенов",
"api_details": "Детали API",
"by_hour": "По часам",
"by_day": "По дням",
"range_filter": "Диапазон времени",
"range_all": "За всё время",
"range_7h": "Последние 7 часов",
"range_24h": "Последние 24 часа",
"range_7d": "Последние 7 дней",
"filter_all": "Все",
"clear_filters": "Сбросить фильтры",
"refresh": "Обновить",
"export": "Экспорт",
"import": "Импорт",
"export_csv": "Экспорт CSV",
"export_json": "Экспорт JSON",
"export_success": "Экспорт использования скачан",
"import_success": "Импорт завершён: добавлено {{added}}, пропущено {{skipped}}, всего {{total}}, ошибок {{failed}}",
"import_invalid": "Недопустимый файл экспорта использования",
"chart_line_label_1": "Линия 1",
"chart_line_label_2": "Линия 2",
"chart_line_label_3": "Линия 3",
"chart_line_label_4": "Линия 4",
"chart_line_label_5": "Линия 5",
"chart_line_label_6": "Линия 6",
"chart_line_label_7": "Линия 7",
"chart_line_label_8": "Линия 8",
"chart_line_label_9": "Линия 9",
"chart_line_hidden": "Скрыть",
"chart_line_actions_label": "Линии для отображения",
"chart_line_add": "Добавить линию",
"chart_line_all": "Все",
"chart_line_delete": "Удалить линию",
"chart_line_hint": "Отображайте до 9 линий моделей одновременно",
"no_data": "Данные отсутствуют",
"loading_error": "Не удалось загрузить",
"api_endpoint": "API-эндпоинт",
"requests_count": "Количество запросов",
"tokens_count": "Количество токенов",
"models": "Статистика моделей",
"success_rate": "Успешность",
"total_cost": "Общая стоимость",
"total_cost_hint": "Основано на настроенной стоимости моделей",
"model_price_title": "Стоимость моделей",
"model_price_reset": "Сбросить цены",
"model_price_model_label": "Модель",
"model_price_select_placeholder": "Выберите модель",
"model_price_select_hint": "Модели берутся из детальной статистики использования",
"model_price_prompt": "Цена prompt",
"model_price_completion": "Цена completion",
"model_price_cache": "Цена кэша",
"model_price_save": "Сохранить цену",
"model_price_empty": "Цена для моделей не задана",
"model_price_model": "Модель",
"model_price_saved": "Цена модели сохранена",
"model_price_model_required": "Выберите модель для задания цены",
"cost_trend": "Обзор стоимости",
"cost_axis_label": "Стоимость ($)",
"cost_need_price": "Задайте стоимость модели, чтобы увидеть статистику затрат",
"cost_need_usage": "Нет данных использования для расчёта стоимости",
"cost_no_data": "Данных о стоимости ещё нет",
"credential_stats": "Статистика учётных данных",
"credential_name": "Учётные данные",
"token_breakdown": "Распределение типов токенов",
"request_events_title": "События запросов",
"request_events_filter_model": "Модель",
"request_events_filter_source": "Источник",
"request_events_filter_auth_index": "Auth Index",
"request_events_timestamp": "Время",
"request_events_source": "Источник",
"request_events_auth_index": "Auth Index",
"request_events_result": "Результат",
"thinking_intensity": "Рассуждение",
"thinking_mode": "Режим рассуждения",
"thinking_level": "Уровень рассуждения",
"thinking_budget": "Бюджет рассуждения",
"time": "Задержка",
"avg_time": "Средняя задержка",
"latency_unit_hint": "Длительность берётся из поля бэкенда {{field}} и интерпретируется как {{unit}} перед форматированием.",
"duration_unit_d": "д",
"duration_unit_h": "ч",
"duration_unit_m": "мин",
"duration_unit_s": "с",
"duration_unit_ms": "мс",
"request_events_empty_title": "События запросов отсутствуют",
"request_events_empty_desc": "Нет деталей запросов для выбранного диапазона времени.",
"request_events_no_result_title": "Совпадений не найдено",
"request_events_no_result_desc": "Попробуйте другие фильтры или очистите текущие.",
"request_events_count": "{{count}} событий",
"request_events_limit_hint": "Показано {{shown}} из {{total}} событий для стабильной производительности.",
"input_tokens": "Входные токены",
"output_tokens": "Выходные токены",
"last_updated": "Обновлено"
},
"stats": {
"success": "Успех",
"failure": "Сбой"
@@ -1109,34 +989,6 @@
"filter_status_4xx": "4xx",
"filter_status_5xx": "5xx",
"clear_filters": "Очистить фильтры",
"trace_button": "Трассировка",
"trace_title": "Трассировка запроса (V1)",
"trace_notice": "V1 использует эвристическую корреляцию между логами и usage-событиями. Результаты оценочные и не являются точным сопоставлением по request_id.",
"trace_log_info": "Событие лога",
"trace_candidates_title": "Кандидаты из usage",
"trace_loading": "Сопоставление usage-событий...",
"trace_no_match": "Подходящие кандидаты не найдены.",
"trace_usage_load_error": "Не удалось загрузить usage-детали для трассировки",
"trace_download_request_log": "Скачать журнал запроса",
"trace_confidence_high": "Высокая",
"trace_confidence_medium": "Средняя",
"trace_confidence_low": "Низкая",
"trace_score": "Оценка {{score}}",
"trace_delta_seconds": "Δt {{seconds}}с",
"trace_model_matched": "Модель совпала",
"trace_request_id": "Request ID",
"trace_method": "Метод",
"trace_path": "Путь",
"trace_status_code": "Статус",
"trace_latency": "Задержка",
"trace_ip": "IP",
"trace_timestamp": "Время",
"trace_message": "Сообщение",
"trace_endpoint": "Usage-эндпоинт",
"trace_model": "Модель",
"trace_source": "Источник",
"trace_auth_index": "Auth Index",
"trace_result": "Результат",
"search_empty_title": "Подходящих журналов не найдено",
"search_empty_desc": "Попробуйте другой запрос или сбросьте фильтры.",
"double_click_copy_hint": "Дважды нажмите, чтобы скопировать строку журнала",
@@ -1238,11 +1090,7 @@
"commercial_mode_desc": "Отключить тяжёлое промежуточное ПО для поддержки высокой нагрузки",
"logging_to_file": "Журналировать в файл",
"logging_to_file_desc": "Сохранять журналы в файлы",
"usage_statistics": "Статистика использования",
"usage_statistics_desc": "Собирать статистику использования",
"logs_max_size": "Максимальный размер файла журнала (МБ)",
"usage_retention_days": "Хранить статистику (дней)",
"usage_retention_hint": "0 означает без ограничений (без очистки)"
"logs_max_size": "Максимальный размер файла журнала (МБ)"
},
"network": {
"title": "Сетевые настройки",
@@ -1411,7 +1259,7 @@
"link_docs": "Документация",
"link_docs_desc": "Учебные пособия и руководства по настройке",
"clear_login_title": "Локальные данные входа",
"clear_login_desc": "Очистите локально сохранённые данные входа и выполните выход. Настройки стоимости статистики использования сохранятся.",
"clear_login_desc": "Очистите локально сохранённые данные входа и выполните выход.",
"clear_login_button": "Очистить данные входа",
"clear_login_confirm": "Очистить локальные данные входа и выйти?"
},
@@ -1422,7 +1270,6 @@
"retry_updated": "Настройки повторов обновлены",
"quota_switch_project_updated": "Настройки переключения проектов обновлены",
"quota_switch_preview_updated": "Настройки переключения на preview-модель обновлены",
"usage_statistics_updated": "Настройки статистики использования обновлены",
"logging_to_file_updated": "Настройки журналирования обновлены",
"logs_max_total_size_updated": "Лимит размера журналов обновлён",
"request_log_updated": "Настройка журналирования запросов обновлена",
+1 -152
View File
@@ -115,7 +115,6 @@
"auth_files": "认证文件",
"oauth": "OAuth 登录",
"quota_management": "配额管理",
"usage_stats": "使用统计",
"config_management": "配置面板",
"logs": "日志查看",
"system_info": "中心信息"
@@ -129,14 +128,6 @@
"management_keys": "管理密钥",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} O:{{openai}}",
"oauth_credentials": "OAuth 凭证",
"usage_overview": "使用概览",
"total_requests": "总请求数",
"total_tokens": "总 Token 数",
"rpm_30min": "RPM (30分钟)",
"tpm_30min": "TPM (30分钟)",
"models_used": "使用模型数",
"no_usage_data": "暂无使用数据",
"view_detailed_usage": "查看详细统计",
"edit_settings": "编辑设置",
"routing_strategy": "路由策略",
"available_models": "可用模型",
@@ -167,8 +158,6 @@
"quota_title": "配额超出行为",
"quota_switch_project": "自动切换项目",
"quota_switch_preview": "切换到预览模型",
"usage_statistics_title": "使用统计",
"usage_statistics_enable": "启用使用统计",
"logging_title": "日志记录",
"logging_to_file_enable": "启用日志记录到文件",
"logs_max_total_size_title": "日志容量限制",
@@ -938,115 +927,6 @@
"iflow_oauth_polling_error": "检查认证状态失败:",
"remote_access_disabled": "远程访问不支持此登录方式,请从本地 (localhost) 访问"
},
"usage_stats": {
"title": "使用统计",
"total_requests": "总请求数",
"success_requests": "成功请求",
"failed_requests": "失败请求",
"total_tokens": "总Token数",
"cached_tokens": "缓存 Tokens",
"reasoning_tokens": "思考 Tokens",
"rpm_30m": "RPM",
"tpm_30m": "TPM",
"rate_30m": "近30分钟速率",
"model_name": "模型名称",
"model_price_settings": "模型价格设置",
"saved_prices": "已保存的价格",
"requests_trend": "请求趋势",
"tokens_trend": "Token 使用趋势",
"api_details": "API 详细统计",
"by_hour": "按小时",
"by_day": "按天",
"range_filter": "时间范围",
"range_all": "全部时间",
"range_7h": "最近7小时",
"range_24h": "最近24小时",
"range_7d": "最近7天",
"filter_all": "全部",
"clear_filters": "清空筛选",
"refresh": "刷新",
"export": "导出数据",
"import": "导入数据",
"export_csv": "导出 CSV",
"export_json": "导出 JSON",
"export_success": "使用统计已导出",
"import_success": "导入完成:新增 {{added}},跳过 {{skipped}},总请求 {{total}},失败 {{failed}}",
"import_invalid": "导入文件格式不正确",
"chart_line_label_1": "曲线 1",
"chart_line_label_2": "曲线 2",
"chart_line_label_3": "曲线 3",
"chart_line_label_4": "曲线 4",
"chart_line_label_5": "曲线 5",
"chart_line_label_6": "曲线 6",
"chart_line_label_7": "曲线 7",
"chart_line_label_8": "曲线 8",
"chart_line_label_9": "曲线 9",
"chart_line_hidden": "不显示",
"chart_line_actions_label": "曲线数量",
"chart_line_add": "增加曲线",
"chart_line_all": "全部",
"chart_line_delete": "删除曲线",
"chart_line_hint": "最多同时显示 9 条模型曲线",
"no_data": "暂无数据",
"loading_error": "加载失败",
"api_endpoint": "API端点",
"requests_count": "请求次数",
"tokens_count": "Token数量",
"models": "模型统计",
"success_rate": "成功率",
"total_cost": "总花费",
"total_cost_hint": "基于已设置的模型单价",
"model_price_title": "模型价格",
"model_price_reset": "清除价格",
"model_price_model_label": "选择模型",
"model_price_select_placeholder": "选择模型",
"model_price_select_hint": "模型列表来自使用统计明细",
"model_price_prompt": "提示价格",
"model_price_completion": "补全价格",
"model_price_cache": "缓存价格",
"model_price_save": "保存价格",
"model_price_empty": "暂未设置任何模型价格",
"model_price_model": "模型",
"model_price_saved": "模型价格已保存",
"model_price_model_required": "请选择要设置价格的模型",
"cost_trend": "花费统计",
"cost_axis_label": "花费 ($)",
"cost_need_price": "请先设置模型价格",
"cost_need_usage": "暂无使用数据,无法计算花费",
"cost_no_data": "没有可计算的花费数据",
"credential_stats": "凭证统计",
"credential_name": "凭证",
"token_breakdown": "Token 类型分布",
"request_events_title": "请求事件明细",
"request_events_filter_model": "模型",
"request_events_filter_source": "来源",
"request_events_filter_auth_index": "认证索引",
"request_events_timestamp": "时间",
"request_events_source": "来源",
"request_events_auth_index": "认证索引",
"request_events_result": "结果",
"thinking_intensity": "思考强度",
"thinking_mode": "思考模式",
"thinking_level": "思考等级",
"thinking_budget": "思考预算",
"time": "延迟",
"avg_time": "平均延迟",
"latency_unit_hint": "耗时取自后端字段 {{field}},按 {{unit}} 解释后再格式化显示。",
"duration_unit_d": "天",
"duration_unit_h": "时",
"duration_unit_m": "分",
"duration_unit_s": "秒",
"duration_unit_ms": "毫秒",
"request_events_empty_title": "暂无请求事件",
"request_events_empty_desc": "当前时间范围内暂无可用的请求明细数据。",
"request_events_no_result_title": "没有匹配结果",
"request_events_no_result_desc": "请尝试调整筛选条件,或清空当前筛选。",
"request_events_count": "{{count}} 条事件",
"request_events_limit_hint": "为保证渲染性能,仅展示 {{shown}} / {{total}} 条事件。",
"input_tokens": "输入 Tokens",
"output_tokens": "输出 Tokens",
"last_updated": "更新于"
},
"stats": {
"success": "成功",
"failure": "失败"
@@ -1112,34 +992,6 @@
"filter_status_4xx": "4xx",
"filter_status_5xx": "5xx",
"clear_filters": "清空筛选",
"trace_button": "请求追踪",
"trace_title": "请求追踪 (V1)",
"trace_notice": "V1 通过日志与 usage 事件做启发式关联,结果为估算,不是严格 request_id 级精准匹配。",
"trace_log_info": "日志事件",
"trace_candidates_title": "Usage 候选匹配",
"trace_loading": "正在匹配 usage 事件...",
"trace_no_match": "未找到候选匹配结果。",
"trace_usage_load_error": "加载请求追踪所需 usage 明细失败",
"trace_download_request_log": "下载请求日志",
"trace_confidence_high": "高",
"trace_confidence_medium": "中",
"trace_confidence_low": "低",
"trace_score": "分数 {{score}}",
"trace_delta_seconds": "时间差 {{seconds}} 秒",
"trace_model_matched": "模型匹配",
"trace_request_id": "请求 ID",
"trace_method": "请求方法",
"trace_path": "路径",
"trace_status_code": "状态码",
"trace_latency": "耗时",
"trace_ip": "IP",
"trace_timestamp": "时间",
"trace_message": "消息",
"trace_endpoint": "Usage 接口",
"trace_model": "模型",
"trace_source": "来源",
"trace_auth_index": "认证索引",
"trace_result": "结果",
"search_empty_title": "未找到匹配的日志",
"search_empty_desc": "尝试更换关键字或清空筛选条件。",
"double_click_copy_hint": "双击复制日志原文",
@@ -1241,8 +1093,6 @@
"commercial_mode_desc": "禁用高开销中间件以支持高并发",
"logging_to_file": "写入日志文件",
"logging_to_file_desc": "将日志保存到文件",
"usage_statistics": "使用统计",
"usage_statistics_desc": "收集使用统计信息",
"logs_max_size": "日志文件大小限制 (MB)"
},
"network": {
@@ -1412,7 +1262,7 @@
"link_docs": "使用教程",
"link_docs_desc": "配置指南和使用说明",
"clear_login_title": "本地登录信息",
"clear_login_desc": "清理本地保存的登录信息并退出登录,不会影响使用统计中的价格设置。",
"clear_login_desc": "清理本地保存的登录信息并退出登录。",
"clear_login_button": "清理登录信息",
"clear_login_confirm": "确认清理本地登录信息并退出登录?"
},
@@ -1423,7 +1273,6 @@
"retry_updated": "重试设置已更新",
"quota_switch_project_updated": "项目切换设置已更新",
"quota_switch_preview_updated": "预览模型切换设置已更新",
"usage_statistics_updated": "使用统计设置已更新",
"logging_to_file_updated": "日志记录设置已更新",
"logs_max_total_size_updated": "日志容量设置已更新",
"request_log_updated": "请求日志设置已更新",
+1 -152
View File
@@ -115,7 +115,6 @@
"auth_files": "驗證檔案",
"oauth": "OAuth 登入",
"quota_management": "配額管理",
"usage_stats": "使用統計",
"config_management": "設定面板",
"logs": "記錄檢視",
"system_info": "中心資訊"
@@ -129,14 +128,6 @@
"management_keys": "管理金鑰",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} O:{{openai}}",
"oauth_credentials": "OAuth 憑證",
"usage_overview": "使用概覽",
"total_requests": "總請求數",
"total_tokens": "總 Token 數",
"rpm_30min": "RPM30 分鐘)",
"tpm_30min": "TPM30 分鐘)",
"models_used": "使用模型數",
"no_usage_data": "暫無使用資料",
"view_detailed_usage": "查看詳細統計",
"edit_settings": "編輯設定",
"routing_strategy": "路由策略",
"available_models": "可用模型",
@@ -167,8 +158,6 @@
"quota_title": "配額超出行為",
"quota_switch_project": "自動切換專案",
"quota_switch_preview": "切換到預覽模型",
"usage_statistics_title": "使用統計",
"usage_statistics_enable": "啟用使用統計",
"logging_title": "記錄",
"logging_to_file_enable": "啟用記錄到檔案",
"logs_max_total_size_title": "記錄容量限制",
@@ -964,115 +953,6 @@
"iflow_cookie_result_type": "類型",
"remote_access_disabled": "遠端存取不支援此登入方式,請從本地(localhost)存取"
},
"usage_stats": {
"title": "使用統計",
"total_requests": "總請求數",
"success_requests": "成功請求",
"failed_requests": "失敗請求",
"total_tokens": "總 Token 數",
"cached_tokens": "快取 Tokens",
"reasoning_tokens": "推理 Tokens",
"rpm_30m": "RPM",
"tpm_30m": "TPM",
"rate_30m": "近 30 分鐘速率",
"model_name": "模型名稱",
"model_price_settings": "模型定價設定",
"saved_prices": "已儲存的定價",
"requests_trend": "請求趨勢",
"tokens_trend": "Token 使用趨勢",
"api_details": "API 詳細統計",
"by_hour": "依小時",
"by_day": "依天",
"range_filter": "時間範圍",
"range_all": "全部時間",
"range_7h": "最近 7 小時",
"range_24h": "最近 24 小時",
"range_7d": "最近 7 天",
"filter_all": "全部",
"clear_filters": "清空篩選",
"refresh": "重新整理",
"export": "匯出資料",
"import": "匯入資料",
"export_csv": "匯出 CSV",
"export_json": "匯出 JSON",
"export_success": "使用統計已匯出",
"import_success": "匯入完成:新增 {{added}},跳過 {{skipped}},總請求 {{total}},失敗 {{failed}}",
"import_invalid": "匯入檔案格式不正確",
"chart_line_label_1": "曲線 1",
"chart_line_label_2": "曲線 2",
"chart_line_label_3": "曲線 3",
"chart_line_label_4": "曲線 4",
"chart_line_label_5": "曲線 5",
"chart_line_label_6": "曲線 6",
"chart_line_label_7": "曲線 7",
"chart_line_label_8": "曲線 8",
"chart_line_label_9": "曲線 9",
"chart_line_hidden": "不顯示",
"chart_line_actions_label": "曲線數量",
"chart_line_add": "增加曲線",
"chart_line_all": "全部",
"chart_line_delete": "刪除曲線",
"chart_line_hint": "最多同時顯示 9 條模型曲線",
"no_data": "暫無資料",
"loading_error": "載入失敗",
"api_endpoint": "API 端點",
"requests_count": "請求次數",
"tokens_count": "Token 數量",
"models": "模型統計",
"success_rate": "成功率",
"total_cost": "總花費",
"total_cost_hint": "基於已設定的模型單價",
"model_price_title": "模型定價",
"model_price_reset": "清除定價",
"model_price_model_label": "選擇模型",
"model_price_select_placeholder": "選擇模型",
"model_price_select_hint": "模型清單來自使用統計明細",
"model_price_prompt": "提示定價",
"model_price_completion": "補全定價",
"model_price_cache": "快取定價",
"model_price_save": "儲存定價",
"model_price_empty": "尚未設定任何模型定價",
"model_price_model": "模型",
"model_price_saved": "模型定價已儲存",
"model_price_model_required": "請選擇要設定定價的模型",
"cost_trend": "花費統計",
"cost_axis_label": "花費($",
"cost_need_price": "請先設定模型定價",
"cost_need_usage": "暫無使用資料,無法計算花費",
"cost_no_data": "沒有可計算的花費資料",
"credential_stats": "憑證統計",
"credential_name": "憑證",
"token_breakdown": "Token 類型分布",
"request_events_title": "請求事件明細",
"request_events_filter_model": "模型",
"request_events_filter_source": "來源",
"request_events_filter_auth_index": "驗證索引",
"request_events_timestamp": "時間",
"request_events_source": "來源",
"request_events_auth_index": "驗證索引",
"request_events_result": "結果",
"thinking_intensity": "思考強度",
"thinking_mode": "思考模式",
"thinking_level": "思考等級",
"thinking_budget": "思考預算",
"time": "延遲",
"avg_time": "平均延遲",
"latency_unit_hint": "耗時取自後端欄位 {{field}},按 {{unit}} 解讀後再格式化顯示。",
"duration_unit_d": "天",
"duration_unit_h": "時",
"duration_unit_m": "分",
"duration_unit_s": "秒",
"duration_unit_ms": "毫秒",
"request_events_empty_title": "暫無請求事件",
"request_events_empty_desc": "目前時間範圍內暫無可用的請求明細資料。",
"request_events_no_result_title": "沒有符合結果",
"request_events_no_result_desc": "請嘗試調整篩選條件,或清空目前篩選。",
"request_events_count": "{{count}} 條事件",
"request_events_limit_hint": "為確保渲染效能,僅展示 {{shown}} / {{total}} 條事件。",
"input_tokens": "輸入 Tokens",
"output_tokens": "輸出 Tokens",
"last_updated": "更新於"
},
"stats": {
"success": "成功",
"failure": "失敗"
@@ -1138,34 +1018,6 @@
"filter_status_4xx": "4xx",
"filter_status_5xx": "5xx",
"clear_filters": "清空篩選",
"trace_button": "請求追蹤",
"trace_title": "請求追蹤(V1",
"trace_notice": "V1 透過記錄與 usage 事件做啟發式關聯,結果為估算,不是嚴格 request_id 級精確比對。",
"trace_log_info": "記錄事件",
"trace_candidates_title": "Usage 候選比對",
"trace_loading": "正在比對 usage 事件...",
"trace_no_match": "未找到候選比對結果。",
"trace_usage_load_error": "載入請求追蹤所需 usage 明細失敗",
"trace_download_request_log": "下載請求記錄",
"trace_confidence_high": "高",
"trace_confidence_medium": "中",
"trace_confidence_low": "低",
"trace_score": "分數 {{score}}",
"trace_delta_seconds": "時間差 {{seconds}} 秒",
"trace_model_matched": "模型比對",
"trace_request_id": "請求 ID",
"trace_method": "請求方法",
"trace_path": "路徑",
"trace_status_code": "狀態碼",
"trace_latency": "耗時",
"trace_ip": "IP",
"trace_timestamp": "時間",
"trace_message": "訊息",
"trace_endpoint": "Usage 介面",
"trace_model": "模型",
"trace_source": "來源",
"trace_auth_index": "驗證索引",
"trace_result": "結果",
"search_empty_title": "未找到符合的記錄",
"search_empty_desc": "嘗試更換關鍵字或清空篩選條件。",
"double_click_copy_hint": "雙擊複製記錄原文",
@@ -1267,8 +1119,6 @@
"commercial_mode_desc": "停用高開銷中介軟體以支援高並行",
"logging_to_file": "寫入記錄檔",
"logging_to_file_desc": "將記錄儲存到檔案",
"usage_statistics": "使用統計",
"usage_statistics_desc": "收集使用統計資訊",
"logs_max_size": "記錄檔大小限制(MB"
},
"network": {
@@ -1438,7 +1288,7 @@
"link_docs": "使用教學",
"link_docs_desc": "設定指南和使用說明",
"clear_login_title": "本地登入資訊",
"clear_login_desc": "清理本地儲存的登入資訊並登出,不會影響使用統計中的定價設定。",
"clear_login_desc": "清理本地儲存的登入資訊並登出。",
"clear_login_button": "清理登入資訊",
"clear_login_confirm": "確認清理本地登入資訊並登出?"
},
@@ -1449,7 +1299,6 @@
"retry_updated": "重試設定已更新",
"quota_switch_project_updated": "專案切換設定已更新",
"quota_switch_preview_updated": "預覽模型切換設定已更新",
"usage_statistics_updated": "使用統計設定已更新",
"logging_to_file_updated": "記錄設定已更新",
"logs_max_total_size_updated": "記錄容量設定已更新",
"request_log_updated": "請求記錄設定已更新",
-6
View File
@@ -366,12 +366,6 @@ export function DashboardPage() {
{config.debug ? t('common.yes') : t('common.no')}
</span>
</div>
<div className={styles.configPill}>
<span className={styles.configPillLabel}>{t('basic_settings.usage_statistics_enable')}</span>
<span className={`${styles.configPillValue} ${config.usageStatisticsEnabled ? styles.on : styles.off}`}>
{config.usageStatisticsEnabled ? t('common.yes') : t('common.no')}
</span>
</div>
<div className={styles.configPill}>
<span className={styles.configPillLabel}>{t('basic_settings.logging_to_file_enable')}</span>
<span className={`${styles.configPillValue} ${config.loggingToFile ? styles.on : styles.off}`}>
-153
View File
@@ -610,159 +610,6 @@
}
}
.traceButton {
@include button-reset;
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: $radius-full;
border: 1px solid rgba($primary-color, 0.3);
background: rgba($primary-color, 0.1);
color: var(--primary-color);
font-size: 11px;
font-weight: 600;
line-height: 1.2;
cursor: pointer;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
&:hover {
background: rgba($primary-color, 0.16);
border-color: rgba($primary-color, 0.45);
}
}
.tracePanel {
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.traceNotice {
padding: 8px 10px;
border-radius: $radius-md;
border: 1px solid var(--warning-border);
background: var(--warning-bg);
color: var(--warning-text);
font-size: 12px;
}
.traceSectionTitle {
margin: 4px 0 0;
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
}
.traceCandidatesHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.traceInfoGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
@include mobile {
grid-template-columns: 1fr;
}
}
.traceInfoItem {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background: var(--bg-secondary);
}
.traceInfoItemWide {
grid-column: 1 / -1;
}
.traceInfoLabel {
font-size: 11px;
color: var(--text-secondary);
font-weight: 700;
}
.traceInfoValue {
font-size: 12px;
color: var(--text-primary);
word-break: break-word;
}
.traceSourceType {
display: inline-flex;
align-items: center;
margin-left: 6px;
padding: 0 6px;
border-radius: $radius-full;
border: 1px solid var(--border-color);
font-size: 11px;
color: var(--text-secondary);
background: var(--bg-primary);
}
.traceCandidates {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 320px;
overflow: auto;
}
.traceCandidate {
border: 1px solid var(--border-color);
border-radius: $radius-md;
padding: 10px;
background: var(--bg-secondary);
display: flex;
flex-direction: column;
gap: 8px;
}
.traceCandidateHeader {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.traceModelBadge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: $radius-full;
border: 1px solid var(--success-badge-border, #6ee7b7);
font-size: 11px;
font-weight: 700;
color: var(--success-badge-text, #065f46);
background: var(--success-badge-bg, #d1fae5);
}
.traceDelta {
font-size: 11px;
color: var(--text-secondary);
}
.traceCandidateGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
@include mobile {
grid-template-columns: 1fr;
}
}
@media (max-height: 960px) {
@media (min-width: 769px) {
.logPanel {
-195
View File
@@ -37,7 +37,6 @@ import {
import { parseLogLine } from './hooks/logParsing';
import { useLogFilters } from './hooks/useLogFilters';
import { isNearBottom, useLogScroller } from './hooks/useLogScroller';
import { isTraceableRequestPath, useTraceResolver } from './hooks/useTraceResolver';
import styles from './LogsPage.module.scss';
interface ErrorLogItem {
@@ -68,9 +67,6 @@ export function LogsPage() {
const { t } = useTranslation();
const { showNotification, showConfirmation } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const apiBase = useAuthStore((state) => state.apiBase);
const managementKey = useAuthStore((state) => state.managementKey);
const traceScopeKey = `${apiBase}::${managementKey}`;
const config = useConfigStore((state) => state.config);
const requestLogEnabled = config?.requestLog ?? false;
@@ -96,13 +92,6 @@ export function LogsPage() {
const [requestLogId, setRequestLogId] = useState<string | null>(null);
const [requestLogDownloading, setRequestLogDownloading] = useState(false);
const trace = useTraceResolver({
traceScopeKey,
connectionStatus,
config,
requestLogDownloading
});
const logScrollerRef = useRef<ReturnType<typeof useLogScroller> | null>(null);
const longPressRef = useRef<{
timer: number | null;
@@ -737,7 +726,6 @@ export function LogsPage() {
) : (
<div className={styles.logList}>
{parsedVisibleLines.map((line, index) => {
const canTraceRequest = isTraceableRequestPath(line.path);
const rowClassNames = [styles.logRow];
if (line.level === 'warn') rowClassNames.push(styles.rowWarn);
if (line.level === 'error' || line.level === 'fatal')
@@ -828,21 +816,6 @@ export function LogsPage() {
)}
{line.message && <span className={styles.message}>{line.message}</span>}
{canTraceRequest && (
<button
type="button"
className={styles.traceButton}
onClick={(event) => {
event.stopPropagation();
cancelLongPress();
trace.openTraceModal(line);
}}
title={t('logs.trace_button')}
>
{t('logs.trace_button')}
</button>
)}
</div>
</div>
);
@@ -922,174 +895,6 @@ export function LogsPage() {
)}
</div>
<Modal
open={Boolean(trace.traceLogLine)}
onClose={trace.closeTraceModal}
title={t('logs.trace_title')}
footer={
<>
{trace.traceLogLine?.requestId && (
<Button
variant="secondary"
onClick={() => {
if (trace.traceLogLine?.requestId) {
void downloadRequestLog(trace.traceLogLine.requestId);
}
}}
loading={requestLogDownloading}
>
{t('logs.trace_download_request_log')}
</Button>
)}
<Button
variant="secondary"
onClick={trace.closeTraceModal}
disabled={requestLogDownloading}
>
{t('common.close')}
</Button>
</>
}
>
{trace.traceLogLine && (
<div className={styles.tracePanel}>
<div className={styles.traceNotice}>{t('logs.trace_notice')}</div>
<h3 className={styles.traceSectionTitle}>{t('logs.trace_log_info')}</h3>
<div className={styles.traceInfoGrid}>
<div className={styles.traceInfoItem}>
<span className={styles.traceInfoLabel}>{t('logs.trace_request_id')}</span>
<span className={styles.traceInfoValue}>{trace.traceLogLine.requestId || '-'}</span>
</div>
<div className={styles.traceInfoItem}>
<span className={styles.traceInfoLabel}>{t('logs.trace_method')}</span>
<span className={styles.traceInfoValue}>{trace.traceLogLine.method || '-'}</span>
</div>
<div className={styles.traceInfoItem}>
<span className={styles.traceInfoLabel}>{t('logs.trace_path')}</span>
<span className={styles.traceInfoValue}>{trace.traceLogLine.path || '-'}</span>
</div>
<div className={styles.traceInfoItem}>
<span className={styles.traceInfoLabel}>{t('logs.trace_status_code')}</span>
<span className={styles.traceInfoValue}>
{typeof trace.traceLogLine.statusCode === 'number'
? trace.traceLogLine.statusCode
: '-'}
</span>
</div>
<div className={styles.traceInfoItem}>
<span className={styles.traceInfoLabel}>{t('logs.trace_latency')}</span>
<span className={styles.traceInfoValue}>{trace.traceLogLine.latency || '-'}</span>
</div>
<div className={styles.traceInfoItem}>
<span className={styles.traceInfoLabel}>{t('logs.trace_ip')}</span>
<span className={styles.traceInfoValue}>{trace.traceLogLine.ip || '-'}</span>
</div>
<div className={styles.traceInfoItem}>
<span className={styles.traceInfoLabel}>{t('logs.trace_timestamp')}</span>
<span className={styles.traceInfoValue}>{trace.traceLogLine.timestamp || '-'}</span>
</div>
<div className={`${styles.traceInfoItem} ${styles.traceInfoItemWide}`}>
<span className={styles.traceInfoLabel}>{t('logs.trace_message')}</span>
<span className={styles.traceInfoValue}>{trace.traceLogLine.message || '-'}</span>
</div>
</div>
<div className={styles.traceCandidatesHeader}>
<h3 className={styles.traceSectionTitle}>{t('logs.trace_candidates_title')}</h3>
<Button
variant="secondary"
size="sm"
onClick={() => {
void trace.refreshTraceUsageDetails().catch(() => {});
}}
loading={trace.traceLoading}
disabled={requestLogDownloading}
>
{t('common.refresh')}
</Button>
</div>
{trace.traceLoading ? (
<div className="hint">{t('logs.trace_loading')}</div>
) : trace.traceError ? (
<div className="error-box">{trace.traceError}</div>
) : trace.traceCandidates.length === 0 ? (
<div className="hint">{t('logs.trace_no_match')}</div>
) : (
<div className={styles.traceCandidates}>
{trace.traceCandidates.map((candidate) => {
const sourceInfo = trace.resolveTraceSourceInfo(
String(candidate.detail.source ?? ''),
candidate.detail.auth_index
);
return (
<div
key={`${candidate.detail.__endpoint}-${candidate.detail.__modelName}-${candidate.detail.timestamp}-${candidate.detail.source}`}
className={styles.traceCandidate}
>
<div className={styles.traceCandidateHeader}>
{candidate.modelMatched && (
<span className={styles.traceModelBadge}>
{t('logs.trace_model_matched')}
</span>
)}
{candidate.timeDeltaMs !== null && (
<span className={styles.traceDelta}>
{t('logs.trace_delta_seconds', {
seconds: (candidate.timeDeltaMs / 1000).toFixed(2)
})}
</span>
)}
</div>
<div className={styles.traceCandidateGrid}>
<div className={styles.traceInfoItem}>
<span className={styles.traceInfoLabel}>{t('logs.trace_endpoint')}</span>
<span className={styles.traceInfoValue}>{candidate.detail.__endpoint}</span>
</div>
<div className={styles.traceInfoItem}>
<span className={styles.traceInfoLabel}>{t('logs.trace_model')}</span>
<span className={styles.traceInfoValue}>{candidate.detail.__modelName || '-'}</span>
</div>
<div className={styles.traceInfoItem}>
<span className={styles.traceInfoLabel}>{t('logs.trace_source')}</span>
<span
className={styles.traceInfoValue}
title={String(candidate.detail.source || '-')}
>
<span>{sourceInfo.displayName}</span>
{sourceInfo.type && (
<span className={styles.traceSourceType}>{sourceInfo.type}</span>
)}
</span>
</div>
<div className={styles.traceInfoItem}>
<span className={styles.traceInfoLabel}>{t('logs.trace_auth_index')}</span>
<span className={styles.traceInfoValue}>
{candidate.detail.auth_index ?? '-'}
</span>
</div>
<div className={styles.traceInfoItem}>
<span className={styles.traceInfoLabel}>{t('logs.trace_timestamp')}</span>
<span className={styles.traceInfoValue}>
{candidate.detail.timestamp || '-'}
</span>
</div>
<div className={styles.traceInfoItem}>
<span className={styles.traceInfoLabel}>{t('logs.trace_result')}</span>
<span className={styles.traceInfoValue}>
{candidate.detail.failed ? t('stats.failure') : t('stats.success')}
</span>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
)}
</Modal>
<Modal
open={Boolean(requestLogId)}
onClose={closeRequestLogModal}
File diff suppressed because it is too large Load Diff
-426
View File
@@ -1,426 +0,0 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Chart as ChartJS,
BarElement,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler,
} from 'chart.js';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { Select } from '@/components/ui/Select';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { providersApi } from '@/services/api';
import { useThemeStore, useConfigStore } from '@/stores';
import type { OpenAIProviderConfig } from '@/types';
import {
StatCards,
UsageChart,
ChartLineSelector,
ApiDetailsCard,
ModelStatsCard,
PriceSettingsCard,
CredentialStatsCard,
RequestEventsDetailsCard,
TokenBreakdownChart,
CostTrendChart,
ServiceHealthCard,
useUsageData,
useSparklines,
useChartData,
} from '@/components/usage';
import {
getModelNamesFromUsage,
getApiStats,
getModelStats,
filterUsageByTimeRange,
type UsageTimeRange,
} from '@/utils/usage';
import styles from './UsagePage.module.scss';
// Register Chart.js components
ChartJS.register(
BarElement,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
);
const CHART_LINES_STORAGE_KEY = 'cli-proxy-usage-chart-lines-v1';
const TIME_RANGE_STORAGE_KEY = 'cli-proxy-usage-time-range-v1';
const DEFAULT_CHART_LINES = ['all'];
const DEFAULT_TIME_RANGE: UsageTimeRange = '24h';
const MAX_CHART_LINES = 9;
const TIME_RANGE_OPTIONS: ReadonlyArray<{ value: UsageTimeRange; labelKey: string }> = [
{ value: 'all', labelKey: 'usage_stats.range_all' },
{ value: '7h', labelKey: 'usage_stats.range_7h' },
{ value: '24h', labelKey: 'usage_stats.range_24h' },
{ value: '7d', labelKey: 'usage_stats.range_7d' },
];
const HOUR_WINDOW_BY_TIME_RANGE: Record<Exclude<UsageTimeRange, 'all'>, number> = {
'7h': 7,
'24h': 24,
'7d': 7 * 24,
};
const isUsageTimeRange = (value: unknown): value is UsageTimeRange =>
value === '7h' || value === '24h' || value === '7d' || value === 'all';
const normalizeChartLines = (value: unknown, maxLines = MAX_CHART_LINES): string[] => {
if (!Array.isArray(value)) {
return DEFAULT_CHART_LINES;
}
const filtered = value
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim())
.filter(Boolean)
.slice(0, maxLines);
return filtered.length ? filtered : DEFAULT_CHART_LINES;
};
const loadChartLines = (): string[] => {
try {
if (typeof localStorage === 'undefined') {
return DEFAULT_CHART_LINES;
}
const raw = localStorage.getItem(CHART_LINES_STORAGE_KEY);
if (!raw) {
return DEFAULT_CHART_LINES;
}
return normalizeChartLines(JSON.parse(raw));
} catch {
return DEFAULT_CHART_LINES;
}
};
const loadTimeRange = (): UsageTimeRange => {
try {
if (typeof localStorage === 'undefined') {
return DEFAULT_TIME_RANGE;
}
const raw = localStorage.getItem(TIME_RANGE_STORAGE_KEY);
return isUsageTimeRange(raw) ? raw : DEFAULT_TIME_RANGE;
} catch {
return DEFAULT_TIME_RANGE;
}
};
export function UsagePage() {
const { t } = useTranslation();
const isMobile = useMediaQuery('(max-width: 768px)');
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const isDark = resolvedTheme === 'dark';
const config = useConfigStore((state) => state.config);
const openaiCompatibilityConfig = config?.openaiCompatibility;
const [openaiProvidersWithAuthIndex, setOpenaiProvidersWithAuthIndex] = useState<{
source: OpenAIProviderConfig[] | undefined;
providers: OpenAIProviderConfig[];
} | null>(null);
// Data hook
const {
usage,
loading,
error,
lastRefreshedAt,
modelPrices,
setModelPrices,
loadUsage,
handleExport,
handleImport,
handleImportChange,
importInputRef,
exporting,
importing,
} = useUsageData();
useHeaderRefresh(loadUsage);
// Chart lines state
const [chartLines, setChartLines] = useState<string[]>(loadChartLines);
const [timeRange, setTimeRange] = useState<UsageTimeRange>(loadTimeRange);
useEffect(() => {
let cancelled = false;
const source = openaiCompatibilityConfig;
providersApi
.getOpenAIProviders()
.then((providers) => {
if (cancelled) return;
setOpenaiProvidersWithAuthIndex({ source, providers: providers || [] });
})
.catch(() => {
if (cancelled) return;
setOpenaiProvidersWithAuthIndex(null);
});
return () => {
cancelled = true;
};
}, [openaiCompatibilityConfig]);
const openaiProviderState = openaiProvidersWithAuthIndex;
const openaiProvidersForUsage =
openaiProviderState && openaiProviderState.source === openaiCompatibilityConfig
? openaiProviderState.providers
: (openaiCompatibilityConfig ?? []);
const timeRangeOptions = useMemo(
() =>
TIME_RANGE_OPTIONS.map((opt) => ({
value: opt.value,
label: t(opt.labelKey),
})),
[t]
);
const filteredUsage = useMemo(
() => (usage ? filterUsageByTimeRange(usage, timeRange) : null),
[usage, timeRange]
);
const hourWindowHours = timeRange === 'all' ? undefined : HOUR_WINDOW_BY_TIME_RANGE[timeRange];
const handleChartLinesChange = useCallback((lines: string[]) => {
setChartLines(normalizeChartLines(lines));
}, []);
useEffect(() => {
try {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(CHART_LINES_STORAGE_KEY, JSON.stringify(chartLines));
} catch {
// Ignore storage errors.
}
}, [chartLines]);
useEffect(() => {
try {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(TIME_RANGE_STORAGE_KEY, timeRange);
} catch {
// Ignore storage errors.
}
}, [timeRange]);
const nowMs = lastRefreshedAt?.getTime() ?? 0;
// Sparklines hook
const { requestsSparkline, tokensSparkline, rpmSparkline, tpmSparkline, costSparkline } =
useSparklines({ usage: filteredUsage, loading, nowMs });
// Chart data hook
const {
requestsPeriod,
setRequestsPeriod,
tokensPeriod,
setTokensPeriod,
requestsChartData,
tokensChartData,
requestsChartOptions,
tokensChartOptions,
} = useChartData({ usage: filteredUsage, chartLines, isDark, isMobile, hourWindowHours });
// Derived data
const modelNames = useMemo(() => getModelNamesFromUsage(usage), [usage]);
const apiStats = useMemo(
() => getApiStats(filteredUsage, modelPrices),
[filteredUsage, modelPrices]
);
const modelStats = useMemo(
() => getModelStats(filteredUsage, modelPrices),
[filteredUsage, modelPrices]
);
const hasPrices = Object.keys(modelPrices).length > 0;
return (
<div className={styles.container}>
{loading && !usage && (
<div className={styles.loadingOverlay} aria-busy="true">
<div className={styles.loadingOverlayContent}>
<LoadingSpinner size={28} className={styles.loadingOverlaySpinner} />
<span className={styles.loadingOverlayText}>{t('common.loading')}</span>
</div>
</div>
)}
<div className={styles.header}>
<h1 className={styles.pageTitle}>{t('usage_stats.title')}</h1>
<div className={styles.headerActions}>
<div className={styles.timeRangeGroup}>
<span className={styles.timeRangeLabel}>{t('usage_stats.range_filter')}</span>
<Select
value={timeRange}
options={timeRangeOptions}
onChange={(value) => setTimeRange(value as UsageTimeRange)}
className={styles.timeRangeSelectControl}
ariaLabel={t('usage_stats.range_filter')}
fullWidth={false}
/>
</div>
<Button
variant="secondary"
size="sm"
onClick={handleExport}
loading={exporting}
disabled={loading || importing}
>
{t('usage_stats.export')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleImport}
loading={importing}
disabled={loading || exporting}
>
{t('usage_stats.import')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => void loadUsage().catch(() => {})}
disabled={loading || exporting || importing}
>
{loading ? t('common.loading') : t('usage_stats.refresh')}
</Button>
<input
ref={importInputRef}
type="file"
accept=".json,application/json"
style={{ display: 'none' }}
onChange={handleImportChange}
/>
{lastRefreshedAt && (
<span className={styles.lastRefreshed}>
{t('usage_stats.last_updated')}: {lastRefreshedAt.toLocaleTimeString()}
</span>
)}
</div>
</div>
{error && <div className={styles.errorBox}>{error}</div>}
{/* Stats Overview Cards */}
<StatCards
usage={filteredUsage}
loading={loading}
modelPrices={modelPrices}
nowMs={nowMs}
sparklines={{
requests: requestsSparkline,
tokens: tokensSparkline,
rpm: rpmSparkline,
tpm: tpmSparkline,
cost: costSparkline,
}}
/>
{/* Chart Line Selection */}
<ChartLineSelector
chartLines={chartLines}
modelNames={modelNames}
maxLines={MAX_CHART_LINES}
onChange={handleChartLinesChange}
/>
{/* Service Health */}
<ServiceHealthCard usage={usage} loading={loading} />
{/* Charts Grid */}
<div className={styles.chartsGrid}>
<UsageChart
title={t('usage_stats.requests_trend')}
period={requestsPeriod}
onPeriodChange={setRequestsPeriod}
chartData={requestsChartData}
chartOptions={requestsChartOptions}
loading={loading}
isMobile={isMobile}
emptyText={t('usage_stats.no_data')}
/>
<UsageChart
title={t('usage_stats.tokens_trend')}
period={tokensPeriod}
onPeriodChange={setTokensPeriod}
chartData={tokensChartData}
chartOptions={tokensChartOptions}
loading={loading}
isMobile={isMobile}
emptyText={t('usage_stats.no_data')}
/>
</div>
{/* Token Breakdown Chart */}
<TokenBreakdownChart
usage={filteredUsage}
loading={loading}
isDark={isDark}
isMobile={isMobile}
hourWindowHours={hourWindowHours}
/>
{/* Cost Trend Chart */}
<CostTrendChart
usage={filteredUsage}
loading={loading}
isDark={isDark}
isMobile={isMobile}
modelPrices={modelPrices}
hourWindowHours={hourWindowHours}
/>
{/* Details Grid */}
<div className={styles.detailsGrid}>
<ApiDetailsCard apiStats={apiStats} loading={loading} hasPrices={hasPrices} />
<ModelStatsCard modelStats={modelStats} loading={loading} hasPrices={hasPrices} />
</div>
<RequestEventsDetailsCard
usage={filteredUsage}
loading={loading}
geminiKeys={config?.geminiApiKeys || []}
claudeConfigs={config?.claudeApiKeys || []}
codexConfigs={config?.codexApiKeys || []}
vertexConfigs={config?.vertexApiKeys || []}
openaiProviders={openaiProvidersForUsage}
/>
{/* Credential Stats */}
<CredentialStatsCard
usage={filteredUsage}
loading={loading}
geminiKeys={config?.geminiApiKeys || []}
claudeConfigs={config?.claudeApiKeys || []}
codexConfigs={config?.codexApiKeys || []}
vertexConfigs={config?.vertexApiKeys || []}
openaiProviders={openaiProvidersForUsage}
/>
{/* Price Settings */}
<PriceSettingsCard
modelNames={modelNames}
modelPrices={modelPrices}
onPricesChange={setModelPrices}
/>
</div>
);
}
-252
View File
@@ -1,252 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { authFilesApi } from '@/services/api/authFiles';
import { USAGE_STATS_STALE_TIME_MS, useUsageStatsStore } from '@/stores';
import type { AuthFileItem, Config } from '@/types';
import type { CredentialInfo, SourceInfo } from '@/types/sourceInfo';
import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver';
import { parseTimestampMs } from '@/utils/timestamp';
import {
collectUsageDetailsWithEndpoint,
normalizeAuthIndex,
type UsageDetailWithEndpoint
} from '@/utils/usage';
import type { ParsedLogLine } from './logTypes';
export type TraceCandidate = {
detail: UsageDetailWithEndpoint;
modelMatched: boolean;
timeDeltaMs: number | null;
};
const TRACE_AUTH_CACHE_MS = 60 * 1000;
const TRACE_MAX_CANDIDATES = 5;
const TRACEABLE_EXACT_PATHS = new Set(['/v1/chat/completions', '/v1/messages', '/v1/responses']);
const TRACEABLE_PREFIX_PATHS = ['/v1beta/models'];
const normalizeTracePath = (value?: string) =>
String(value ?? '')
.replace(/^"+|"+$/g, '')
.split('?')[0]
.trim();
const normalizeTraceablePath = (value?: string): string => {
const normalized = normalizeTracePath(value);
if (!normalized || normalized === '/') return normalized;
return normalized.replace(/\/+$/, '');
};
export const isTraceableRequestPath = (value?: string): boolean => {
const normalizedPath = normalizeTraceablePath(value);
if (!normalizedPath) return false;
if (TRACEABLE_EXACT_PATHS.has(normalizedPath)) return true;
return TRACEABLE_PREFIX_PATHS.some((prefix) => normalizedPath.startsWith(prefix));
};
const MODEL_EXTRACT_REGEX = /\bmodel[=:]\s*"?([a-zA-Z0-9._\-/]+)"?/i;
const extractModelFromMessage = (message?: string): string | undefined => {
if (!message) return undefined;
const match = message.match(MODEL_EXTRACT_REGEX);
return match?.[1] || undefined;
};
const isPathMatch = (logPath: string, detailPath: string): boolean => {
if (!logPath || !detailPath) return false;
return logPath === detailPath || logPath.startsWith(detailPath) || detailPath.startsWith(logPath);
};
const getErrorMessage = (err: unknown): string => {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
if (typeof err !== 'object' || err === null) return '';
if (!('message' in err)) return '';
const message = (err as { message?: unknown }).message;
return typeof message === 'string' ? message : '';
};
interface UseTraceResolverOptions {
traceScopeKey: string;
connectionStatus: string;
config: Config | null;
requestLogDownloading: boolean;
}
interface UseTraceResolverReturn {
traceLogLine: ParsedLogLine | null;
traceLoading: boolean;
traceError: string;
traceCandidates: TraceCandidate[];
resolveTraceSourceInfo: (sourceRaw: string, authIndex: unknown) => SourceInfo;
loadTraceUsageDetails: () => Promise<void>;
refreshTraceUsageDetails: () => Promise<void>;
openTraceModal: (line: ParsedLogLine) => void;
closeTraceModal: () => void;
}
export function useTraceResolver(options: UseTraceResolverOptions): UseTraceResolverReturn {
const { traceScopeKey, connectionStatus, config, requestLogDownloading } = options;
const { t } = useTranslation();
const usageSnapshot = useUsageStatsStore((state) => state.usage);
const usageScopeKey = useUsageStatsStore((state) => state.scopeKey);
const loadUsageStats = useUsageStatsStore((state) => state.loadUsageStats);
const [traceLogLine, setTraceLogLine] = useState<ParsedLogLine | null>(null);
const [traceAuthFileMap, setTraceAuthFileMap] = useState<Map<string, CredentialInfo>>(new Map());
const [traceLoading, setTraceLoading] = useState(false);
const [traceError, setTraceError] = useState('');
const traceAuthLoadedAtRef = useRef(0);
const traceScopeKeyRef = useRef('');
const scopedUsageSnapshot = usageScopeKey === traceScopeKey ? usageSnapshot : null;
const traceUsageDetails = useMemo<UsageDetailWithEndpoint[]>(
() => collectUsageDetailsWithEndpoint(scopedUsageSnapshot),
[scopedUsageSnapshot]
);
const traceSourceInfoMap = useMemo(() => buildSourceInfoMap(config ?? {}), [config]);
const loadTraceUsageDetailsInternal = useCallback(async (forceUsage: boolean) => {
if (traceScopeKeyRef.current !== traceScopeKey) {
traceScopeKeyRef.current = traceScopeKey;
traceAuthLoadedAtRef.current = 0;
setTraceAuthFileMap(new Map());
setTraceError('');
}
if (traceLoading) return;
const now = Date.now();
const authFresh =
traceAuthLoadedAtRef.current > 0 && now - traceAuthLoadedAtRef.current < TRACE_AUTH_CACHE_MS;
setTraceLoading(true);
setTraceError('');
try {
const [, authFilesResponse] = await Promise.all([
loadUsageStats({
force: forceUsage,
staleTimeMs: USAGE_STATS_STALE_TIME_MS
}),
authFresh ? Promise.resolve(null) : authFilesApi.list().catch(() => null)
]);
if (authFilesResponse !== null) {
const files = Array.isArray(authFilesResponse)
? authFilesResponse
: (authFilesResponse as { files?: AuthFileItem[] })?.files;
if (Array.isArray(files)) {
const map = new Map<string, CredentialInfo>();
files.forEach((file) => {
const key = normalizeAuthIndex(file['auth_index'] ?? file.authIndex);
if (!key) return;
map.set(key, {
name: file.name || key,
type: (file.type || file.provider || '').toString()
});
});
setTraceAuthFileMap(map);
traceAuthLoadedAtRef.current = Date.now();
}
}
} catch (err: unknown) {
setTraceError(getErrorMessage(err) || t('logs.trace_usage_load_error'));
} finally {
setTraceLoading(false);
}
}, [loadUsageStats, t, traceLoading, traceScopeKey]);
const loadTraceUsageDetails = useCallback(async () => {
await loadTraceUsageDetailsInternal(false);
}, [loadTraceUsageDetailsInternal]);
const refreshTraceUsageDetails = useCallback(async () => {
await loadTraceUsageDetailsInternal(true);
}, [loadTraceUsageDetailsInternal]);
useEffect(() => {
if (connectionStatus === 'connected') {
traceScopeKeyRef.current = traceScopeKey;
traceAuthLoadedAtRef.current = 0;
setTraceAuthFileMap(new Map());
setTraceLoading(false);
setTraceError('');
}
}, [connectionStatus, traceScopeKey]);
const traceCandidates = useMemo(() => {
if (!traceLogLine) return [];
const logPath = normalizeTracePath(traceLogLine.path);
if (!logPath) return [];
const logTimestampMs = traceLogLine.timestamp
? parseTimestampMs(traceLogLine.timestamp)
: Number.NaN;
// Step 1: filter by path match
const pathMatched = traceUsageDetails.filter((detail) =>
isPathMatch(logPath, normalizeTracePath(detail.__endpointPath))
);
if (pathMatched.length === 0) return [];
// Step 2: try to extract model from log message, then filter by model
const logModel = extractModelFromMessage(traceLogLine.message);
const modelMatched = logModel
? pathMatched.filter(
(d) => d.__modelName?.toLowerCase() === logModel.toLowerCase()
)
: [];
// Step 3: prefer model-matched set; fall back to path-matched
const useModelSet = modelMatched.length > 0;
const source = useModelSet ? modelMatched : pathMatched;
return source
.map((detail) => {
const timeDeltaMs =
!Number.isNaN(logTimestampMs) && detail.__timestampMs > 0
? Math.abs(logTimestampMs - detail.__timestampMs)
: null;
return { detail, modelMatched: useModelSet, timeDeltaMs } satisfies TraceCandidate;
})
.sort((a, b) => (b.detail.__timestampMs || 0) - (a.detail.__timestampMs || 0))
.slice(0, TRACE_MAX_CANDIDATES);
}, [traceLogLine, traceUsageDetails]);
const resolveTraceSourceInfo = useCallback(
(sourceRaw: string, authIndex: unknown): SourceInfo =>
resolveSourceDisplay(sourceRaw, authIndex, traceSourceInfoMap, traceAuthFileMap),
[traceAuthFileMap, traceSourceInfoMap]
);
const openTraceModal = useCallback(
(line: ParsedLogLine) => {
if (!isTraceableRequestPath(line.path)) return;
setTraceError('');
setTraceLogLine(line);
void loadTraceUsageDetails();
},
[loadTraceUsageDetails]
);
const closeTraceModal = useCallback(() => {
if (requestLogDownloading) return;
setTraceLogLine(null);
}, [requestLogDownloading]);
return {
traceLogLine,
traceLoading,
traceError,
traceCandidates,
resolveTraceSourceInfo,
loadTraceUsageDetails,
refreshTraceUsageDetails,
openTraceModal,
closeTraceModal
};
}
-2
View File
@@ -16,7 +16,6 @@ import { AuthFilesOAuthExcludedEditPage } from '@/pages/AuthFilesOAuthExcludedEd
import { AuthFilesOAuthModelAliasEditPage } from '@/pages/AuthFilesOAuthModelAliasEditPage';
import { OAuthPage } from '@/pages/OAuthPage';
import { QuotaPage } from '@/pages/QuotaPage';
import { UsagePage } from '@/pages/UsagePage';
import { ConfigPage } from '@/pages/ConfigPage';
import { LogsPage } from '@/pages/LogsPage';
import { SystemPage } from '@/pages/SystemPage';
@@ -72,7 +71,6 @@ const mainRoutes = [
{ path: '/auth-files/oauth-model-alias', element: <AuthFilesOAuthModelAliasEditPage /> },
{ path: '/oauth', element: <OAuthPage /> },
{ path: '/quota', element: <QuotaPage /> },
{ path: '/usage', element: <UsagePage /> },
{ path: '/config', element: <ConfigPage /> },
{ path: '/logs', element: <LogsPage /> },
{ path: '/system', element: <SystemPage /> },
-6
View File
@@ -52,12 +52,6 @@ export const configApi = {
updateSwitchPreviewModel: (enabled: boolean) =>
apiClient.put('/quota-exceeded/switch-preview-model', { value: enabled }),
/**
* 使
*/
updateUsageStatistics: (enabled: boolean) =>
apiClient.put('/usage-statistics-enabled', { value: enabled }),
/**
*
*/
-1
View File
@@ -8,7 +8,6 @@ export * from './ampcode';
export * from './providers';
export * from './authFiles';
export * from './oauth';
export * from './usage';
export * from './logs';
export * from './version';
export * from './models';
-3
View File
@@ -400,9 +400,6 @@ export const normalizeConfigResponse = (raw: unknown): Config => {
};
}
config.usageStatisticsEnabled = normalizeBoolean(
raw['usage-statistics-enabled'] ?? raw.usageStatisticsEnabled
);
config.requestLog = normalizeBoolean(raw['request-log'] ?? raw.requestLog);
config.loggingToFile = normalizeBoolean(raw['logging-to-file'] ?? raw.loggingToFile);
const logsMaxTotalSizeMb = raw['logs-max-total-size-mb'] ?? raw.logsMaxTotalSizeMb;
-53
View File
@@ -1,53 +0,0 @@
/**
* 使 API
*/
import { apiClient } from './client';
import { computeKeyStats, KeyStats } from '@/utils/usage';
const USAGE_TIMEOUT_MS = 60 * 1000;
export interface UsageExportPayload {
version?: number;
exported_at?: string;
usage?: Record<string, unknown>;
[key: string]: unknown;
}
export interface UsageImportResponse {
added?: number;
skipped?: number;
total_requests?: number;
failed_requests?: number;
[key: string]: unknown;
}
export const usageApi = {
/**
* 使
*/
getUsage: () => apiClient.get<Record<string, unknown>>('/usage', { timeout: USAGE_TIMEOUT_MS }),
/**
* 使
*/
exportUsage: () => apiClient.get<UsageExportPayload>('/usage/export', { timeout: USAGE_TIMEOUT_MS }),
/**
* 使
*/
importUsage: (payload: unknown) =>
apiClient.post<UsageImportResponse>('/usage/import', payload, { timeout: USAGE_TIMEOUT_MS }),
/**
* / usage
*/
async getKeyStats(usageData?: unknown): Promise<KeyStats> {
let payload = usageData;
if (!payload) {
const response = await apiClient.get<Record<string, unknown>>('/usage', { timeout: USAGE_TIMEOUT_MS });
payload = response?.usage ?? response;
}
return computeKeyStats(payload);
}
};
-1
View File
@@ -11,4 +11,3 @@ export { useModelsStore } from './useModelsStore';
export { useQuotaStore } from './useQuotaStore';
export { useOpenAIEditDraftStore } from './useOpenAIEditDraftStore';
export { useClaudeEditDraftStore } from './useClaudeEditDraftStore';
export { useUsageStatsStore, USAGE_STATS_STALE_TIME_MS } from './useUsageStatsStore';
-2
View File
@@ -10,7 +10,6 @@ import { STORAGE_KEY_AUTH } from '@/utils/constants';
import { obfuscatedStorage } from '@/services/storage/secureStorage';
import { apiClient } from '@/services/api/client';
import { useConfigStore } from './useConfigStore';
import { useUsageStatsStore } from './useUsageStatsStore';
import { useModelsStore } from './useModelsStore';
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
@@ -139,7 +138,6 @@ export const useAuthStore = create<AuthStoreState>()(
logout: () => {
restoreSessionPromise = null;
useConfigStore.getState().clearCache();
useUsageStatsStore.getState().clearUsageStats();
useModelsStore.getState().clearCache();
set({
isAuthenticated: false,
-6
View File
@@ -38,7 +38,6 @@ const SECTION_KEYS: RawConfigSection[] = [
'proxy-url',
'request-retry',
'quota-exceeded',
'usage-statistics-enabled',
'request-log',
'logging-to-file',
'logs-max-total-size-mb',
@@ -66,8 +65,6 @@ const extractSectionValue = (config: Config | null, section?: RawConfigSection)
return config.requestRetry;
case 'quota-exceeded':
return config.quotaExceeded;
case 'usage-statistics-enabled':
return config.usageStatisticsEnabled;
case 'request-log':
return config.requestLog;
case 'logging-to-file':
@@ -202,9 +199,6 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
case 'quota-exceeded':
nextConfig.quotaExceeded = value as Config['quotaExceeded'];
break;
case 'usage-statistics-enabled':
nextConfig.usageStatisticsEnabled = value as Config['usageStatisticsEnabled'];
break;
case 'request-log':
nextConfig.requestLog = value as Config['requestLog'];
break;
-144
View File
@@ -1,144 +0,0 @@
import { create } from 'zustand';
import { usageApi } from '@/services/api';
import { useAuthStore } from '@/stores/useAuthStore';
import { collectUsageDetails, computeKeyStatsFromDetails, type KeyStats, type UsageDetail } from '@/utils/usage';
import i18n from '@/i18n';
export const USAGE_STATS_STALE_TIME_MS = 240_000;
export type LoadUsageStatsOptions = {
force?: boolean;
staleTimeMs?: number;
};
type UsageStatsSnapshot = Record<string, unknown>;
type UsageStatsState = {
usage: UsageStatsSnapshot | null;
keyStats: KeyStats;
usageDetails: UsageDetail[];
loading: boolean;
error: string | null;
lastRefreshedAt: number | null;
scopeKey: string;
loadUsageStats: (options?: LoadUsageStatsOptions) => Promise<void>;
clearUsageStats: () => void;
};
const createEmptyKeyStats = (): KeyStats => ({ bySource: {}, byAuthIndex: {} });
let usageRequestToken = 0;
let inFlightUsageRequest: { id: number; scopeKey: string; promise: Promise<void> } | null = null;
const getErrorMessage = (error: unknown) =>
error instanceof Error
? error.message
: typeof error === 'string'
? error
: i18n.t('usage_stats.loading_error');
export const useUsageStatsStore = create<UsageStatsState>((set, get) => ({
usage: null,
keyStats: createEmptyKeyStats(),
usageDetails: [],
loading: false,
error: null,
lastRefreshedAt: null,
scopeKey: '',
loadUsageStats: async (options = {}) => {
const force = options.force === true;
const staleTimeMs = options.staleTimeMs ?? USAGE_STATS_STALE_TIME_MS;
const { apiBase = '', managementKey = '' } = useAuthStore.getState();
const scopeKey = `${apiBase}::${managementKey}`;
const state = get();
const scopeChanged = state.scopeKey !== scopeKey;
// 先复用同源 in-flight 请求,避免多个页面同时发起重复 /usage。
if (inFlightUsageRequest && inFlightUsageRequest.scopeKey === scopeKey) {
await inFlightUsageRequest.promise;
return;
}
// 连接目标变化时,旧请求结果必须失效。
if (inFlightUsageRequest && inFlightUsageRequest.scopeKey !== scopeKey) {
usageRequestToken += 1;
inFlightUsageRequest = null;
}
const fresh =
!scopeChanged &&
state.lastRefreshedAt !== null &&
Date.now() - state.lastRefreshedAt < staleTimeMs;
if (!force && fresh) {
return;
}
if (scopeChanged) {
set({
usage: null,
keyStats: createEmptyKeyStats(),
usageDetails: [],
error: null,
lastRefreshedAt: null,
scopeKey
});
}
const requestId = (usageRequestToken += 1);
set({ loading: true, error: null, scopeKey });
const requestPromise = (async () => {
try {
const usageResponse = await usageApi.getUsage();
const rawUsage = usageResponse?.usage ?? usageResponse;
const usage =
rawUsage && typeof rawUsage === 'object' ? (rawUsage as UsageStatsSnapshot) : null;
if (requestId !== usageRequestToken) return;
const usageDetails = collectUsageDetails(usage);
set({
usage,
keyStats: computeKeyStatsFromDetails(usageDetails),
usageDetails,
loading: false,
error: null,
lastRefreshedAt: Date.now(),
scopeKey
});
} catch (error: unknown) {
if (requestId !== usageRequestToken) return;
const message = getErrorMessage(error);
set({
loading: false,
error: message,
scopeKey
});
throw new Error(message);
} finally {
if (inFlightUsageRequest?.id === requestId) {
inFlightUsageRequest = null;
}
}
})();
inFlightUsageRequest = { id: requestId, scopeKey, promise: requestPromise };
await requestPromise;
},
clearUsageStats: () => {
usageRequestToken += 1;
inFlightUsageRequest = null;
set({
usage: null,
keyStats: createEmptyKeyStats(),
usageDetails: [],
loading: false,
error: null,
lastRefreshedAt: null,
scopeKey: ''
});
}
}));
-2
View File
@@ -17,7 +17,6 @@ export interface Config {
proxyUrl?: string;
requestRetry?: number;
quotaExceeded?: QuotaExceededConfig;
usageStatisticsEnabled?: boolean;
requestLog?: boolean;
loggingToFile?: boolean;
logsMaxTotalSizeMb?: number;
@@ -40,7 +39,6 @@ export type RawConfigSection =
| 'proxy-url'
| 'request-retry'
| 'quota-exceeded'
| 'usage-statistics-enabled'
| 'request-log'
| 'logging-to-file'
| 'logs-max-total-size-mb'
-2
View File
@@ -10,7 +10,5 @@ export * from './provider';
export * from './ampcode';
export * from './authFile';
export * from './oauth';
export * from './usage';
export * from './log';
export * from './quota';
export * from './sourceInfo';
-10
View File
@@ -1,10 +0,0 @@
export type SourceInfo = {
displayName: string;
type: string;
identityKey?: string;
};
export type CredentialInfo = {
name: string;
type: string;
};
-52
View File
@@ -1,52 +0,0 @@
/**
* 使
* src/modules/usage.js
*/
// 时间段类型
export type TimePeriod = 'hour' | 'day';
// 数据点
export interface DataPoint {
timestamp: string;
value: number;
}
// 模型使用统计
export interface ModelUsage {
modelName: string;
requests: number;
inputTokens: number;
outputTokens: number;
totalTokens: number;
cost: number;
}
// 使用统计数据
export interface UsageStats {
overview: {
totalRequests: number;
totalTokens: number;
totalCost: number;
};
requestsData: {
hour: DataPoint[];
day: DataPoint[];
};
tokensData: {
hour: DataPoint[];
day: DataPoint[];
};
costData: {
hour: DataPoint[];
day: DataPoint[];
};
modelStats: ModelUsage[];
}
// 模型价格
export interface ModelPrice {
modelName: string;
inputPricePer1M: number;
outputPricePer1M: number;
}
-2
View File
@@ -67,7 +67,6 @@ export type VisualConfigValues = {
commercialMode: boolean;
loggingToFile: boolean;
logsMaxTotalSizeMb: string;
usageStatisticsEnabled: boolean;
proxyUrl: string;
forceModelPrefix: boolean;
requestRetry: string;
@@ -109,7 +108,6 @@ export const DEFAULT_VISUAL_VALUES: VisualConfigValues = {
commercialMode: false,
loggingToFile: false,
logsMaxTotalSizeMb: '',
usageStatisticsEnabled: false,
proxyUrl: '',
forceModelPrefix: false,
requestRetry: '',
+10
View File
@@ -0,0 +1,10 @@
export const normalizeAuthIndex = (value: unknown): string | null => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value.toString();
}
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
return null;
};
-1
View File
@@ -76,6 +76,5 @@ export const API_ENDPOINTS = {
PROVIDERS: '/providers',
AUTH_FILES: '/auth-files',
OAUTH: '/oauth',
USAGE: '/usage',
LOGS: '/logs'
} as const;
+1 -1
View File
@@ -3,7 +3,7 @@
*/
import type { ClaudeUsagePayload, CodexUsagePayload, GeminiCliCodeAssistPayload, GeminiCliQuotaPayload, KimiUsagePayload } from '@/types';
import { normalizeAuthIndex } from '@/utils/usage';
import { normalizeAuthIndex } from '@/utils/authIndex';
const GEMINI_CLI_MODEL_SUFFIX = '_vertex';
export { normalizeAuthIndex };
+16 -2
View File
@@ -1,6 +1,20 @@
import type { StatusBarData, StatusBlockDetail, StatusBlockState } from '@/utils/usage';
export type StatusBlockState = 'success' | 'failure' | 'mixed' | 'idle';
export type { StatusBarData, StatusBlockDetail, StatusBlockState };
export interface StatusBlockDetail {
success: number;
failure: number;
rate: number;
startTime: number;
endTime: number;
}
export interface StatusBarData {
blocks: StatusBlockState[];
blockDetails: StatusBlockDetail[];
successRate: number;
totalSuccess: number;
totalFailure: number;
}
export interface RecentRequestBucket {
time?: string;
-169
View File
@@ -1,169 +0,0 @@
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
import type { CredentialInfo, SourceInfo } from '@/types/sourceInfo';
import { buildCandidateUsageSourceIds, normalizeAuthIndex } from '@/utils/usage';
export interface SourceInfoMapInput {
geminiApiKeys?: GeminiKeyConfig[];
claudeApiKeys?: ProviderKeyConfig[];
codexApiKeys?: ProviderKeyConfig[];
vertexApiKeys?: ProviderKeyConfig[];
openaiCompatibility?: OpenAIProviderConfig[];
}
type SourceInfoEntry = Required<Pick<SourceInfo, 'displayName' | 'type' | 'identityKey'>>;
export interface SourceInfoMap {
byAuthIndex: Map<string, SourceInfoEntry | null>;
bySource: Map<string, SourceInfoEntry | null>;
}
const buildProviderIdentityKey = (type: string, index: number) => `${type}:${index}`;
const registerIdentity = (
map: Map<string, SourceInfoEntry | null>,
key: string | null | undefined,
entry: SourceInfoEntry
) => {
if (!key) return;
const existing = map.get(key);
if (existing === undefined) {
map.set(key, entry);
return;
}
if (existing === null) {
return;
}
if (existing.identityKey === entry.identityKey) {
return;
}
map.set(key, null);
};
const formatRawSourceDisplayName = (source: string) => {
if (!source) return '-';
return source.startsWith('t:') ? source.slice(2) : source;
};
export function buildSourceInfoMap(input: SourceInfoMapInput): SourceInfoMap {
const byAuthIndex = new Map<string, SourceInfoEntry | null>();
const bySource = new Map<string, SourceInfoEntry | null>();
const registerProvider = (
entry: SourceInfoEntry,
authIndices: Array<unknown>,
candidates: Iterable<string>
) => {
authIndices.forEach((authIndex) => {
registerIdentity(byAuthIndex, normalizeAuthIndex(authIndex), entry);
});
Array.from(candidates).forEach((candidate) => {
registerIdentity(bySource, candidate, entry);
});
};
const providers: Array<{
items: Array<{ apiKey?: string; prefix?: string; authIndex?: string }>;
type: string;
label: string;
}> = [
{ items: input.geminiApiKeys || [], type: 'gemini', label: 'Gemini' },
{ items: input.claudeApiKeys || [], type: 'claude', label: 'Claude' },
{ items: input.codexApiKeys || [], type: 'codex', label: 'Codex' },
{ items: input.vertexApiKeys || [], type: 'vertex', label: 'Vertex' },
];
providers.forEach(({ items, type, label }) => {
items.forEach((item, index) => {
registerProvider(
{
displayName: item.prefix?.trim() || `${label} #${index + 1}`,
type,
identityKey: buildProviderIdentityKey(type, index),
},
[item.authIndex],
buildCandidateUsageSourceIds({ apiKey: item.apiKey, prefix: item.prefix })
);
});
});
(input.openaiCompatibility || []).forEach((provider, providerIndex) => {
const candidates = new Set<string>();
const authIndices: Array<unknown> = [provider.authIndex];
buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((id) => candidates.add(id));
(provider.apiKeyEntries || []).forEach((entry) => {
authIndices.push(entry.authIndex);
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => candidates.add(id));
});
registerProvider(
{
displayName: provider.prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`,
type: 'openai',
identityKey: buildProviderIdentityKey('openai', providerIndex),
},
authIndices,
candidates
);
});
return { byAuthIndex, bySource };
}
export function resolveSourceDisplay(
sourceRaw: string,
authIndex: unknown,
sourceInfoMap: SourceInfoMap,
authFileMap: Map<string, CredentialInfo>
): SourceInfo {
const source = sourceRaw.trim();
const authIndexKey = normalizeAuthIndex(authIndex);
if (authIndexKey) {
const matchedByAuthIndex = sourceInfoMap.byAuthIndex.get(authIndexKey);
if (matchedByAuthIndex) {
return matchedByAuthIndex;
}
const authInfo = authFileMap.get(authIndexKey);
if (authInfo) {
return {
displayName: authInfo.name || authIndexKey,
type: authInfo.type,
identityKey: `auth:${authIndexKey}`,
};
}
}
const matchedBySource = source ? sourceInfoMap.bySource.get(source) : null;
if (matchedBySource) {
return matchedBySource;
}
if (source) {
return {
displayName: formatRawSourceDisplayName(source),
type: '',
identityKey: `source:${source}`,
};
}
if (authIndexKey) {
return {
displayName: authIndexKey,
type: '',
identityKey: `auth:${authIndexKey}`,
};
}
return {
displayName: '-',
type: '',
identityKey: 'source:-',
};
}
-1945
View File
File diff suppressed because it is too large Load Diff
-142
View File
@@ -1,142 +0,0 @@
/**
* Chart.js configuration utilities for usage statistics
* Extracted from UsagePage.tsx for reusability
*/
import type { ChartOptions } from 'chart.js';
/**
* Static sparkline chart options (no dependencies on theme/mobile)
*/
export const sparklineOptions: ChartOptions<'line'> = {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false }, tooltip: { enabled: false } },
scales: { x: { display: false }, y: { display: false } },
elements: { line: { tension: 0.45 }, point: { radius: 0 } }
};
export interface ChartConfigOptions {
period: 'hour' | 'day';
labels: string[];
isDark: boolean;
isMobile: boolean;
}
/**
* Build chart options with theme and responsive awareness
*/
export function buildChartOptions({
period,
labels,
isDark,
isMobile
}: ChartConfigOptions): ChartOptions<'line'> {
const pointRadius = isMobile && period === 'hour' ? 0 : isMobile ? 2 : 4;
const tickFontSize = isMobile ? 10 : 12;
const maxTickLabelCount = isMobile ? (period === 'hour' ? 8 : 6) : period === 'hour' ? 12 : 10;
const gridColor = isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(17, 24, 39, 0.06)';
const axisBorderColor = isDark ? 'rgba(255, 255, 255, 0.10)' : 'rgba(17, 24, 39, 0.10)';
const tickColor = isDark ? 'rgba(255, 255, 255, 0.72)' : 'rgba(17, 24, 39, 0.72)';
const tooltipBg = isDark ? 'rgba(17, 24, 39, 0.92)' : 'rgba(255, 255, 255, 0.98)';
const tooltipTitle = isDark ? '#ffffff' : '#111827';
const tooltipBody = isDark ? 'rgba(255, 255, 255, 0.86)' : '#374151';
const tooltipBorder = isDark ? 'rgba(255, 255, 255, 0.10)' : 'rgba(17, 24, 39, 0.10)';
return {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: tooltipBg,
titleColor: tooltipTitle,
bodyColor: tooltipBody,
borderColor: tooltipBorder,
borderWidth: 1,
padding: 10,
displayColors: true,
usePointStyle: true
}
},
scales: {
x: {
grid: {
color: gridColor,
drawTicks: false
},
border: {
color: axisBorderColor
},
ticks: {
color: tickColor,
font: { size: tickFontSize },
maxRotation: isMobile ? 0 : 45,
minRotation: 0,
autoSkip: true,
maxTicksLimit: maxTickLabelCount,
callback: (value) => {
const index = typeof value === 'number' ? value : Number(value);
const raw =
Number.isFinite(index) && labels[index] ? labels[index] : typeof value === 'string' ? value : '';
if (period === 'hour') {
const [md, time] = raw.split(' ');
if (!time) return raw;
if (time.startsWith('00:')) {
return md ? [md, time] : time;
}
return time;
}
if (isMobile) {
const parts = raw.split('-');
if (parts.length === 3) {
return `${parts[1]}-${parts[2]}`;
}
}
return raw;
}
}
},
y: {
beginAtZero: true,
grid: {
color: gridColor
},
border: {
color: axisBorderColor
},
ticks: {
color: tickColor,
font: { size: tickFontSize }
}
}
},
elements: {
line: {
tension: 0.35,
borderWidth: isMobile ? 1.5 : 2
},
point: {
borderWidth: 2,
radius: pointRadius,
hoverRadius: 4
}
}
};
}
/**
* Calculate minimum chart width for hourly data on mobile devices
*/
export function getHourChartMinWidth(labelCount: number, isMobile: boolean): string | undefined {
if (!isMobile || labelCount <= 0) return undefined;
const perPoint = 56;
const minWidth = Math.min(labelCount * perPoint, 3000);
return `${minWidth}px`;
}
-6
View File
@@ -1,6 +0,0 @@
// Chart configuration utilities
export { sparklineOptions, buildChartOptions, getHourChartMinWidth } from './chartConfig';
export type { ChartConfigOptions } from './chartConfig';
// Re-export everything from the main usage.ts for backwards compatibility
export * from '../usage';
-199
View File
@@ -1,199 +0,0 @@
import i18n from '@/i18n';
export const LATENCY_SOURCE_FIELD = 'latency_ms';
export const LATENCY_SOURCE_UNIT = 'ms';
export interface LatencyStats {
averageMs: number | null;
totalMs: number | null;
sampleCount: number;
}
export interface DurationFormatOptions {
maxUnits?: number;
invalidText?: string;
secondDecimals?: number | 'auto';
locale?: string;
}
export interface LatencyAccumulator {
totalMs: number;
sampleCount: number;
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const normalizeDurationMaxUnits = (value: number | undefined): number => {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return 2;
}
return Math.min(Math.floor(parsed), 4);
};
const resolveSecondDecimalPlaces = (
seconds: number,
secondDecimals: number | 'auto' | undefined
): number => {
if (secondDecimals === 'auto' || secondDecimals === undefined) {
return seconds < 10 ? 2 : 1;
}
const parsed = Math.floor(Number(secondDecimals));
if (!Number.isFinite(parsed) || parsed < 0) {
return seconds < 10 ? 2 : 1;
}
return Math.min(parsed, 3);
};
const resolveDurationLocale = (locale?: string): string | undefined =>
locale?.trim() || i18n.resolvedLanguage || i18n.language || undefined;
const formatDurationNumber = (
value: number,
locale: string | undefined,
options: Intl.NumberFormatOptions = {}
): string => {
try {
return new Intl.NumberFormat(locale, {
useGrouping: false,
...options,
}).format(value);
} catch {
return String(value);
}
};
const getDurationUnitLabel = (unit: 'd' | 'h' | 'm' | 's' | 'ms'): string =>
i18n.t(`usage_stats.duration_unit_${unit}`, { defaultValue: unit });
const formatDurationPart = (
value: number,
unit: 'd' | 'h' | 'm' | 's' | 'ms',
locale: string | undefined,
options: Intl.NumberFormatOptions = {}
): string => `${formatDurationNumber(value, locale, options)}${getDurationUnitLabel(unit)}`;
/**
* latency_ms
*/
export function extractLatencyMs(detail: unknown): number | null {
const record = isRecord(detail) ? detail : null;
const rawValue = record?.[LATENCY_SOURCE_FIELD];
if (
rawValue === null ||
rawValue === undefined ||
(typeof rawValue === 'string' && rawValue.trim() === '')
) {
return null;
}
const parsed = Number(rawValue);
if (!Number.isFinite(parsed) || parsed < 0) {
return null;
}
return parsed;
}
export const createLatencyAccumulator = (): LatencyAccumulator => ({
totalMs: 0,
sampleCount: 0,
});
export const addLatencySample = (
accumulator: LatencyAccumulator,
latencyMs: number | null | undefined
): void => {
if (latencyMs === null || latencyMs === undefined) {
return;
}
const parsed = Number(latencyMs);
if (!Number.isFinite(parsed) || parsed < 0) {
return;
}
accumulator.totalMs += parsed;
accumulator.sampleCount += 1;
};
export const finalizeLatencyStats = (accumulator: LatencyAccumulator): LatencyStats => ({
averageMs: accumulator.sampleCount > 0 ? accumulator.totalMs / accumulator.sampleCount : null,
totalMs: accumulator.sampleCount > 0 ? accumulator.totalMs : null,
sampleCount: accumulator.sampleCount,
});
/**
*
*/
export function calculateLatencyStatsFromDetails(details: Iterable<unknown>): LatencyStats {
const accumulator = createLatencyAccumulator();
for (const detail of details) {
addLatencySample(accumulator, extractLatencyMs(detail));
}
return finalizeLatencyStats(accumulator);
}
/**
*
*/
export function formatDurationMs(
value: number | null | undefined,
options: DurationFormatOptions = {}
): string {
const invalidText = options.invalidText ?? '--';
if (value === null || value === undefined) {
return invalidText;
}
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
return invalidText;
}
const locale = resolveDurationLocale(options.locale);
if (parsed < 1000) {
return formatDurationPart(Math.round(parsed), 'ms', locale);
}
const seconds = parsed / 1000;
if (seconds < 60) {
const secondDecimalPlaces = resolveSecondDecimalPlaces(seconds, options.secondDecimals);
return formatDurationPart(seconds, 's', locale, {
minimumFractionDigits: 0,
maximumFractionDigits: secondDecimalPlaces,
});
}
const totalSeconds = Math.floor(seconds);
let remainingSeconds = totalSeconds;
const days = Math.floor(remainingSeconds / 86_400);
remainingSeconds -= days * 86_400;
const hours = Math.floor(remainingSeconds / 3_600);
remainingSeconds -= hours * 3_600;
const minutes = Math.floor(remainingSeconds / 60);
remainingSeconds -= minutes * 60;
const parts = [
{ unit: 'd' as const, value: days },
{ unit: 'h' as const, value: hours },
{ unit: 'm' as const, value: minutes },
{ unit: 's' as const, value: remainingSeconds },
].filter((part) => part.value > 0);
if (!parts.length) {
return formatDurationPart(0, 's', locale);
}
return parts
.slice(0, normalizeDurationMaxUnits(options.maxUnits))
.map((part, index) =>
formatDurationPart(part.value, part.unit, locale, {
minimumIntegerDigits: index > 0 && (part.unit === 'm' || part.unit === 's') ? 2 : 1,
maximumFractionDigits: 0,
})
)
.join(' ');
}
-84
View File
@@ -1,84 +0,0 @@
import type { UsageDetail } from '@/utils/usage';
import { normalizeAuthIndex } from '@/utils/usage';
export type UsageDetailsBySource = Map<string, UsageDetail[]>;
export type UsageDetailsByAuthIndex = Map<string, UsageDetail[]>;
const EMPTY_USAGE_DETAILS: UsageDetail[] = [];
export function indexUsageDetailsBySource(usageDetails: UsageDetail[]): UsageDetailsBySource {
const map: UsageDetailsBySource = new Map();
usageDetails.forEach((detail) => {
const sourceId = detail.source;
if (!sourceId) return;
const bucket = map.get(sourceId);
if (bucket) {
bucket.push(detail);
} else {
map.set(sourceId, [detail]);
}
});
return map;
}
export function indexUsageDetailsByAuthIndex(usageDetails: UsageDetail[]): UsageDetailsByAuthIndex {
const map: UsageDetailsByAuthIndex = new Map();
usageDetails.forEach((detail) => {
const authIndexKey = normalizeAuthIndex(detail.auth_index);
if (!authIndexKey) return;
const bucket = map.get(authIndexKey);
if (bucket) {
bucket.push(detail);
} else {
map.set(authIndexKey, [detail]);
}
});
return map;
}
export function collectUsageDetailsForCandidates(
usageDetailsBySource: UsageDetailsBySource,
candidates: Iterable<string>
): UsageDetail[] {
return collectUsageDetailsForKeys(usageDetailsBySource, candidates);
}
export function collectUsageDetailsForAuthIndices(
usageDetailsByAuthIndex: UsageDetailsByAuthIndex,
authIndices: Iterable<string>
): UsageDetail[] {
return collectUsageDetailsForKeys(usageDetailsByAuthIndex, authIndices);
}
function collectUsageDetailsForKeys(
usageDetailsByKey: Map<string, UsageDetail[]>,
keys: Iterable<string>
): UsageDetail[] {
let firstDetails: UsageDetail[] | null = null;
let merged: UsageDetail[] | null = null;
for (const key of keys) {
const details = usageDetailsByKey.get(key);
if (!details || details.length === 0) continue;
if (!firstDetails) {
firstDetails = details;
continue;
}
if (!merged) {
merged = [...firstDetails];
}
for (const detail of details) {
merged.push(detail);
}
}
return merged ?? firstDetails ?? EMPTY_USAGE_DETAILS;
}