mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
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:
@@ -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 isn’t)
|
||||
|
||||
- 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
@@ -1,6 +1,6 @@
|
||||
# CLI Proxy API 管理中心
|
||||
|
||||
用于管理与故障排查 **CLI Proxy API** 的单文件 Web UI(React + TypeScript),通过 **Management API** 完成配置、凭据、日志与统计等管理操作。
|
||||
用于管理与故障排查 **CLI Proxy API** 的单文件 Web UI(React + 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 才能查询模型)。
|
||||
|
||||
Generated
+295
-265
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }]
|
||||
: []),
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": "記錄容量限制",
|
||||
@@ -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": "請求記錄設定已更新",
|
||||
|
||||
@@ -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}`}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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 /> },
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
/**
|
||||
* 请求日志开关
|
||||
*/
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: ''
|
||||
});
|
||||
}
|
||||
}));
|
||||
@@ -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'
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export type SourceInfo = {
|
||||
displayName: string;
|
||||
type: string;
|
||||
identityKey?: string;
|
||||
};
|
||||
|
||||
export type CredentialInfo = {
|
||||
name: string;
|
||||
type: string;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -76,6 +76,5 @@ export const API_ENDPOINTS = {
|
||||
PROVIDERS: '/providers',
|
||||
AUTH_FILES: '/auth-files',
|
||||
OAUTH: '/oauth',
|
||||
USAGE: '/usage',
|
||||
LOGS: '/logs'
|
||||
} as const;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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`;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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(' ');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user