diff --git a/README.md b/README.md index 601e680..d49430f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README_CN.md b/README_CN.md index a9e66b8..8792847 100644 --- a/README_CN.md +++ b/README_CN.md @@ -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 才能查询模型)。 diff --git a/package-lock.json b/package-lock.json index a2610be..27bf6fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index e6bdc06..ee9e6f3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/config/VisualConfigEditor.tsx b/src/components/config/VisualConfigEditor.tsx index b49aacc..9dc45a1 100644 --- a/src/components/config/VisualConfigEditor.tsx +++ b/src/components/config/VisualConfigEditor.tsx @@ -789,13 +789,6 @@ export function VisualConfigEditor({ disabled={disabled} onChange={(loggingToFile) => onChange({ loggingToFile })} /> - onChange({ usageStatisticsEnabled })} - /> diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index e8edf65..7995121 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -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 = { authFiles: , oauth: , quota: , - usage: , config: , logs: , system: , @@ -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 }] : []), diff --git a/src/components/quota/quotaConfigs.ts b/src/components/quota/quotaConfigs.ts index a0f5578..7ca5d81 100644 --- a/src/components/quota/quotaConfigs.ts +++ b/src/components/quota/quotaConfigs.ts @@ -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'; diff --git a/src/components/ui/icons.tsx b/src/components/ui/icons.tsx index 3f93e14..28c6cdd 100644 --- a/src/components/ui/icons.tsx +++ b/src/components/ui/icons.tsx @@ -429,25 +429,6 @@ export function IconSidebarQuota({ size = 20, ...props }: IconProps) { ); } -export function IconSidebarUsage({ size = 20, ...props }: IconProps) { - return ( - - - - - - - ); -} - export function IconSidebarLogs({ size = 20, ...props }: IconProps) { return ( diff --git a/src/components/usage/ApiDetailsCard.tsx b/src/components/usage/ApiDetailsCard.tsx deleted file mode 100644 index aba6b60..0000000 --- a/src/components/usage/ApiDetailsCard.tsx +++ /dev/null @@ -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>(new Set()); - const [sortKey, setSortKey] = useState('requests'); - const [sortDir, setSortDir] = useState('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 ( - - {loading ? ( -
{t('common.loading')}
- ) : sorted.length > 0 ? ( - <> -
- {([ - ['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]) => ( - - ))} -
-
-
- {sorted.map((api, index) => { - const isExpanded = expandedApis.has(api.endpoint); - const panelId = `api-models-${index}`; - - return ( -
- - {isExpanded && ( -
- {Object.entries(api.models).map(([model, stats]) => ( -
- {model} - - - {stats.requests.toLocaleString()} - - ({stats.successCount.toLocaleString()}{' '} - {stats.failureCount.toLocaleString()}) - - - - {formatCompactNumber(stats.tokens)} -
- ))} -
- )} -
- ); - })} -
-
- - ) : ( -
{t('usage_stats.no_data')}
- )} - - ); -} diff --git a/src/components/usage/ChartLineSelector.tsx b/src/components/usage/ChartLineSelector.tsx deleted file mode 100644 index 22acc79..0000000 --- a/src/components/usage/ChartLineSelector.tsx +++ /dev/null @@ -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 ( - - - {chartLines.length}/{maxLines} - - - - } - > -
- {chartLines.map((line, index) => ( -
- - {t(`usage_stats.chart_line_label_${index + 1}`)} - - -
-
- - setPromptPrice(e.target.value)} - placeholder="0.00" - step="0.0001" - /> -
-
- - setCompletionPrice(e.target.value)} - placeholder="0.00" - step="0.0001" - /> -
-
- - setCachePrice(e.target.value)} - placeholder="0.00" - step="0.0001" - /> -
- -
- - - {/* Saved Prices List */} -
-

{t('usage_stats.saved_prices')}

- {Object.keys(modelPrices).length > 0 ? ( -
- {Object.entries(modelPrices).map(([model, price]) => ( -
-
- {model} -
- - {t('usage_stats.model_price_prompt')}: ${price.prompt.toFixed(4)}/1M - - - {t('usage_stats.model_price_completion')}: ${price.completion.toFixed(4)}/1M - - - {t('usage_stats.model_price_cache')}: ${price.cache.toFixed(4)}/1M - -
-
-
- - -
-
- ))} -
- ) : ( -
{t('usage_stats.model_price_empty')}
- )} -
- - - {/* Edit Modal */} - setEditModel(null)} - footer={ -
- - -
- } - width={420} - > -
-
- - setEditPrompt(e.target.value)} - placeholder="0.00" - step="0.0001" - /> -
-
- - setEditCompletion(e.target.value)} - placeholder="0.00" - step="0.0001" - /> -
-
- - setEditCache(e.target.value)} - placeholder="0.00" - step="0.0001" - /> -
-
-
-
- ); -} diff --git a/src/components/usage/RequestEventsDetailsCard.tsx b/src/components/usage/RequestEventsDetailsCard.tsx deleted file mode 100644 index bd2b321..0000000 --- a/src/components/usage/RequestEventsDetailsCard.tsx +++ /dev/null @@ -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>(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(); - 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(() => { - 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>(); - baseRows.forEach((row) => { - const keys = sourceLabelKeyMap.get(row.source) ?? new Set(); - 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(); - 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 ( - - - - - - } - > -
-
- - {t('usage_stats.request_events_filter_model')} - - -
-
- - {t('usage_stats.request_events_filter_auth_index')} - - setTimeRange(value as UsageTimeRange)} - className={styles.timeRangeSelectControl} - ariaLabel={t('usage_stats.range_filter')} - fullWidth={false} - /> -
- - - - - {lastRefreshedAt && ( - - {t('usage_stats.last_updated')}: {lastRefreshedAt.toLocaleTimeString()} - - )} -
- - - {error &&
{error}
} - - {/* Stats Overview Cards */} - - - {/* Chart Line Selection */} - - - {/* Service Health */} - - - {/* Charts Grid */} -
- - -
- - {/* Token Breakdown Chart */} - - - {/* Cost Trend Chart */} - - - {/* Details Grid */} -
- - -
- - - - {/* Credential Stats */} - - - {/* Price Settings */} - - - ); -} diff --git a/src/pages/hooks/useTraceResolver.ts b/src/pages/hooks/useTraceResolver.ts deleted file mode 100644 index 41da24e..0000000 --- a/src/pages/hooks/useTraceResolver.ts +++ /dev/null @@ -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; - refreshTraceUsageDetails: () => Promise; - 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(null); - const [traceAuthFileMap, setTraceAuthFileMap] = useState>(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( - () => 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(); - 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 - }; -} diff --git a/src/router/MainRoutes.tsx b/src/router/MainRoutes.tsx index 5551750..506a041 100644 --- a/src/router/MainRoutes.tsx +++ b/src/router/MainRoutes.tsx @@ -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: }, { path: '/oauth', element: }, { path: '/quota', element: }, - { path: '/usage', element: }, { path: '/config', element: }, { path: '/logs', element: }, { path: '/system', element: }, diff --git a/src/services/api/config.ts b/src/services/api/config.ts index 16096dc..8832ca6 100644 --- a/src/services/api/config.ts +++ b/src/services/api/config.ts @@ -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 }), - /** * 请求日志开关 */ diff --git a/src/services/api/index.ts b/src/services/api/index.ts index 23c585b..c7b2143 100644 --- a/src/services/api/index.ts +++ b/src/services/api/index.ts @@ -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'; diff --git a/src/services/api/transformers.ts b/src/services/api/transformers.ts index a0e4345..f235f1e 100644 --- a/src/services/api/transformers.ts +++ b/src/services/api/transformers.ts @@ -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; diff --git a/src/services/api/usage.ts b/src/services/api/usage.ts deleted file mode 100644 index 6041c23..0000000 --- a/src/services/api/usage.ts +++ /dev/null @@ -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; - [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>('/usage', { timeout: USAGE_TIMEOUT_MS }), - - /** - * 导出使用统计快照 - */ - exportUsage: () => apiClient.get('/usage/export', { timeout: USAGE_TIMEOUT_MS }), - - /** - * 导入使用统计快照 - */ - importUsage: (payload: unknown) => - apiClient.post('/usage/import', payload, { timeout: USAGE_TIMEOUT_MS }), - - /** - * 计算密钥成功/失败统计,必要时会先获取 usage 数据 - */ - async getKeyStats(usageData?: unknown): Promise { - let payload = usageData; - if (!payload) { - const response = await apiClient.get>('/usage', { timeout: USAGE_TIMEOUT_MS }); - payload = response?.usage ?? response; - } - return computeKeyStats(payload); - } -}; diff --git a/src/stores/index.ts b/src/stores/index.ts index 893ec83..6c287ca 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -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'; diff --git a/src/stores/useAuthStore.ts b/src/stores/useAuthStore.ts index 963565e..f97eecb 100644 --- a/src/stores/useAuthStore.ts +++ b/src/stores/useAuthStore.ts @@ -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()( logout: () => { restoreSessionPromise = null; useConfigStore.getState().clearCache(); - useUsageStatsStore.getState().clearUsageStats(); useModelsStore.getState().clearCache(); set({ isAuthenticated: false, diff --git a/src/stores/useConfigStore.ts b/src/stores/useConfigStore.ts index b97a36d..9a21739 100644 --- a/src/stores/useConfigStore.ts +++ b/src/stores/useConfigStore.ts @@ -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((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; diff --git a/src/stores/useUsageStatsStore.ts b/src/stores/useUsageStatsStore.ts deleted file mode 100644 index e431f58..0000000 --- a/src/stores/useUsageStatsStore.ts +++ /dev/null @@ -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; - -type UsageStatsState = { - usage: UsageStatsSnapshot | null; - keyStats: KeyStats; - usageDetails: UsageDetail[]; - loading: boolean; - error: string | null; - lastRefreshedAt: number | null; - scopeKey: string; - loadUsageStats: (options?: LoadUsageStatsOptions) => Promise; - clearUsageStats: () => void; -}; - -const createEmptyKeyStats = (): KeyStats => ({ bySource: {}, byAuthIndex: {} }); - -let usageRequestToken = 0; -let inFlightUsageRequest: { id: number; scopeKey: string; promise: Promise } | 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((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: '' - }); - } -})); diff --git a/src/types/config.ts b/src/types/config.ts index b9816d3..df38b8a 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -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' diff --git a/src/types/index.ts b/src/types/index.ts index 476e891..804d75e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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'; diff --git a/src/types/sourceInfo.ts b/src/types/sourceInfo.ts deleted file mode 100644 index bd7d725..0000000 --- a/src/types/sourceInfo.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type SourceInfo = { - displayName: string; - type: string; - identityKey?: string; -}; - -export type CredentialInfo = { - name: string; - type: string; -}; diff --git a/src/types/usage.ts b/src/types/usage.ts deleted file mode 100644 index 1487d42..0000000 --- a/src/types/usage.ts +++ /dev/null @@ -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; -} diff --git a/src/types/visualConfig.ts b/src/types/visualConfig.ts index 76cdf8a..40b335b 100644 --- a/src/types/visualConfig.ts +++ b/src/types/visualConfig.ts @@ -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: '', diff --git a/src/utils/authIndex.ts b/src/utils/authIndex.ts new file mode 100644 index 0000000..bdf42ea --- /dev/null +++ b/src/utils/authIndex.ts @@ -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; +}; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index f34ce84..dbb236a 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -76,6 +76,5 @@ export const API_ENDPOINTS = { PROVIDERS: '/providers', AUTH_FILES: '/auth-files', OAUTH: '/oauth', - USAGE: '/usage', LOGS: '/logs' } as const; diff --git a/src/utils/quota/parsers.ts b/src/utils/quota/parsers.ts index 6664cef..aef4799 100644 --- a/src/utils/quota/parsers.ts +++ b/src/utils/quota/parsers.ts @@ -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 }; diff --git a/src/utils/recentRequests.ts b/src/utils/recentRequests.ts index 1bd5a25..6399a3c 100644 --- a/src/utils/recentRequests.ts +++ b/src/utils/recentRequests.ts @@ -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; diff --git a/src/utils/sourceResolver.ts b/src/utils/sourceResolver.ts deleted file mode 100644 index 8b0fd76..0000000 --- a/src/utils/sourceResolver.ts +++ /dev/null @@ -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>; - -export interface SourceInfoMap { - byAuthIndex: Map; - bySource: Map; -} - -const buildProviderIdentityKey = (type: string, index: number) => `${type}:${index}`; - -const registerIdentity = ( - map: Map, - 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(); - const bySource = new Map(); - - const registerProvider = ( - entry: SourceInfoEntry, - authIndices: Array, - candidates: Iterable - ) => { - 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(); - const authIndices: Array = [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 -): 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:-', - }; -} diff --git a/src/utils/usage.ts b/src/utils/usage.ts deleted file mode 100644 index a8b553e..0000000 --- a/src/utils/usage.ts +++ /dev/null @@ -1,1945 +0,0 @@ -/** - * 使用统计相关工具 - * 迁移自基线 modules/usage.js 的纯逻辑部分 - */ - -import type { ScriptableContext } from 'chart.js'; -import type { LatencyAccumulator, LatencyStats } from './usage/latency'; -import { - addLatencySample, - calculateLatencyStatsFromDetails, - createLatencyAccumulator, - extractLatencyMs, - finalizeLatencyStats, -} from './usage/latency'; -import { maskApiKey } from './format'; -import { parseTimestampMs } from './timestamp'; - -export type { DurationFormatOptions, LatencyStats } from './usage/latency'; -export { - LATENCY_SOURCE_FIELD, - LATENCY_SOURCE_UNIT, - calculateLatencyStatsFromDetails, - extractLatencyMs, - formatDurationMs, -} from './usage/latency'; - -export interface KeyStatBucket { - success: number; - failure: number; -} - -export interface KeyStats { - bySource: Record; - byAuthIndex: Record; -} - -export interface TokenBreakdown { - cachedTokens: number; - reasoningTokens: number; -} - -export interface UsageThinking { - intensity?: string; - mode?: string; - level?: string; - budget?: number; -} - -export interface RateStats { - rpm: number; - tpm: number; - windowMinutes: number; - requestCount: number; - tokenCount: number; -} - -export interface ModelPrice { - prompt: number; - completion: number; - cache: number; -} - -export interface UsageDetail { - timestamp: string; - source: string; - auth_index: string | number | null; - latency_ms?: number; - tokens: { - input_tokens: number; - output_tokens: number; - reasoning_tokens: number; - cached_tokens: number; - cache_tokens?: number; - total_tokens: number; - }; - thinking?: UsageThinking | null; - failed: boolean; - __modelName?: string; - __timestampMs?: number; -} - -export interface UsageDetailWithEndpoint extends UsageDetail { - __endpoint: string; - __endpointMethod?: string; - __endpointPath?: string; - __timestampMs: number; -} - -export interface ApiStats { - endpoint: string; - totalRequests: number; - successCount: number; - failureCount: number; - totalTokens: number; - totalCost: number; - models: Record< - string, - { requests: number; successCount: number; failureCount: number; tokens: number } - >; -} - -export interface ModelStatsSummary { - model: string; - requests: number; - successCount: number; - failureCount: number; - tokens: number; - cost: number; - averageLatencyMs: number | null; - latencySampleCount: number; -} - -export type UsageTimeRange = '7h' | '24h' | '7d' | 'all'; - -const TOKENS_PER_PRICE_UNIT = 1_000_000; -const MODEL_PRICE_STORAGE_KEY = 'cli-proxy-model-prices-v2'; -const USAGE_ENDPOINT_METHOD_REGEX = /^(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s+(\S+)/i; -const USAGE_TIME_RANGE_MS: Record, number> = { - '7h': 7 * 60 * 60 * 1000, - '24h': 24 * 60 * 60 * 1000, - '7d': 7 * 24 * 60 * 60 * 1000, -}; - -const isRecord = (value: unknown): value is Record => - value !== null && typeof value === 'object' && !Array.isArray(value); - -const getApisRecord = (usageData: unknown): Record | null => { - const usageRecord = isRecord(usageData) ? usageData : null; - const apisRaw = usageRecord ? usageRecord.apis : null; - return isRecord(apisRaw) ? apisRaw : null; -}; - -const normalizeUsageThinking = (value: unknown): UsageThinking | null => { - if (!isRecord(value)) { - return null; - } - - const intensity = typeof value.intensity === 'string' ? value.intensity.trim() : ''; - const mode = typeof value.mode === 'string' ? value.mode.trim() : ''; - const level = typeof value.level === 'string' ? value.level.trim() : ''; - const budget = - typeof value.budget === 'number' && Number.isFinite(value.budget) ? value.budget : undefined; - - if (!intensity && !mode && !level && budget === undefined) { - return null; - } - - return { - ...(intensity ? { intensity } : {}), - ...(mode ? { mode } : {}), - ...(level ? { level } : {}), - ...(budget !== undefined ? { budget } : {}), - }; -}; - -interface UsageSummary { - totalRequests: number; - successCount: number; - failureCount: number; - totalTokens: number; -} - -const createUsageSummary = (): UsageSummary => ({ - totalRequests: 0, - successCount: 0, - failureCount: 0, - totalTokens: 0, -}); - -const toUsageSummaryFields = (summary: UsageSummary) => ({ - total_requests: summary.totalRequests, - success_count: summary.successCount, - failure_count: summary.failureCount, - total_tokens: summary.totalTokens, -}); - -export function filterUsageByTimeRange( - usageData: T, - range: UsageTimeRange, - nowMs: number = Date.now() -): T { - if (range === 'all') { - return usageData; - } - - const usageRecord = isRecord(usageData) ? usageData : null; - const apis = getApisRecord(usageData); - if (!usageRecord || !apis) { - return usageData; - } - - const rangeMs = USAGE_TIME_RANGE_MS[range]; - if (!Number.isFinite(rangeMs) || rangeMs <= 0) { - return usageData; - } - - const windowStart = nowMs - rangeMs; - const filteredApis: Record = {}; - const totalSummary = createUsageSummary(); - - Object.entries(apis).forEach(([apiName, apiEntry]) => { - if (!isRecord(apiEntry)) { - return; - } - - const models = isRecord(apiEntry.models) ? apiEntry.models : null; - if (!models) { - return; - } - - const filteredModels: Record = {}; - const apiSummary = createUsageSummary(); - let hasModelData = false; - - Object.entries(models).forEach(([modelName, modelEntry]) => { - if (!isRecord(modelEntry)) { - return; - } - - const detailsRaw = Array.isArray(modelEntry.details) ? modelEntry.details : []; - const modelSummary = createUsageSummary(); - const filteredDetails: unknown[] = []; - - detailsRaw.forEach((detail) => { - const detailRecord = isRecord(detail) ? detail : null; - if (!detailRecord || typeof detailRecord.timestamp !== 'string') { - return; - } - const timestamp = parseTimestampMs(detailRecord.timestamp); - if (Number.isNaN(timestamp) || timestamp < windowStart || timestamp > nowMs) { - return; - } - - filteredDetails.push(detail); - modelSummary.totalRequests += 1; - if (detailRecord.failed === true) { - modelSummary.failureCount += 1; - } else { - modelSummary.successCount += 1; - } - modelSummary.totalTokens += extractTotalTokens(detailRecord); - }); - - if (!filteredDetails.length) { - return; - } - - filteredModels[modelName] = { - ...modelEntry, - ...toUsageSummaryFields(modelSummary), - details: filteredDetails, - }; - hasModelData = true; - - apiSummary.totalRequests += modelSummary.totalRequests; - apiSummary.successCount += modelSummary.successCount; - apiSummary.failureCount += modelSummary.failureCount; - apiSummary.totalTokens += modelSummary.totalTokens; - }); - - if (!hasModelData) { - return; - } - - filteredApis[apiName] = { - ...apiEntry, - ...toUsageSummaryFields(apiSummary), - models: filteredModels, - }; - - totalSummary.totalRequests += apiSummary.totalRequests; - totalSummary.successCount += apiSummary.successCount; - totalSummary.failureCount += apiSummary.failureCount; - totalSummary.totalTokens += apiSummary.totalTokens; - }); - - return { - ...usageRecord, - ...toUsageSummaryFields(totalSummary), - apis: filteredApis, - } as T; -} - -export const normalizeAuthIndex = (value: unknown) => { - if (typeof value === 'number' && Number.isFinite(value)) { - return value.toString(); - } - if (typeof value === 'string') { - const trimmed = value.trim(); - return trimmed ? trimmed : null; - } - return null; -}; - -const USAGE_SOURCE_PREFIX_KEY = 'k:'; -const USAGE_SOURCE_PREFIX_MASKED = 'm:'; -const USAGE_SOURCE_PREFIX_TEXT = 't:'; - -const KEY_LIKE_TOKEN_REGEX = - /(sk-[A-Za-z0-9-_]{6,}|sk-ant-[A-Za-z0-9-_]{6,}|AIza[0-9A-Za-z-_]{8,}|AI[a-zA-Z0-9_-]{6,}|hf_[A-Za-z0-9]{6,}|pk_[A-Za-z0-9]{6,}|rk_[A-Za-z0-9]{6,})/; -const MASKED_TOKEN_HINT_REGEX = /^[^\s]{1,24}(\*{2,}|\.{3}|…)[^\s]{1,24}$/; - -const keyFingerprintCache = new Map(); - -const fnv1a64Hex = (value: string): string => { - const cached = keyFingerprintCache.get(value); - if (cached) return cached; - - const FNV_OFFSET_BASIS = 0xcbf29ce484222325n; - const FNV_PRIME = 0x100000001b3n; - - let hash = FNV_OFFSET_BASIS; - for (let i = 0; i < value.length; i++) { - hash ^= BigInt(value.charCodeAt(i)); - hash = (hash * FNV_PRIME) & 0xffffffffffffffffn; - } - - const hex = hash.toString(16).padStart(16, '0'); - keyFingerprintCache.set(value, hex); - return hex; -}; - -const looksLikeRawSecret = (text: string): boolean => { - if (!text || /\s/.test(text)) return false; - - const lower = text.toLowerCase(); - if (lower.endsWith('.json')) return false; - if (lower.startsWith('http://') || lower.startsWith('https://')) return false; - if (/[\\/]/.test(text)) return false; - - if (KEY_LIKE_TOKEN_REGEX.test(text)) return true; - - if (text.length >= 32 && text.length <= 512) { - return true; - } - - if (text.length >= 16 && text.length < 32 && /^[A-Za-z0-9._=-]+$/.test(text)) { - return /[A-Za-z]/.test(text) && /\d/.test(text); - } - - return false; -}; - -const extractRawSecretFromText = (text: string): string | null => { - if (!text) return null; - if (looksLikeRawSecret(text)) return text; - - const keyLikeMatch = text.match(KEY_LIKE_TOKEN_REGEX); - if (keyLikeMatch?.[0]) return keyLikeMatch[0]; - - const queryMatch = text.match( - /(?:[?&])(api[-_]?key|key|token|access_token|authorization)=([^&#\s]+)/i - ); - const queryValue = queryMatch?.[2]; - if (queryValue && looksLikeRawSecret(queryValue)) { - return queryValue; - } - - const headerMatch = text.match( - /(api[-_]?key|key|token|access[-_]?token|authorization)\s*[:=]\s*([A-Za-z0-9._=-]+)/i - ); - const headerValue = headerMatch?.[2]; - if (headerValue && looksLikeRawSecret(headerValue)) { - return headerValue; - } - - const bearerMatch = text.match(/\bBearer\s+([A-Za-z0-9._=-]{6,})/i); - const bearerValue = bearerMatch?.[1]; - if (bearerValue && looksLikeRawSecret(bearerValue)) { - return bearerValue; - } - - return null; -}; - -export function normalizeUsageSourceId( - value: unknown, - masker: (val: string) => string = maskApiKey -): string { - const raw = - typeof value === 'string' ? value : value === null || value === undefined ? '' : String(value); - const trimmed = raw.trim(); - if (!trimmed) return ''; - - const extracted = extractRawSecretFromText(trimmed); - if (extracted) { - return `${USAGE_SOURCE_PREFIX_KEY}${fnv1a64Hex(extracted)}`; - } - - if (MASKED_TOKEN_HINT_REGEX.test(trimmed)) { - return `${USAGE_SOURCE_PREFIX_MASKED}${masker(trimmed)}`; - } - - return `${USAGE_SOURCE_PREFIX_TEXT}${trimmed}`; -} - -export function buildCandidateUsageSourceIds(input: { - apiKey?: string; - prefix?: string; -}): string[] { - const result: string[] = []; - - const prefix = input.prefix?.trim(); - if (prefix) { - result.push(`${USAGE_SOURCE_PREFIX_TEXT}${prefix}`); - } - - const apiKey = input.apiKey?.trim(); - if (apiKey) { - // Include the normalised form first so that "non-standard" keys (e.g. short tokens, - // keys containing '/' etc.) that are classified as text by normalizeUsageSourceId() - // can still match usage details. - result.push(normalizeUsageSourceId(apiKey)); - result.push(`${USAGE_SOURCE_PREFIX_KEY}${fnv1a64Hex(apiKey)}`); - result.push(`${USAGE_SOURCE_PREFIX_MASKED}${maskApiKey(apiKey)}`); - } - - return Array.from(new Set(result)); -} - -/** - * 对使用数据中的敏感字段进行遮罩 - */ -export function maskUsageSensitiveValue( - value: unknown, - masker: (val: string) => string = maskApiKey -): string { - if (value === null || value === undefined) { - return ''; - } - const raw = typeof value === 'string' ? value : String(value); - if (!raw) { - return ''; - } - - let masked = raw; - - const queryRegex = /([?&])(api[-_]?key|key|token|access_token|authorization)=([^&#\s]+)/gi; - masked = masked.replace( - queryRegex, - (_full, prefix, keyName, valuePart) => `${prefix}${keyName}=${masker(valuePart)}` - ); - - const headerRegex = - /(api[-_]?key|key|token|access[-_]?token|authorization)\s*([:=])\s*([A-Za-z0-9._-]+)/gi; - masked = masked.replace( - headerRegex, - (_full, keyName, separator, valuePart) => `${keyName}${separator}${masker(valuePart)}` - ); - - const keyLikeRegex = - /(sk-[A-Za-z0-9]{6,}|AI[a-zA-Z0-9_-]{6,}|AIza[0-9A-Za-z-_]{8,}|hf_[A-Za-z0-9]{6,}|pk_[A-Za-z0-9]{6,}|rk_[A-Za-z0-9]{6,})/g; - masked = masked.replace(keyLikeRegex, (match) => masker(match)); - - if (masked === raw) { - const trimmed = raw.trim(); - if (trimmed && !/\s/.test(trimmed)) { - const looksLikeKey = - /^sk-/i.test(trimmed) || - /^AI/i.test(trimmed) || - /^AIza/i.test(trimmed) || - /^hf_/i.test(trimmed) || - /^pk_/i.test(trimmed) || - /^rk_/i.test(trimmed) || - (!/[\\/]/.test(trimmed) && (/\d/.test(trimmed) || trimmed.length >= 10)) || - trimmed.length >= 24; - if (looksLikeKey) { - return masker(trimmed); - } - } - } - - return masked; -} - -/** - * 格式化每分钟数值 - */ -export function formatPerMinuteValue(value: number): string { - const num = Number(value); - if (!Number.isFinite(num)) { - return '0.00'; - } - const abs = Math.abs(num); - if (abs >= 1000) { - return Math.round(num).toLocaleString(); - } - if (abs >= 100) { - return num.toFixed(0); - } - if (abs >= 10) { - return num.toFixed(1); - } - return num.toFixed(2); -} - -/** - * 格式化紧凑数字 - */ -export function formatCompactNumber(value: number): string { - const num = Number(value); - if (!Number.isFinite(num)) { - return '0'; - } - const abs = Math.abs(num); - if (abs >= 1_000_000) { - return `${(num / 1_000_000).toFixed(1)}M`; - } - if (abs >= 1_000) { - return `${(num / 1_000).toFixed(1)}K`; - } - return abs >= 1 ? num.toFixed(0) : num.toFixed(2); -} - -/** - * 格式化美元 - */ -export function formatUsd(value: number): string { - const num = Number(value); - if (!Number.isFinite(num)) { - return '$0.00'; - } - const fixed = num.toFixed(2); - const parts = Number(fixed).toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }); - return `$${parts}`; -} - -const usageDetailsCache = new WeakMap(); -const usageDetailsWithEndpointCache = new WeakMap(); - -/** - * 从使用数据中收集所有请求明细 - */ -export function collectUsageDetails(usageData: unknown): UsageDetail[] { - const cacheKey = isRecord(usageData) ? (usageData as object) : null; - if (cacheKey) { - const cached = usageDetailsCache.get(cacheKey); - if (cached) return cached; - } - - const apis = getApisRecord(usageData); - if (!apis) return []; - const details: UsageDetail[] = []; - const sourceCache = new Map(); - - const normalizeSource = (value: unknown): string => { - const raw = - typeof value === 'string' - ? value - : value === null || value === undefined - ? '' - : String(value); - const trimmed = raw.trim(); - if (!trimmed) return ''; - const cached = sourceCache.get(trimmed); - if (cached !== undefined) return cached; - const normalized = normalizeUsageSourceId(trimmed); - sourceCache.set(trimmed, normalized); - return normalized; - }; - - Object.values(apis).forEach((apiEntry) => { - if (!isRecord(apiEntry)) return; - const modelsRaw = apiEntry.models; - const models = isRecord(modelsRaw) ? modelsRaw : null; - if (!models) return; - - Object.entries(models).forEach(([modelName, modelEntry]) => { - if (!isRecord(modelEntry)) return; - const modelDetailsRaw = modelEntry.details; - const modelDetails = Array.isArray(modelDetailsRaw) ? modelDetailsRaw : []; - - modelDetails.forEach((detailRaw) => { - if (!isRecord(detailRaw) || typeof detailRaw.timestamp !== 'string') return; - const timestamp = detailRaw.timestamp; - const timestampMs = parseTimestampMs(timestamp); - const tokensRaw = isRecord(detailRaw.tokens) ? detailRaw.tokens : {}; - const latencyMs = extractLatencyMs(detailRaw); - details.push({ - timestamp, - source: normalizeSource(detailRaw.source), - auth_index: (detailRaw?.auth_index ?? - detailRaw?.authIndex ?? - detailRaw?.AuthIndex ?? - null) as UsageDetail['auth_index'], - latency_ms: latencyMs ?? undefined, - tokens: tokensRaw as unknown as UsageDetail['tokens'], - thinking: normalizeUsageThinking(detailRaw.thinking), - failed: detailRaw.failed === true, - __modelName: modelName, - __timestampMs: Number.isNaN(timestampMs) ? 0 : timestampMs, - }); - }); - }); - }); - - if (cacheKey) { - usageDetailsCache.set(cacheKey, details); - } - return details; -} - -/** - * 从使用数据中收集包含 endpoint/method/path 的请求明细 - */ -export function collectUsageDetailsWithEndpoint(usageData: unknown): UsageDetailWithEndpoint[] { - const cacheKey = isRecord(usageData) ? (usageData as object) : null; - if (cacheKey) { - const cached = usageDetailsWithEndpointCache.get(cacheKey); - if (cached) return cached; - } - - const apis = getApisRecord(usageData); - if (!apis) return []; - - const details: UsageDetailWithEndpoint[] = []; - const sourceCache = new Map(); - - const normalizeSource = (value: unknown): string => { - const raw = - typeof value === 'string' - ? value - : value === null || value === undefined - ? '' - : String(value); - const trimmed = raw.trim(); - if (!trimmed) return ''; - const cached = sourceCache.get(trimmed); - if (cached !== undefined) return cached; - const normalized = normalizeUsageSourceId(trimmed); - sourceCache.set(trimmed, normalized); - return normalized; - }; - - Object.entries(apis).forEach(([endpoint, apiEntry]) => { - if (!isRecord(apiEntry)) return; - const modelsRaw = apiEntry.models; - const models = isRecord(modelsRaw) ? modelsRaw : null; - if (!models) return; - - const endpointMatch = endpoint.match(USAGE_ENDPOINT_METHOD_REGEX); - const endpointMethod = endpointMatch?.[1]?.toUpperCase(); - const endpointPath = endpointMatch?.[2]; - - Object.entries(models).forEach(([modelName, modelEntry]) => { - if (!isRecord(modelEntry)) return; - const modelDetailsRaw = modelEntry.details; - const modelDetails = Array.isArray(modelDetailsRaw) ? modelDetailsRaw : []; - - modelDetails.forEach((detailRaw) => { - if (!isRecord(detailRaw) || typeof detailRaw.timestamp !== 'string') return; - const timestamp = detailRaw.timestamp; - const timestampMs = parseTimestampMs(timestamp); - const tokensRaw = isRecord(detailRaw.tokens) ? detailRaw.tokens : {}; - const latencyMs = extractLatencyMs(detailRaw); - details.push({ - timestamp, - source: normalizeSource(detailRaw.source), - auth_index: (detailRaw?.auth_index ?? - detailRaw?.authIndex ?? - detailRaw?.AuthIndex ?? - null) as UsageDetail['auth_index'], - latency_ms: latencyMs ?? undefined, - tokens: tokensRaw as unknown as UsageDetail['tokens'], - thinking: normalizeUsageThinking(detailRaw.thinking), - failed: detailRaw.failed === true, - __modelName: modelName, - __endpoint: endpoint, - __endpointMethod: endpointMethod, - __endpointPath: endpointPath, - __timestampMs: Number.isNaN(timestampMs) ? 0 : timestampMs, - }); - }); - }); - }); - - if (cacheKey) { - usageDetailsWithEndpointCache.set(cacheKey, details); - } - return details; -} - -/** - * 从单条明细提取总 tokens - */ -export function extractTotalTokens(detail: unknown): number { - const record = isRecord(detail) ? detail : null; - const tokensRaw = record?.tokens; - const tokens = isRecord(tokensRaw) ? tokensRaw : {}; - if (typeof tokens.total_tokens === 'number') { - return tokens.total_tokens; - } - const inputTokens = typeof tokens.input_tokens === 'number' ? tokens.input_tokens : 0; - const outputTokens = typeof tokens.output_tokens === 'number' ? tokens.output_tokens : 0; - const reasoningTokens = typeof tokens.reasoning_tokens === 'number' ? tokens.reasoning_tokens : 0; - const 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 - ); - - return inputTokens + outputTokens + reasoningTokens + cachedTokens; -} - -/** - * 计算耗时统计 - */ -export function calculateLatencyStats(usageData: unknown): LatencyStats { - return calculateLatencyStatsFromDetails(collectUsageDetails(usageData)); -} - -/** - * 计算 token 分类统计 - */ -export function calculateTokenBreakdown(usageData: unknown): TokenBreakdown { - const details = collectUsageDetails(usageData); - if (!details.length) { - return { cachedTokens: 0, reasoningTokens: 0 }; - } - - let cachedTokens = 0; - let reasoningTokens = 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; - } - }); - - return { cachedTokens, reasoningTokens }; -} - -/** - * 计算最近 N 分钟的 RPM/TPM - */ -export function calculateRecentPerMinuteRates( - windowMinutes: number = 30, - usageData: unknown -): RateStats { - const details = collectUsageDetails(usageData); - const effectiveWindow = Number.isFinite(windowMinutes) && windowMinutes > 0 ? windowMinutes : 30; - - if (!details.length) { - return { rpm: 0, tpm: 0, windowMinutes: effectiveWindow, requestCount: 0, tokenCount: 0 }; - } - - const now = Date.now(); - const windowStart = now - effectiveWindow * 60 * 1000; - let requestCount = 0; - let tokenCount = 0; - - details.forEach((detail) => { - const timestamp = - typeof detail.__timestampMs === 'number' - ? detail.__timestampMs - : parseTimestampMs(detail.timestamp); - if (!Number.isFinite(timestamp) || timestamp < windowStart || timestamp > now) { - return; - } - requestCount += 1; - tokenCount += extractTotalTokens(detail); - }); - - const denominator = effectiveWindow > 0 ? effectiveWindow : 1; - return { - rpm: requestCount / denominator, - tpm: tokenCount / denominator, - windowMinutes: effectiveWindow, - requestCount, - tokenCount, - }; -} - -/** - * 从使用数据获取模型名称列表 - */ -export function getModelNamesFromUsage(usageData: unknown): string[] { - const apis = getApisRecord(usageData); - if (!apis) return []; - const names = new Set(); - Object.values(apis).forEach((apiEntry) => { - if (!isRecord(apiEntry)) return; - const modelsRaw = apiEntry.models; - const models = isRecord(modelsRaw) ? modelsRaw : null; - if (!models) return; - Object.keys(models).forEach((modelName) => { - if (modelName) { - names.add(modelName); - } - }); - }); - return Array.from(names).sort((a, b) => a.localeCompare(b)); -} - -/** - * 计算成本数据 - */ -export function calculateCost( - detail: UsageDetail, - modelPrices: Record -): number { - const modelName = detail.__modelName || ''; - const price = modelPrices[modelName]; - if (!price) { - return 0; - } - const tokens = detail.tokens; - const rawInputTokens = Number(tokens.input_tokens); - const rawCompletionTokens = Number(tokens.output_tokens); - const rawCachedTokensPrimary = Number(tokens.cached_tokens); - const rawCachedTokensAlternate = Number(tokens.cache_tokens); - - const inputTokens = Number.isFinite(rawInputTokens) ? Math.max(rawInputTokens, 0) : 0; - const completionTokens = Number.isFinite(rawCompletionTokens) - ? Math.max(rawCompletionTokens, 0) - : 0; - const cachedTokens = Math.max( - Number.isFinite(rawCachedTokensPrimary) ? Math.max(rawCachedTokensPrimary, 0) : 0, - Number.isFinite(rawCachedTokensAlternate) ? Math.max(rawCachedTokensAlternate, 0) : 0 - ); - const promptTokens = Math.max(inputTokens - cachedTokens, 0); - - const promptCost = (promptTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.prompt) || 0); - const cachedCost = (cachedTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.cache) || 0); - const completionCost = - (completionTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.completion) || 0); - const total = promptCost + cachedCost + completionCost; - return Number.isFinite(total) && total > 0 ? total : 0; -} - -/** - * 计算总成本 - */ -export function calculateTotalCost( - usageData: unknown, - modelPrices: Record -): number { - const details = collectUsageDetails(usageData); - if (!details.length || !Object.keys(modelPrices).length) { - return 0; - } - return details.reduce((sum, detail) => sum + calculateCost(detail, modelPrices), 0); -} - -/** - * 从 localStorage 加载模型价格 - */ -export function loadModelPrices(): Record { - try { - if (typeof localStorage === 'undefined') { - return {}; - } - const raw = localStorage.getItem(MODEL_PRICE_STORAGE_KEY); - if (!raw) { - return {}; - } - const parsed: unknown = JSON.parse(raw); - if (!isRecord(parsed)) { - return {}; - } - const normalized: Record = {}; - Object.entries(parsed).forEach(([model, price]: [string, unknown]) => { - if (!model) return; - const priceRecord = isRecord(price) ? price : null; - const promptRaw = Number(priceRecord?.prompt); - const completionRaw = Number(priceRecord?.completion); - const cacheRaw = Number(priceRecord?.cache); - - if ( - !Number.isFinite(promptRaw) && - !Number.isFinite(completionRaw) && - !Number.isFinite(cacheRaw) - ) { - return; - } - - const prompt = Number.isFinite(promptRaw) && promptRaw >= 0 ? promptRaw : 0; - const completion = Number.isFinite(completionRaw) && completionRaw >= 0 ? completionRaw : 0; - const cache = - Number.isFinite(cacheRaw) && cacheRaw >= 0 - ? cacheRaw - : Number.isFinite(promptRaw) && promptRaw >= 0 - ? promptRaw - : prompt; - - normalized[model] = { - prompt, - completion, - cache, - }; - }); - return normalized; - } catch { - return {}; - } -} - -/** - * 保存模型价格到 localStorage - */ -export function saveModelPrices(prices: Record): void { - try { - if (typeof localStorage === 'undefined') { - return; - } - localStorage.setItem(MODEL_PRICE_STORAGE_KEY, JSON.stringify(prices)); - } catch { - console.warn('保存模型价格失败'); - } -} - -/** - * 获取 API 统计数据 - */ -export function getApiStats( - usageData: unknown, - modelPrices: Record -): ApiStats[] { - const apis = getApisRecord(usageData); - if (!apis) return []; - const result: ApiStats[] = []; - - Object.entries(apis).forEach(([endpoint, apiData]) => { - if (!isRecord(apiData)) return; - const models: Record< - string, - { requests: number; successCount: number; failureCount: number; tokens: number } - > = {}; - let derivedSuccessCount = 0; - let derivedFailureCount = 0; - let totalCost = 0; - - const modelsData = isRecord(apiData.models) ? apiData.models : {}; - Object.entries(modelsData).forEach(([modelName, modelData]) => { - if (!isRecord(modelData)) return; - const details = Array.isArray(modelData.details) ? modelData.details : []; - const hasExplicitCounts = - typeof modelData.success_count === 'number' || typeof modelData.failure_count === 'number'; - - let successCount = 0; - let failureCount = 0; - if (hasExplicitCounts) { - successCount += Number(modelData.success_count) || 0; - failureCount += Number(modelData.failure_count) || 0; - } - - const price = modelPrices[modelName]; - if (details.length > 0 && (!hasExplicitCounts || price)) { - details.forEach((detail) => { - const detailRecord = isRecord(detail) ? detail : null; - if (!hasExplicitCounts) { - if (detailRecord?.failed === true) { - failureCount += 1; - } else { - successCount += 1; - } - } - - if (price && detailRecord) { - totalCost += calculateCost( - { ...(detailRecord as unknown as UsageDetail), __modelName: modelName }, - modelPrices - ); - } - }); - } - - models[modelName] = { - requests: Number(modelData.total_requests) || 0, - successCount, - failureCount, - tokens: Number(modelData.total_tokens) || 0, - }; - derivedSuccessCount += successCount; - derivedFailureCount += failureCount; - }); - - const hasApiExplicitCounts = - typeof apiData.success_count === 'number' || typeof apiData.failure_count === 'number'; - const successCount = hasApiExplicitCounts - ? Number(apiData.success_count) || 0 - : derivedSuccessCount; - const failureCount = hasApiExplicitCounts - ? Number(apiData.failure_count) || 0 - : derivedFailureCount; - - result.push({ - endpoint: maskUsageSensitiveValue(endpoint) || endpoint, - totalRequests: Number(apiData.total_requests) || 0, - successCount, - failureCount, - totalTokens: Number(apiData.total_tokens) || 0, - totalCost, - models, - }); - }); - - return result; -} - -/** - * 获取模型统计数据 - */ -export function getModelStats( - usageData: unknown, - modelPrices: Record -): ModelStatsSummary[] { - const apis = getApisRecord(usageData); - if (!apis) return []; - - const modelMap = new Map< - string, - { - requests: number; - successCount: number; - failureCount: number; - tokens: number; - cost: number; - latency: LatencyAccumulator; - } - >(); - - Object.values(apis).forEach((apiData) => { - if (!isRecord(apiData)) return; - const modelsRaw = apiData.models; - const models = isRecord(modelsRaw) ? modelsRaw : null; - if (!models) return; - - Object.entries(models).forEach(([modelName, modelData]) => { - if (!isRecord(modelData)) return; - const existing = modelMap.get(modelName) || { - requests: 0, - successCount: 0, - failureCount: 0, - tokens: 0, - cost: 0, - latency: createLatencyAccumulator(), - }; - existing.requests += Number(modelData.total_requests) || 0; - existing.tokens += Number(modelData.total_tokens) || 0; - - const details = Array.isArray(modelData.details) ? modelData.details : []; - - const price = modelPrices[modelName]; - - const hasExplicitCounts = - typeof modelData.success_count === 'number' || typeof modelData.failure_count === 'number'; - if (hasExplicitCounts) { - existing.successCount += Number(modelData.success_count) || 0; - existing.failureCount += Number(modelData.failure_count) || 0; - } - - if (details.length > 0) { - details.forEach((detail) => { - const detailRecord = isRecord(detail) ? detail : null; - const latencyMs = extractLatencyMs(detailRecord); - if (!hasExplicitCounts) { - if (detailRecord?.failed === true) { - existing.failureCount += 1; - } else { - existing.successCount += 1; - } - } - - addLatencySample(existing.latency, latencyMs); - - if (price && detailRecord) { - existing.cost += calculateCost( - { ...(detailRecord as unknown as UsageDetail), __modelName: modelName }, - modelPrices - ); - } - }); - } - modelMap.set(modelName, existing); - }); - }); - - return Array.from(modelMap.entries()) - .map(([model, stats]) => { - const latencyStats = finalizeLatencyStats(stats.latency); - return { - model, - requests: stats.requests, - successCount: stats.successCount, - failureCount: stats.failureCount, - tokens: stats.tokens, - cost: stats.cost, - averageLatencyMs: latencyStats.averageMs, - latencySampleCount: latencyStats.sampleCount, - }; - }) - .sort((a, b) => b.requests - a.requests); -} - -/** - * 格式化小时标签 - */ -export function formatHourLabel(date: Date): string { - if (!(date instanceof Date)) { - return ''; - } - const month = (date.getMonth() + 1).toString().padStart(2, '0'); - const day = date.getDate().toString().padStart(2, '0'); - const hour = date.getHours().toString().padStart(2, '0'); - return `${month}-${day} ${hour}:00`; -} - -/** - * 格式化日期标签 - */ -export function formatDayLabel(date: Date): string { - if (!(date instanceof Date)) { - return ''; - } - const year = date.getFullYear(); - const month = (date.getMonth() + 1).toString().padStart(2, '0'); - const day = date.getDate().toString().padStart(2, '0'); - return `${year}-${month}-${day}`; -} - -/** - * 构建小时级别的数据序列 - */ -export function buildHourlySeriesByModel( - usageData: unknown, - metric: 'requests' | 'tokens' = 'requests', - hourWindow: number = 24 -): { - labels: string[]; - dataByModel: Map; - hasData: boolean; -} { - const hourMs = 60 * 60 * 1000; - const resolvedHourWindow = - Number.isFinite(hourWindow) && hourWindow > 0 - ? Math.min(Math.max(Math.floor(hourWindow), 1), 24 * 31) - : 24; - const now = new Date(); - const currentHour = new Date(now); - currentHour.setMinutes(0, 0, 0); - - const earliestBucket = new Date(currentHour); - earliestBucket.setHours(earliestBucket.getHours() - (resolvedHourWindow - 1)); - const earliestTime = earliestBucket.getTime(); - - const labels: string[] = []; - for (let i = 0; i < resolvedHourWindow; i++) { - const bucketStart = earliestTime + i * hourMs; - labels.push(formatHourLabel(new Date(bucketStart))); - } - - const details = collectUsageDetails(usageData); - const dataByModel = new Map(); - let hasData = false; - - if (!details.length) { - return { labels, dataByModel, hasData }; - } - - details.forEach((detail) => { - const timestamp = - typeof detail.__timestampMs === 'number' - ? detail.__timestampMs - : parseTimestampMs(detail.timestamp); - if (!Number.isFinite(timestamp) || timestamp <= 0) { - return; - } - - const normalized = new Date(timestamp); - normalized.setMinutes(0, 0, 0); - const bucketStart = normalized.getTime(); - const lastBucketTime = earliestTime + (labels.length - 1) * hourMs; - if (bucketStart < earliestTime || bucketStart > lastBucketTime) { - return; - } - - const bucketIndex = Math.floor((bucketStart - earliestTime) / hourMs); - if (bucketIndex < 0 || bucketIndex >= labels.length) { - return; - } - - const modelName = detail.__modelName || 'Unknown'; - if (!dataByModel.has(modelName)) { - dataByModel.set(modelName, new Array(labels.length).fill(0)); - } - - const bucketValues = dataByModel.get(modelName)!; - if (metric === 'tokens') { - bucketValues[bucketIndex] += extractTotalTokens(detail); - } else { - bucketValues[bucketIndex] += 1; - } - hasData = true; - }); - - return { labels, dataByModel, hasData }; -} - -/** - * 构建日级别的数据序列 - */ -export function buildDailySeriesByModel( - usageData: unknown, - metric: 'requests' | 'tokens' = 'requests' -): { - labels: string[]; - dataByModel: Map; - hasData: boolean; -} { - const details = collectUsageDetails(usageData); - const valuesByModel = new Map>(); - const labelsSet = new Set(); - let hasData = false; - - if (!details.length) { - return { labels: [], dataByModel: new Map(), hasData }; - } - - details.forEach((detail) => { - const timestamp = - typeof detail.__timestampMs === 'number' - ? detail.__timestampMs - : parseTimestampMs(detail.timestamp); - if (!Number.isFinite(timestamp) || timestamp <= 0) { - return; - } - const dayLabel = formatDayLabel(new Date(timestamp)); - if (!dayLabel) { - return; - } - - const modelName = detail.__modelName || 'Unknown'; - if (!valuesByModel.has(modelName)) { - valuesByModel.set(modelName, new Map()); - } - const modelDayMap = valuesByModel.get(modelName)!; - const increment = metric === 'tokens' ? extractTotalTokens(detail) : 1; - modelDayMap.set(dayLabel, (modelDayMap.get(dayLabel) || 0) + increment); - labelsSet.add(dayLabel); - hasData = true; - }); - - const labels = Array.from(labelsSet).sort(); - const dataByModel = new Map(); - valuesByModel.forEach((dayMap, modelName) => { - const series = labels.map((label) => dayMap.get(label) || 0); - dataByModel.set(modelName, series); - }); - - return { labels, dataByModel, hasData }; -} - -export interface ChartDataset { - label: string; - data: number[]; - borderColor: string; - backgroundColor: - | string - | CanvasGradient - | ((context: ScriptableContext<'line'>) => string | CanvasGradient); - pointBackgroundColor?: string; - pointBorderColor?: string; - fill: boolean; - tension: number; -} - -export interface ChartData { - labels: string[]; - datasets: ChartDataset[]; -} - -const CHART_COLORS = [ - { borderColor: '#8b8680', backgroundColor: 'rgba(139, 134, 128, 0.15)' }, - { borderColor: '#22c55e', backgroundColor: 'rgba(34, 197, 94, 0.15)' }, - { borderColor: '#f59e0b', backgroundColor: 'rgba(245, 158, 11, 0.15)' }, - { borderColor: '#c65746', backgroundColor: 'rgba(198, 87, 70, 0.15)' }, - { borderColor: '#8b5cf6', backgroundColor: 'rgba(139, 92, 246, 0.15)' }, - { borderColor: '#06b6d4', backgroundColor: 'rgba(6, 182, 212, 0.15)' }, - { borderColor: '#ec4899', backgroundColor: 'rgba(236, 72, 153, 0.15)' }, - { borderColor: '#84cc16', backgroundColor: 'rgba(132, 204, 22, 0.15)' }, - { borderColor: '#f97316', backgroundColor: 'rgba(249, 115, 22, 0.15)' }, -]; - -const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); - -const hexToRgb = (hex: string): { r: number; g: number; b: number } | null => { - const normalized = hex.trim().replace('#', ''); - if (normalized.length !== 6) { - return null; - } - const r = Number.parseInt(normalized.slice(0, 2), 16); - const g = Number.parseInt(normalized.slice(2, 4), 16); - const b = Number.parseInt(normalized.slice(4, 6), 16); - if (![r, g, b].every((channel) => Number.isFinite(channel))) { - return null; - } - return { r, g, b }; -}; - -const withAlpha = (hex: string, alpha: number) => { - const rgb = hexToRgb(hex); - if (!rgb) { - return hex; - } - const clamped = clamp(alpha, 0, 1); - return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${clamped})`; -}; - -const buildAreaGradient = ( - context: ScriptableContext<'line'>, - baseHex: string, - fallback: string -) => { - const chart = context.chart; - const ctx = chart.ctx; - const area = chart.chartArea; - - if (!area) { - return fallback; - } - - const gradient = ctx.createLinearGradient(0, area.top, 0, area.bottom); - gradient.addColorStop(0, withAlpha(baseHex, 0.28)); - gradient.addColorStop(0.6, withAlpha(baseHex, 0.12)); - gradient.addColorStop(1, withAlpha(baseHex, 0.02)); - return gradient; -}; - -/** - * 构建图表数据 - */ -export function buildChartData( - usageData: unknown, - period: 'hour' | 'day' = 'day', - metric: 'requests' | 'tokens' = 'requests', - selectedModels: string[] = [], - options: { hourWindowHours?: number } = {} -): ChartData { - const baseSeries = - period === 'hour' - ? buildHourlySeriesByModel(usageData, metric, options.hourWindowHours) - : buildDailySeriesByModel(usageData, metric); - - const { labels, dataByModel } = baseSeries; - - // Build "All" series as sum of all models - const getAllSeries = (): number[] => { - const summed = new Array(labels.length).fill(0); - dataByModel.forEach((values) => { - values.forEach((value, idx) => { - summed[idx] = (summed[idx] || 0) + value; - }); - }); - return summed; - }; - - // Determine which models to show - const modelsToShow = selectedModels.length > 0 ? selectedModels : ['all']; - - const datasets: ChartDataset[] = modelsToShow.map((model, index) => { - const isAll = model === 'all'; - const data = isAll - ? getAllSeries() - : dataByModel.get(model) || new Array(labels.length).fill(0); - const colorIndex = index % CHART_COLORS.length; - const style = CHART_COLORS[colorIndex]; - const shouldFill = modelsToShow.length === 1 || (isAll && modelsToShow.length > 1); - - return { - label: isAll ? 'All Models' : model, - data, - borderColor: style.borderColor, - backgroundColor: shouldFill - ? (ctx) => buildAreaGradient(ctx, style.borderColor, style.backgroundColor) - : style.backgroundColor, - pointBackgroundColor: style.borderColor, - pointBorderColor: style.borderColor, - fill: shouldFill, - tension: 0.35, - }; - }); - - return { labels, datasets }; -} - -/** - * 依据 usage 数据计算密钥使用统计 - */ -/** - * 状态栏单个格子的状态 - */ -export type StatusBlockState = 'success' | 'failure' | 'mixed' | 'idle'; - -/** - * 状态栏单个格子的详细信息 - */ -export interface StatusBlockDetail { - success: number; - failure: number; - /** 该格子的成功率 (0–1),无请求时为 -1 */ - rate: number; - /** 格子起始时间戳 (ms) */ - startTime: number; - /** 格子结束时间戳 (ms) */ - endTime: number; -} - -/** - * 状态栏数据 - */ -export interface StatusBarData { - blocks: StatusBlockState[]; - blockDetails: StatusBlockDetail[]; - successRate: number; - totalSuccess: number; - totalFailure: number; -} - -/** - * 计算状态栏数据(最近200分钟,分为20个10分钟的时间块) - * 每个时间块代表窗口内的一个等长区间,用于展示成功/失败趋势 - */ -export function calculateStatusBarData( - usageDetails: UsageDetail[], - sourceFilter?: string, - authIndexFilter?: string | number -): StatusBarData { - const BLOCK_COUNT = 20; - const BLOCK_DURATION_MS = 10 * 60 * 1000; // 10 minutes - const WINDOW_MS = BLOCK_COUNT * BLOCK_DURATION_MS; // 200 minutes - - const now = Date.now(); - const windowStart = now - WINDOW_MS; - - // Initialize blocks - const blockStats: Array<{ success: number; failure: number }> = Array.from( - { length: BLOCK_COUNT }, - () => ({ success: 0, failure: 0 }) - ); - - let totalSuccess = 0; - let totalFailure = 0; - - // Filter and bucket the usage details - usageDetails.forEach((detail) => { - const timestamp = - typeof detail.__timestampMs === 'number' - ? detail.__timestampMs - : parseTimestampMs(detail.timestamp); - if ( - !Number.isFinite(timestamp) || - timestamp <= 0 || - timestamp < windowStart || - timestamp > now - ) { - return; - } - - // Apply filters if provided - if (sourceFilter !== undefined && detail.source !== sourceFilter) { - return; - } - if (authIndexFilter !== undefined && detail.auth_index !== authIndexFilter) { - return; - } - - // Calculate which block this falls into (0 = oldest, 19 = newest) - const ageMs = now - timestamp; - const blockIndex = BLOCK_COUNT - 1 - Math.floor(ageMs / BLOCK_DURATION_MS); - - if (blockIndex >= 0 && blockIndex < BLOCK_COUNT) { - if (detail.failed) { - blockStats[blockIndex].failure += 1; - totalFailure += 1; - } else { - blockStats[blockIndex].success += 1; - totalSuccess += 1; - } - } - }); - - // Convert stats to block states and build details - const blocks: StatusBlockState[] = []; - const blockDetails: StatusBlockDetail[] = []; - - blockStats.forEach((stat, idx) => { - const total = stat.success + stat.failure; - if (total === 0) { - blocks.push('idle'); - } else if (stat.failure === 0) { - blocks.push('success'); - } else if (stat.success === 0) { - blocks.push('failure'); - } else { - blocks.push('mixed'); - } - - const blockStartTime = windowStart + idx * BLOCK_DURATION_MS; - blockDetails.push({ - success: stat.success, - failure: stat.failure, - rate: total > 0 ? stat.success / total : -1, - startTime: blockStartTime, - endTime: blockStartTime + BLOCK_DURATION_MS, - }); - }); - - // Calculate success rate - const total = totalSuccess + totalFailure; - const successRate = total > 0 ? (totalSuccess / total) * 100 : 100; - - return { - blocks, - blockDetails, - successRate, - totalSuccess, - totalFailure, - }; -} - -/** - * 服务健康监测数据(最近168小时/7天,7×96网格) - * 每个格子代表15分钟的健康度 - */ -export interface ServiceHealthData { - blocks: StatusBlockState[]; - blockDetails: StatusBlockDetail[]; - successRate: number; - totalSuccess: number; - totalFailure: number; - rows: number; - cols: number; -} - -export function calculateServiceHealthData(usageDetails: UsageDetail[]): ServiceHealthData { - const ROWS = 7; - const COLS = 96; - const BLOCK_COUNT = ROWS * COLS; // 672 - const BLOCK_DURATION_MS = 15 * 60 * 1000; // 15 minutes - const WINDOW_MS = BLOCK_COUNT * BLOCK_DURATION_MS; // 168 hours (7 days) - - const now = Date.now(); - const windowStart = now - WINDOW_MS; - - const blockStats: Array<{ success: number; failure: number }> = Array.from( - { length: BLOCK_COUNT }, - () => ({ success: 0, failure: 0 }) - ); - - let totalSuccess = 0; - let totalFailure = 0; - - usageDetails.forEach((detail) => { - const timestamp = - typeof detail.__timestampMs === 'number' - ? detail.__timestampMs - : parseTimestampMs(detail.timestamp); - if ( - !Number.isFinite(timestamp) || - timestamp <= 0 || - timestamp < windowStart || - timestamp > now - ) { - return; - } - - const ageMs = now - timestamp; - const blockIndex = BLOCK_COUNT - 1 - Math.floor(ageMs / BLOCK_DURATION_MS); - - if (blockIndex >= 0 && blockIndex < BLOCK_COUNT) { - if (detail.failed) { - blockStats[blockIndex].failure += 1; - totalFailure += 1; - } else { - blockStats[blockIndex].success += 1; - totalSuccess += 1; - } - } - }); - - const blocks: StatusBlockState[] = []; - const blockDetails: StatusBlockDetail[] = []; - - blockStats.forEach((stat, idx) => { - const total = stat.success + stat.failure; - if (total === 0) { - blocks.push('idle'); - } else if (stat.failure === 0) { - blocks.push('success'); - } else if (stat.success === 0) { - blocks.push('failure'); - } else { - blocks.push('mixed'); - } - - const blockStartTime = windowStart + idx * BLOCK_DURATION_MS; - blockDetails.push({ - success: stat.success, - failure: stat.failure, - rate: total > 0 ? stat.success / total : -1, - startTime: blockStartTime, - endTime: blockStartTime + BLOCK_DURATION_MS, - }); - }); - - const total = totalSuccess + totalFailure; - const successRate = total > 0 ? (totalSuccess / total) * 100 : 100; - - return { - blocks, - blockDetails, - successRate, - totalSuccess, - totalFailure, - rows: ROWS, - cols: COLS, - }; -} - -export function computeKeyStats( - usageData: unknown, - masker: (val: string) => string = maskApiKey -): KeyStats { - const apis = getApisRecord(usageData); - if (!apis) { - return { bySource: {}, byAuthIndex: {} }; - } - - const sourceStats: Record = {}; - const authIndexStats: Record = {}; - - const ensureBucket = (bucket: Record, key: string) => { - if (!bucket[key]) { - bucket[key] = { success: 0, failure: 0 }; - } - return bucket[key]; - }; - - Object.values(apis).forEach((apiEntry) => { - if (!isRecord(apiEntry)) return; - const modelsRaw = apiEntry.models; - const models = isRecord(modelsRaw) ? modelsRaw : null; - if (!models) return; - - Object.values(models).forEach((modelEntry) => { - if (!isRecord(modelEntry)) return; - const details = Array.isArray(modelEntry.details) ? modelEntry.details : []; - - details.forEach((detail) => { - const detailRecord = isRecord(detail) ? detail : null; - const source = normalizeUsageSourceId(detailRecord?.source, masker); - const authIndexKey = normalizeAuthIndex(detailRecord?.auth_index); - const isFailed = detailRecord?.failed === true; - - if (source) { - const bucket = ensureBucket(sourceStats, source); - if (isFailed) { - bucket.failure += 1; - } else { - bucket.success += 1; - } - } - - if (authIndexKey) { - const bucket = ensureBucket(authIndexStats, authIndexKey); - if (isFailed) { - bucket.failure += 1; - } else { - bucket.success += 1; - } - } - }); - }); - }); - - return { - bySource: sourceStats, - byAuthIndex: authIndexStats, - }; -} - -export function computeKeyStatsFromDetails(usageDetails: UsageDetail[]): KeyStats { - const bySource: Record = {}; - const byAuthIndex: Record = {}; - - const ensureBucket = (bucket: Record, key: string) => { - if (!bucket[key]) { - bucket[key] = { success: 0, failure: 0 }; - } - return bucket[key]; - }; - - usageDetails.forEach((detail) => { - const source = detail.source; - const authIndexKey = normalizeAuthIndex(detail.auth_index); - const isFailed = detail.failed === true; - - if (source) { - const bucket = ensureBucket(bySource, source); - if (isFailed) { - bucket.failure += 1; - } else { - bucket.success += 1; - } - } - - if (authIndexKey) { - const bucket = ensureBucket(byAuthIndex, authIndexKey); - if (isFailed) { - bucket.failure += 1; - } else { - bucket.success += 1; - } - } - }); - - return { bySource, byAuthIndex }; -} - -export type TokenCategory = 'input' | 'output' | 'cached' | 'reasoning'; - -export interface TokenBreakdownSeries { - labels: string[]; - dataByCategory: Record; - hasData: boolean; -} - -/** - * 按 token 类别构建小时级别的堆叠序列 - */ -export function buildHourlyTokenBreakdown( - usageData: unknown, - hourWindow: number = 24 -): TokenBreakdownSeries { - const hourMs = 60 * 60 * 1000; - const resolvedHourWindow = - Number.isFinite(hourWindow) && hourWindow > 0 - ? Math.min(Math.max(Math.floor(hourWindow), 1), 24 * 31) - : 24; - const now = new Date(); - const currentHour = new Date(now); - currentHour.setMinutes(0, 0, 0); - - const earliestBucket = new Date(currentHour); - earliestBucket.setHours(earliestBucket.getHours() - (resolvedHourWindow - 1)); - const earliestTime = earliestBucket.getTime(); - - const labels: string[] = []; - for (let i = 0; i < resolvedHourWindow; i++) { - labels.push(formatHourLabel(new Date(earliestTime + i * hourMs))); - } - - const dataByCategory: Record = { - input: new Array(labels.length).fill(0), - output: new Array(labels.length).fill(0), - cached: new Array(labels.length).fill(0), - reasoning: new Array(labels.length).fill(0), - }; - - const details = collectUsageDetails(usageData); - let hasData = false; - - details.forEach((detail) => { - const timestamp = - typeof detail.__timestampMs === 'number' - ? detail.__timestampMs - : parseTimestampMs(detail.timestamp); - if (!Number.isFinite(timestamp) || timestamp <= 0) return; - const normalized = new Date(timestamp); - normalized.setMinutes(0, 0, 0); - const bucketStart = normalized.getTime(); - const lastBucketTime = earliestTime + (labels.length - 1) * hourMs; - if (bucketStart < earliestTime || bucketStart > lastBucketTime) return; - const bucketIndex = Math.floor((bucketStart - earliestTime) / hourMs); - if (bucketIndex < 0 || bucketIndex >= labels.length) return; - - const tokens = detail.tokens; - const input = typeof tokens.input_tokens === 'number' ? Math.max(tokens.input_tokens, 0) : 0; - const output = typeof tokens.output_tokens === 'number' ? Math.max(tokens.output_tokens, 0) : 0; - const cached = 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 - ); - const reasoning = - typeof tokens.reasoning_tokens === 'number' ? Math.max(tokens.reasoning_tokens, 0) : 0; - - dataByCategory.input[bucketIndex] += input; - dataByCategory.output[bucketIndex] += output; - dataByCategory.cached[bucketIndex] += cached; - dataByCategory.reasoning[bucketIndex] += reasoning; - hasData = true; - }); - - return { labels, dataByCategory, hasData }; -} - -/** - * 按 token 类别构建日级别的堆叠序列 - */ -export function buildDailyTokenBreakdown(usageData: unknown): TokenBreakdownSeries { - const details = collectUsageDetails(usageData); - const dayMap: Record> = {}; - let hasData = false; - - details.forEach((detail) => { - const timestamp = - typeof detail.__timestampMs === 'number' - ? detail.__timestampMs - : parseTimestampMs(detail.timestamp); - if (!Number.isFinite(timestamp) || timestamp <= 0) return; - const dayLabel = formatDayLabel(new Date(timestamp)); - if (!dayLabel) return; - - if (!dayMap[dayLabel]) { - dayMap[dayLabel] = { input: 0, output: 0, cached: 0, reasoning: 0 }; - } - - const tokens = detail.tokens; - const input = typeof tokens.input_tokens === 'number' ? Math.max(tokens.input_tokens, 0) : 0; - const output = typeof tokens.output_tokens === 'number' ? Math.max(tokens.output_tokens, 0) : 0; - const cached = 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 - ); - const reasoning = - typeof tokens.reasoning_tokens === 'number' ? Math.max(tokens.reasoning_tokens, 0) : 0; - - dayMap[dayLabel].input += input; - dayMap[dayLabel].output += output; - dayMap[dayLabel].cached += cached; - dayMap[dayLabel].reasoning += reasoning; - hasData = true; - }); - - const labels = Object.keys(dayMap).sort(); - const dataByCategory: Record = { - input: labels.map((l) => dayMap[l].input), - output: labels.map((l) => dayMap[l].output), - cached: labels.map((l) => dayMap[l].cached), - reasoning: labels.map((l) => dayMap[l].reasoning), - }; - - return { labels, dataByCategory, hasData }; -} - -export interface CostSeries { - labels: string[]; - data: number[]; - hasData: boolean; -} - -/** - * 按小时构建费用时间序列 - */ -export function buildHourlyCostSeries( - usageData: unknown, - modelPrices: Record, - hourWindow: number = 24 -): CostSeries { - const hourMs = 60 * 60 * 1000; - const resolvedHourWindow = - Number.isFinite(hourWindow) && hourWindow > 0 - ? Math.min(Math.max(Math.floor(hourWindow), 1), 24 * 31) - : 24; - const now = new Date(); - const currentHour = new Date(now); - currentHour.setMinutes(0, 0, 0); - - const earliestBucket = new Date(currentHour); - earliestBucket.setHours(earliestBucket.getHours() - (resolvedHourWindow - 1)); - const earliestTime = earliestBucket.getTime(); - - const labels: string[] = []; - for (let i = 0; i < resolvedHourWindow; i++) { - labels.push(formatHourLabel(new Date(earliestTime + i * hourMs))); - } - - const data = new Array(labels.length).fill(0); - const details = collectUsageDetails(usageData); - let hasData = false; - - details.forEach((detail) => { - const timestamp = - typeof detail.__timestampMs === 'number' - ? detail.__timestampMs - : parseTimestampMs(detail.timestamp); - if (!Number.isFinite(timestamp) || timestamp <= 0) return; - const normalized = new Date(timestamp); - normalized.setMinutes(0, 0, 0); - const bucketStart = normalized.getTime(); - const lastBucketTime = earliestTime + (labels.length - 1) * hourMs; - if (bucketStart < earliestTime || bucketStart > lastBucketTime) return; - const bucketIndex = Math.floor((bucketStart - earliestTime) / hourMs); - if (bucketIndex < 0 || bucketIndex >= labels.length) return; - - const cost = calculateCost(detail, modelPrices); - if (cost > 0) { - data[bucketIndex] += cost; - hasData = true; - } - }); - - return { labels, data, hasData }; -} - -/** - * 按天构建费用时间序列 - */ -export function buildDailyCostSeries( - usageData: unknown, - modelPrices: Record -): CostSeries { - const details = collectUsageDetails(usageData); - const dayMap: Record = {}; - let hasData = false; - - details.forEach((detail) => { - const timestamp = - typeof detail.__timestampMs === 'number' - ? detail.__timestampMs - : parseTimestampMs(detail.timestamp); - if (!Number.isFinite(timestamp) || timestamp <= 0) return; - const dayLabel = formatDayLabel(new Date(timestamp)); - if (!dayLabel) return; - - const cost = calculateCost(detail, modelPrices); - if (cost > 0) { - dayMap[dayLabel] = (dayMap[dayLabel] || 0) + cost; - hasData = true; - } - }); - - const labels = Object.keys(dayMap).sort(); - const data = labels.map((l) => dayMap[l]); - - return { labels, data, hasData }; -} diff --git a/src/utils/usage/chartConfig.ts b/src/utils/usage/chartConfig.ts deleted file mode 100644 index 688da21..0000000 --- a/src/utils/usage/chartConfig.ts +++ /dev/null @@ -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`; -} diff --git a/src/utils/usage/index.ts b/src/utils/usage/index.ts deleted file mode 100644 index 45a9fa8..0000000 --- a/src/utils/usage/index.ts +++ /dev/null @@ -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'; diff --git a/src/utils/usage/latency.ts b/src/utils/usage/latency.ts deleted file mode 100644 index b109e6b..0000000 --- a/src/utils/usage/latency.ts +++ /dev/null @@ -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 => - 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): 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(' '); -} diff --git a/src/utils/usageIndex.ts b/src/utils/usageIndex.ts deleted file mode 100644 index 959812e..0000000 --- a/src/utils/usageIndex.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { UsageDetail } from '@/utils/usage'; -import { normalizeAuthIndex } from '@/utils/usage'; - -export type UsageDetailsBySource = Map; -export type UsageDetailsByAuthIndex = Map; - -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 -): UsageDetail[] { - return collectUsageDetailsForKeys(usageDetailsBySource, candidates); -} - -export function collectUsageDetailsForAuthIndices( - usageDetailsByAuthIndex: UsageDetailsByAuthIndex, - authIndices: Iterable -): UsageDetail[] { - return collectUsageDetailsForKeys(usageDetailsByAuthIndex, authIndices); -} - -function collectUsageDetailsForKeys( - usageDetailsByKey: Map, - keys: Iterable -): 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; -}