Compare commits

...

23 Commits

Author SHA1 Message Date
kongkongyo
593653099d Merge upstream/main: 同步上游更新 2026-02-06 14:17:20 +08:00
kongkongyo
58c6df279d Merge branch 'main' into feature 2026-02-06 14:10:36 +08:00
kongkongyo
43ec39040d chore: bump version to 1.1.3 2026-02-05 21:38:30 +08:00
kongkongyo
d981506942 Merge upstream/main: 合并上游更新,保留本地 Kiro 功能 2026-02-05 21:35:00 +08:00
kongkongyo
20d93142d6 fix: fix right navigation overlapping buttons on AI providers page
Changes:
- Add right padding (80px) to content area to prevent ProviderNav from overlapping buttons
- Mobile responsive: reset right padding to 0 (navigation displays at bottom)

Modified files:
- src/pages/AiProvidersPage.module.scss (modified)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 14:08:40 +08:00
kongkongyo
9ee120feb8 chore: bump version to 1.1.1
Changes:
- Upgrade project version from 1.0.9 to 1.1.1

Modified files:
- package.json (modified)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 13:35:11 +08:00
kongkongyo
dd21ec2c72 feat: add Kiro OAuth login support
- Add Kiro icon (kiro.svg)
- Add Kiro OAuth with AWS Builder ID and Identity Center (IDC) support
- Add Kiro refreshToken import functionality
- Add i18n translations for Kiro OAuth (en/zh-CN)

Merged from PR #13 with upstream ProviderNav changes preserved
2026-02-03 12:12:03 +08:00
kongkongyo
f54172e2df Merge remote-tracking branch 'upstream/main' 2026-02-03 12:03:57 +08:00
Lany798
a61893d102 feat: add Kiro OAuth login support
- Add Kiro OAuth card to OAuthPage with AWS Builder ID and IDC login
- Support refreshToken import from Kiro IDE
- Add kiro.svg icon
- Add i18n translations for zh-CN and en
2026-02-02 22:43:26 +08:00
kongkongyo
8513bd4186 chore: bump version to 1.0.9
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 21:45:40 +08:00
kongkongyo
d996b95f0a Merge pull request #11: feat: add Kiro quota display support
新增 Kiro (AWS CodeWhisperer) 额度查看功能:
- 配额页面新增 Kiro 额度区块
- 显示基础额度、赠送额度、合计额度
- 显示订阅类型和重置时间
- 支持单个刷新和批量获取

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 21:43:04 +08:00
kongkongyo
c4642444ef Merge pull request #12 from router-for-me/main
sync: merge upstream router-for-me/Cli-Proxy-API-Management-Center

主要更新:
- 登录页面重新设计(分屏布局 + 自动登录 UI)
- AI 提供商编辑改为独立页面(替代弹窗模式)
- 新增浮动导航侧边栏,支持快速跳转
- OAuth 模型别名和排除模型编辑页面
- 新增多个模型图标(Codex、DeepSeek、GLM、Grok、Kimi、MiniMax)
- 页面过渡动画优化,防止空白闪烁
- 日志页面新增原始日志显示开关
- 移动端响应式布局优化
- 配额管理新增认证类型统计

冲突解决:
- src/stores/index.ts: 保留本地 useDisabledModelsStore 和 PR 的 useOpenAIEditDraftStore
- src/pages/AiProvidersPage.tsx: 采用 PR 版本(页面导航重构)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 21:38:04 +08:00
Lany798
f77cbad98e feat: add Kiro (AWS CodeWhisperer) quota display support
- Add Kiro quota types (KiroFreeTrialInfo, KiroUsageBreakdown, KiroQuotaPayload, KiroQuotaState)
- Add Kiro API constants and request headers
- Add isKiroFile validator and parseKiroQuotaPayload parser
- Add KIRO_CONFIG with fetchKiroQuota and renderKiroItems
- Add kiroQuota state to useQuotaStore
- Add Kiro QuotaSection to QuotaPage
- Add Kiro styles (.kiroGrid, .kiroControls, .kiroControl, .kiroCard)
- Add i18n translations for kiro_quota (zh-CN and en)

Features:
- Separate display for base credits and bonus credits (freeTrialInfo)
- Total credits summary
- Correct parsing of seconds timestamp (scientific notation like 1.769904E9)
- Reset time display
- Subscription type display
2026-01-31 23:47:46 +08:00
kongkongyo
dcdb20159b chore: bump version to 1.0.8
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-28 10:29:14 +08:00
kongkongyo
54e6121234 Merge pull request #8 from router-for-me/main
Merge branch 'router-for-me:main' into feature
2026-01-28 10:25:53 +08:00
kongkongyo
6096ffc94e docs: 更新 README 中的 GitHub 仓库链接为正确的地址 2026-01-25 23:14:41 +08:00
kongkongyo
77fe9905b1 Merge branch 'main' of https://github.com/kongkongyo/Cli-Proxy-API-Management-Center 2026-01-25 15:54:44 +08:00
kongkongyo
eaaa8f6e80 chore: bump version to 1.0.7 2026-01-25 15:53:49 +08:00
kongkongyo
4932374bdd Merge pull request #5 from router-for-me/main
Merge branch 'router-for-me:main' into feature
2026-01-25 15:53:04 +08:00
kongkongyo
82cb521b2e feat: 同步上游仓库更新并增强功能
主要更新:
- 新增使用统计功能,支持按模型显示成功/失败计数
- 大幅增强认证文件页面功能
  - 新增每个文件的启用/禁用切换
  - 新增前缀/代理 URL 模态编辑器
  - 新增 OAuth 映射的模型建议功能
  - 优化模型映射 UI,使用自动完成输入
  - 新增禁用状态样式
- UI/UX 改进
  - 实现自定义 AutocompleteInput 组件
  - 优化页面过渡动画,使用交叉淡入淡出效果
  - 改进 GSAP 页面过渡流畅度
- 配额管理优化
  - 统一 Gemini CLI 配额组(Flash/Pro 系列)
- 系统监控增强
  - 扩展健康监控窗口到 200 分钟
- 修复多个 bug 和改进代码质量

涉及文件:21 个文件修改,新增 1484 行,删除 461 行
2026-01-25 15:49:20 +08:00
kongkongyo
8dc4b169d0 Merge remote-tracking branch 'upstream/main' into feature 2026-01-25 15:45:51 +08:00
kongkongyo
8c3ac0d50a Merge branch 'router-for-me:main' into feature 2026-01-21 08:29:49 +08:00
kongkongyo
e4850656a5 feat: 增强文档和监控功能
主要更新:
- 完善 README 文档,新增中文详细使用说明与监控中心介绍
- 优化 README.md 文档内容和格式,增加英文和中文文档切换链接
- 新增监控中心模块,支持请求日志、统计分析和模型管理
- 增强 AI 提供商配置页面,添加配置搜索功能
- 更新 .gitignore,移除无效注释和调整条目名称
- 删除 README_CN.md 文件,统一文档结构

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-19 00:58:48 +08:00
47 changed files with 6812 additions and 247 deletions

3
.gitignore vendored
View File

@@ -30,3 +30,6 @@ settings.local.json
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
tmpclaude*
.claude
CLIProxyAPI

363
README.md
View File

@@ -1,130 +1,305 @@
# CLI Proxy API Management Center # CLI Proxy API 管理中心 (CPAMC)
A single-file WebUI (React + TypeScript) for operating and troubleshooting the **CLI Proxy API** via its **Management API** (config, credentials, logs, and usage). > 一个基于官方仓库二次创作的 Web 管理界面
[中文文档](README_CN.md) **[English](README_EN.md) | [中文](README.md)**
**Main Project**: https://github.com/router-for-me/CLIProxyAPI ---
**Example URL**: https://remote.router-for.me/
**Minimum Required Version**: ≥ 6.3.0 (recommended ≥ 6.5.0)
Since version 6.0.19, the WebUI ships with the main program; access it via `/management.html` on the API port once the service is running. ## 关于本项目
## What this is (and isnt) 本项目是基于官方 [CLI Proxy API WebUI](https://github.com/router-for-me/Cli-Proxy-API-Management-Center) 进行开发的日志监控和数据可视化管理界面
- This repository is the WebUI only. It talks to the CLI Proxy API **Management API** (`/v0/management`) to read/update config, upload credentials, view logs, and inspect usage. ### 与官方版本的区别
- It is **not** a proxy and does not forward traffic.
## Quick start 本版本与官方版本其他功能保持一致,主要差异在于**新增监控中心**,对日志分析和查看的增强
### Option A: Use the WebUI bundled in CLIProxyAPI (recommended) ### 界面预览
1. Start your CLI Proxy API service. 管理界面展示
2. Open: `http://<host>:<api_port>/management.html`
3. Enter your **management key** and connect.
The address is auto-detected from the current page URL; manual override is supported. ![Dashboard Preview](dashboard-preview.png)
### Option B: Run the dev server ---
```bash ## 快速开始
npm install
npm run dev ### 使用本管理界面
在你的 `config.yaml` 中修改以下配置:
```yaml
remote-management:
panel-github-repository: "https://github.com/kongkongyo/Cli-Proxy-API-Management-Center"
``` ```
Open `http://localhost:5173`, then connect to your CLI Proxy API instance. 配置完成后,重启 CLI Proxy API 服务,访问 `http://<host>:<api_port>/management.html` 即可查看管理界面
### Option C: Build a single HTML file 详细配置说明请参考官方文档https://help.router-for.me/cn/management/webui.html
```bash ---
npm install
npm run build ## 主要功能
### 监控中心 - 核心新增功能
这是本管理界面相对于官方版本的唯一新增功能,提供了全方位的数据可视化和监控能力
> 注意CLI Proxy API 主程序目前没有数据持久化功能,重启程序后统计数据会丢失。需要先通过 API 使用相关服务产生数据后,才能在监控中心看到统计信息。
#### KPI 指标仪表盘
实时展示核心运营指标,支持按时间范围筛选:
- **请求数**:总请求数、成功/失败统计、成功率百分比
- **Token 数**:总 Token 数、输入 Token、输出 Token
- **平均 TPM**:每分钟 Token 使用量
- **平均 RPM**:每分钟请求数
- **日均 RPD**:日均请求数
所有指标都会根据选择的时间范围(今天/7天/14天/30天动态计算实时更新
#### 模型用量分布
直观的饼图展示不同模型的使用占比:
- 按请求数分布
- 按 Token 数分布
- 可切换查看请求占比或 Token 占比
#### 每日趋势分析
详细的时间序列图表,展示每日用量变化趋势:
- 请求数趋势曲线
- 输入 Token 趋势
- 输出 Token 趋势
- 思考 Token 趋势(如支持)
- 缓存 Token 趋势
#### 每小时分析
两个详细的小时级图表,帮助定位高峰时段:
**每小时模型请求分布**
- 柱状图展示不同模型在各小时的请求数
- 支持最近 6 小时/12 小时/24 小时/全部视图切换
**每小时 Token 用量**
- 堆叠柱状图展示 Token 使用构成
- 区分输入 Token、输出 Token、思考 Token、缓存 Token
#### 渠道统计
详细表格展示各渠道API Key/模型)的使用情况:
- 可按全部渠道/特定渠道筛选
- 可按全部模型/特定模型筛选
- 可按全部状态/仅成功/仅失败筛选
- 显示渠道名称、请求数、成功率
- 点击展开查看该渠道下各模型的详细统计
- 显示最近请求状态(最近 10 次请求的迷你状态条)
- 最近请求时间
#### 失败来源分析
帮助定位问题渠道和模型:
- 按渠道统计失败次数
- 显示最近失败时间
- 列出主要失败的模型
- 点击展开查看该渠道下所有失败的请求详情
#### 请求日志 - 高级功能
功能强大的请求日志表格,支持海量数据流畅浏览
**多维度筛选**
- 按 API Key 筛选
- 按提供商类型筛选OpenAI/Gemini/Claude 等)
- 按模型名称筛选
- 按来源渠道筛选
- 按请求状态筛选(全部/成功/失败)
**独立时间范围**
- 支持今天/7天/14天/30天/自定义日期范围
- 与主页面时间范围独立控制
**虚拟滚动**
- 支持 10 万+ 条日志流畅浏览
- 显示当前可见范围统计
- 性能优化,只渲染可见行
**智能信息展示**
- 自动匹配 API Key 到提供商名称(基于配置信息)
- 完整的渠道信息(提供商名称 + 掩码后的密钥)
- 请求类型/模型名称/请求状态
- 最近 10 次请求的状态可视化(绿点=成功,红点=失败)
- 成功率百分比
- 总请求数/输入 Token/输出 Token/总 Token
- 请求时间(完整时间戳)
**自动刷新**
- 支持手动刷新 / 5秒 / 10秒 / 15秒 / 30秒 / 60秒 自动刷新
- 倒计时显示下次刷新时间
- 独立数据加载,不阻塞主页面
**一键禁用模型**
- 支持直接在日志中禁用某渠道的某个模型
- 只对支持该操作的渠道类型生效
- 不支持时显示提示和手动操作指南
---
## 官方版本功能
以下功能与官方版本一致,通过改进的界面提供更好的使用体验
### 仪表盘
- 连接状态实时监控
- 服务器版本和构建信息一目了然
- 使用数据快速概览,掌握全局
- 可用模型统计
### API 密钥管理
- 添加、编辑、删除 API 密钥
- 管理代理服务认证
### AI 提供商配置
- **Gemini**API 密钥管理、排除模型、模型前缀
- **Claude**API 密钥和配置、自定义模型列表
- **Codex**完整配置管理API 密钥、Base URL、代理
- **Vertex**:模型映射配置
- **OpenAI 兼容**:多密钥管理、模型别名导入、连通性测试
- **Ampcode**:上游集成和模型映射
### 认证文件管理
- 上传、下载、删除 JSON 认证文件
- 支持多种提供商Qwen、Gemini、Claude 等)
- 搜索、筛选、分页浏览
- 查看每个凭证支持的模型
### OAuth 登录
- 一键启动 OAuth 授权流程
- 支持 Codex、Anthropic、Gemini CLI、Qwen、iFlow 等
- 自动保存认证文件
- 支持远程浏览器回调提交
### 配额管理
- Antigravity 额度查询
- Codex 额度查询5 小时、周限额、代码审查)
- Gemini CLI 额度查询
- 一键刷新所有额度
### 使用统计
- 请求/Token 趋势图表
- 按模型和 API 的详细统计
- RPM/TPM 实时速率
- 缓存和推理 Token 分解
- 成本估算(支持自定义价格)
### 配置管理
- 在线编辑 `config.yaml`
- YAML 语法高亮
- 搜索和导航
- 保存和重载配置
### 日志查看
- 实时日志流
- 搜索和过滤
- 自动刷新
- 下载错误日志
- 屏蔽管理端流量
### 中心信息
- 连接状态检查
- 版本更新检查
- 可用模型列表展示
- 快捷链接入口
---
## 连接说明
### API 地址格式
以下格式都可以,系统会自动识别
```
localhost:8317
http://192.168.1.10:8317
https://example.com:8317
``` ```
- Output: `dist/index.html` (all assets are inlined). ### 管理密钥
- For CLIProxyAPI bundling, the release workflow renames it to `management.html`.
- To preview locally: `npm run preview`
Tip: opening `dist/index.html` via `file://` may be blocked by browser CORS; serving it (preview/static server) is more reliable. 管理密钥是验证管理操作的钥匙,和客户端使用的 API 密钥不一样
## Connecting to the server ### 远程管理
### API address 从非本地浏览器访问的时候,需要在服务器启用远程管理(`allow-remote-management: true`
You can enter any of the following; the UI will normalize it: ---
- `localhost:8317` ## 界面特性
- `http://192.168.1.10:8317`
- `https://example.com:8317`
- `http://example.com:8317/v0/management` (also accepted; the suffix is removed internally)
### Management key (not the same as API keys) ### 主题切换
- 亮色模式
- 暗色模式
- 跟随系统
The management key is sent with every request as: ### 语言支持
- 简体中文
- English
- `Authorization: Bearer <MANAGEMENT_KEY>` (default) ### 响应式设计
- 桌面端完整功能
- 移动端适配体验
- 侧边栏可折叠
This is different from the proxy `api-keys` you manage inside the UI (those are for client requests to the proxy endpoints). ---
### Remote management ## 常见问题
If you connect from a non-localhost browser, the server must allow remote management (e.g. `allow-remote-management: true`). **Q: 如何使用这个自定义 UI**
See `api.md` for the full authentication rules, server-side limits, and edge cases.
## What you can manage (mapped to the UI pages) A: 在 CLI Proxy API 的配置文件中添加以下配置即可
```yaml
- **Dashboard**: connection status, server version/build date, quick counts, model availability snapshot. remote-management:
- **Basic Settings**: debug, proxy URL, request retry, quota fallback (switch project/preview models), usage statistics, request logging, file logging, WebSocket auth. panel-github-repository: "https://github.com/kongkongyo/CLIProxyAPI-Web-Dashboard"
- **API Keys**: manage proxy `api-keys` (add/edit/delete).
- **AI Providers**:
- Gemini/Codex/Claude key entries (base URL, headers, proxy, model aliases, excluded models, prefix).
- OpenAI-compatible providers (multiple API keys, custom headers, model alias import via `/v1/models`, optional browser-side “chat/completions” test).
- Ampcode integration (upstream URL/key, force mappings, model mapping table).
- **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).
- **OAuth**: start OAuth/device flows for supported providers, poll status, optionally submit callback `redirect_url`; includes iFlow cookie import.
- **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.
## Build & release notes
- Vite produces a **single HTML** output (`dist/index.html`) with all assets inlined (via `vite-plugin-singlefile`).
- Tagging `vX.Y.Z` triggers `.github/workflows/release.yml` to publish `dist/management.html`.
- The UI version shown in the footer is injected at build time (env `VERSION`, git tag, or `package.json` fallback).
## Security notes
- The management key is stored in browser `localStorage` using a lightweight obfuscation format (`enc::v1::...`) to avoid plaintext storage; treat it as sensitive.
- Use a dedicated browser profile/device for management. Be cautious when enabling remote management and evaluate its exposure surface.
## Troubleshooting
- **Cant connect / 401**: confirm the API address and management key; remote access may require enabling remote management in the server config.
- **Repeated auth failures**: the server may temporarily block remote IPs.
- **Logs page missing**: enable “Logging to file” in Basic Settings; the navigation item is shown only when file logging is enabled.
- **Some features show “unsupported”**: the backend may be too old or the endpoint is disabled/absent (common for model lists per auth file, excluded models, logs).
- **OpenAI provider test fails**: the test runs in the browser and depends on network/CORS of the provider endpoint; a failure here does not always mean the server cannot reach it.
## Development
```bash
npm run dev # Vite dev server
npm run build # tsc + Vite build
npm run preview # serve dist locally
npm run lint # ESLint (fails on warnings)
npm run format # Prettier
npm run type-check # tsc --noEmit
``` ```
## Contributing **Q: 无法连接到服务器?**
Issues and PRs are welcome. Please include: A: 请检查以下内容
- API 地址是否正确
- 管理密钥是否正确
- 服务器是否启动
- 远程访问是否启用
- Reproduction steps (server version + UI version) **Q: 日志页面不显示?**
- Screenshots for UI changes
- Verification notes (`npm run lint`, `npm run type-check`)
## License A: 需要去"基础设置"里开启"日志记录到文件"功能
MIT **Q: 某些功能显示"不支持"**
A: 可能是服务器版本太旧,升级到最新版本的 CLI Proxy API
**Q: OpenAI 提供商测试失败?**
A: 测试是在浏览器端执行的,可能会受到 CORS 限制,失败不一定代表服务器端不能用
**Q: 这个版本和官方版本有什么区别?**
A: 主要区别有两个:
1. **界面风格**全新的视觉设计UI 细节更精致
2. **监控中心**:这是唯一新增的功能模块,提供了强大的数据可视化和监控能力,包括 KPI 仪表盘、模型用量分布、趋势分析、小时级图表、渠道统计、失败分析和高级请求日志等功能
其他所有功能与官方版本保持一致
---
## 相关链接
- **官方主程序**: https://github.com/router-for-me/CLIProxyAPI
- **官方 WebUI**: https://github.com/router-for-me/Cli-Proxy-API-Management-Center
- **本仓库**: https://github.com/kongkongyo/CLIProxyAPI-Web-Dashboard
## 许可证
MIT License

View File

@@ -1,130 +0,0 @@
# CLI Proxy API 管理中心
用于管理与排障 **CLI Proxy API** 的单文件 WebUIReact + TypeScript通过 **Management API** 完成配置、凭据、日志与统计等运维工作。
[English](README.md)
**主项目**: https://github.com/router-for-me/CLIProxyAPI
**示例地址**: https://remote.router-for.me/
**最低版本要求**: ≥ 6.3.0(推荐 ≥ 6.5.0
Since version 6.0.19, the WebUI ships with the main program; access it via `/management.html` on the API port once the service is running.
## 这是什么(以及不是什么)
- 本仓库只包含 Web 管理界面本身,通过 CLI Proxy API 的 **Management API**`/v0/management`)读取/修改配置、上传凭据、查看日志与使用统计。
-**不是** 代理本体,不参与流量转发。
## 快速开始
### 方式 A使用 CLIProxyAPI 自带的 WebUI推荐
1. 启动 CLI Proxy API 服务。
2. 打开:`http://<host>:<api_port>/management.html`
3. 输入 **管理密钥** 并连接。
页面会根据当前地址自动推断 API 地址,也支持手动修改。
### 方式 B开发调试
```bash
npm install
npm run dev
```
打开 `http://localhost:5173`,然后连接到你的 CLI Proxy API 实例。
### 方式 C构建单文件 HTML
```bash
npm install
npm run build
```
- 构建产物:`dist/index.html`(资源已全部内联)。
- 在 CLIProxyAPI 的发布流程里会重命名为 `management.html`
- 本地预览:`npm run preview`
提示:直接用 `file://` 打开 `dist/index.html` 可能遇到浏览器 CORS 限制;更稳妥的方式是用预览/静态服务器打开。
## 连接说明
### API 地址怎么填
以下格式均可WebUI 会自动归一化:
- `localhost:8317`
- `http://192.168.1.10:8317`
- `https://example.com:8317`
- `http://example.com:8317/v0/management`(也可填写,后缀会被自动去除)
### 管理密钥(注意:不是 API Keys
管理密钥会以如下方式随请求发送:
- `Authorization: Bearer <MANAGEMENT_KEY>`(默认)
这与 WebUI 中“API Keys”页面管理的 `api-keys` 不同:后者是代理对外接口(如 OpenAI 兼容接口)给客户端使用的鉴权 key。
### 远程管理
当你从非 localhost 的浏览器访问时,服务端通常需要开启远程管理(例如 `allow-remote-management: true`)。
完整鉴权规则、限制与边界情况请查看 `api.md`
## 功能一览(按页面对应)
- **仪表盘**:连接状态、服务版本/构建时间、关键数量概览、可用模型概览。
- **基础设置**:调试开关、代理 URL、请求重试、配额回退切项目/切预览模型、使用统计、请求日志、文件日志、WebSocket 鉴权。
- **API Keys**:管理代理 `api-keys`(增/改/删)。
- **AI 提供商**
- Gemini/Codex/Claude 配置Base URL、Headers、代理、模型别名、排除模型、Prefix
- OpenAI 兼容提供商(多 Key、Header、自助从 `/v1/models` 拉取并导入模型别名、可选浏览器侧 `chat/completions` 测试)。
- Ampcode 集成(上游地址/密钥、强制映射、模型映射表)。
- **认证文件**:上传/下载/删除 JSON 凭据,筛选/搜索/分页,标记 runtime-only查看单个凭据可用模型依赖后端支持管理 OAuth 排除模型(支持 `*` 通配符)。
- **OAuth**:对支持的提供商发起 OAuth/设备码流程,轮询状态;可选提交回调 `redirect_url`;包含 iFlow Cookie 导入。
- **使用统计**:按小时/天图表、按 API 与按模型统计、缓存/推理 Token 拆分、RPM/TPM 时间窗、可选本地保存的模型价格用于费用估算。
- **配置文件**:浏览器内编辑 `/config.yaml`YAML 高亮 + 搜索),保存/重载。
- **日志**:增量拉取日志、自动刷新、搜索、隐藏管理端流量、清空日志;下载请求错误日志文件。
- **系统信息**:快捷链接 + 拉取 `/v1/models` 并分组展示(需要至少一个代理 API Key 才能查询模型)。
## 构建与发布说明
- 使用 Vite 输出 **单文件 HTML**`dist/index.html`),资源全部内联(`vite-plugin-singlefile`)。
-`vX.Y.Z` 标签会触发 `.github/workflows/release.yml`,发布 `dist/management.html`
- 页脚显示的 UI 版本在构建期注入(优先使用环境变量 `VERSION`,否则使用 git tag / `package.json`)。
## 安全提示
- 管理密钥会存入浏览器 `localStorage`,并使用轻量混淆格式(`enc::v1::...`)避免明文;仍应视为敏感信息。
- 建议使用独立浏览器配置/设备进行管理;开启远程管理时请谨慎评估暴露面。
## 常见问题
- **无法连接 / 401**:确认 API 地址与管理密钥;远程访问可能需要服务端开启远程管理。
- **反复输错密钥**:服务端可能对远程 IP 进行临时封禁。
- **日志页面不显示**:需要在“基础设置”里开启“写入日志文件”,导航项才会出现。
- **功能提示不支持**:多为后端版本较旧或接口未启用/不存在(如:认证文件模型列表、排除模型、日志相关接口)。
- **OpenAI 提供商测试失败**:测试在浏览器侧执行,会受网络与 CORS 影响;这里失败不一定代表服务端不可用。
## 开发命令
```bash
npm run dev # 启动开发服务器
npm run build # tsc + Vite 构建
npm run preview # 本地预览 dist
npm run lint # ESLintwarnings 视为失败)
npm run format # Prettier
npm run type-check # tsc --noEmit
```
## 贡献
欢迎提 Issue 与 PR。建议附上
- 复现步骤(服务端版本 + UI 版本)
- UI 改动截图
- 验证记录(`npm run lint``npm run type-check`
## 许可证
MIT

305
README_EN.md Normal file
View File

@@ -0,0 +1,305 @@
# CLI Proxy API Management Center (CPAMC)
> A Web management interface based on the official repository with custom modifications
**[English](README_EN.md) | [中文](README.md)**
---
## About This Project
This project is a log monitoring and data visualization management interface developed based on the official [CLI Proxy API WebUI](https://github.com/router-for-me/Cli-Proxy-API-Management-Center)
### Differences from Official Version
This version is consistent with the official version in other functions, with the main difference being the **new monitoring center**, which enhances log analysis and viewing
### Interface Preview
Management interface display
![Dashboard Preview](dashboard-preview.png)
---
## Quick Start
### Using This Management Interface
Modify following configuration in your `config.yaml`:
```yaml
remote-management:
panel-github-repository: "https://github.com/kongkongyo/CLIProxyAPI-Web-Dashboard"
```
After configuration, restart the CLI Proxy API service and visit `http://<host>:<api_port>/management.html` to view the management interface
For detailed configuration instructions, please refer to the official documentation: https://help.router-for.me/cn/management/webui.html
---
## Main Features
### Monitoring Center - Core New Feature
This is the only new feature of this management interface compared to the official version, providing comprehensive data visualization and monitoring capabilities
> Note: The CLI Proxy API main program currently does not have data persistence functionality. Statistical data will be lost after restarting the program. You need to use related services through the API first to generate data before you can see statistical information in the monitoring center.
#### KPI Dashboard
Real-time display of core operational metrics, supports filtering by time range:
- **Request Count**: Total requests, success/failure statistics, success rate percentage
- **Token Count**: Total tokens, input tokens, output tokens
- **Average TPM**: Tokens per minute
- **Average RPM**: Requests per minute
- **Average RPD**: Daily average requests
All metrics are dynamically calculated and updated in real-time based on the selected time range (today/7 days/14 days/30 days)
#### Model Usage Distribution
Intuitive pie chart showing the usage distribution of different models:
- Distribution by request count
- Distribution by token count
- Switchable between request percentage and token percentage
#### Daily Trend Analysis
Detailed time series charts showing daily usage trends:
- Request count trend curve
- Input token trend
- Output token trend
- Thinking token trend (if supported)
- Cache token trend
#### Hourly Analysis
Two detailed hourly charts to help identify peak periods:
**Hourly Model Request Distribution**
- Bar chart showing requests for different models in each hour
- Supports switching between recent 6 hours/12 hours/24 hours/all views
**Hourly Token Usage**
- Stacked bar chart showing token usage composition
- Distinguishes between input tokens, output tokens, thinking tokens, cache tokens
#### Channel Statistics
Detailed table showing usage of each channel (API Key/model):
- Filter by all channels/specific channel
- Filter by all models/specific model
- Filter by all status/success only/failure only
- Display channel name, request count, success rate
- Click to expand and view detailed statistics of each model under that channel
- Display recent request status (mini status bar of recent 10 requests)
- Most recent request time
#### Failure Source Analysis
Help locate problematic channels and models:
- Statistics of failure counts by channel
- Display most recent failure time
- List of main failed models
- Click to expand and view all failed request details under that channel
#### Request Logs - Advanced Feature
Powerful request log table, supports smooth browsing of massive data
**Multi-dimensional Filtering**
- Filter by API Key
- Filter by provider type (OpenAI/Gemini/Claude, etc.)
- Filter by model name
- Filter by source channel
- Filter by request status (all/success/failure)
**Independent Time Range**
- Supports today/7 days/14 days/30 days/custom date range
- Independent control from main page time range
**Virtual Scrolling**
- Supports smooth browsing of 100,000+ logs
- Display current visible range statistics
- Performance optimized, only renders visible rows
**Smart Information Display**
- Automatically match API Key to provider name (based on configuration)
- Complete channel information (provider name + masked key)
- Request type/model name/request status
- Status visualization of recent 10 requests (green dot=success, red dot=failure)
- Success rate percentage
- Total requests/input tokens/output tokens/total tokens
- Request time (complete timestamp)
**Auto Refresh**
- Supports manual refresh / 5s / 10s / 15s / 30s / 60s auto refresh
- Countdown display for next refresh time
- Independent data loading, does not block main page
**One-click Disable Model**
- Supports directly disabling a specific model of a channel in logs
- Only effective for channel types that support this operation
- Shows prompt and manual operation guide when not supported
---
## Official Version Features
The following features are consistent with the official version, providing a better user experience through an improved interface
### Dashboard
- Real-time connection status monitoring
- Server version and build information at a glance
- Quick overview of usage data
- Available model statistics
### API Key Management
- Add, edit, delete API keys
- Manage proxy service authentication
### AI Provider Configuration
- **Gemini**: API key management, model exclusion, model prefix
- **Claude**: API key and configuration, custom model list
- **Codex**: Complete configuration management (API key, Base URL, proxy)
- **Vertex**: Model mapping configuration
- **OpenAI Compatible**: Multi-key management, model alias import, connectivity testing
- **Ampcode**: Upstream integration and model mapping
### Authentication File Management
- Upload, download, delete JSON authentication files
- Supports multiple providers (Qwen, Gemini, Claude, etc.)
- Search, filter, paginated browsing
- View models supported by each credential
### OAuth Login
- One-click start OAuth authorization flow
- Supports Codex, Anthropic, Gemini CLI, Qwen, iFlow, etc.
- Automatically save authentication files
- Supports remote browser callback submission
### Quota Management
- Antigravity quota query
- Codex quota query (5 hours, weekly limit, code review)
- Gemini CLI quota query
- One-click refresh all quotas
### Usage Statistics
- Request/Token trend charts
- Detailed statistics by model and API
- RPM/TPM real-time rates
- Cache and reasoning token breakdown
- Cost estimation (supports custom prices)
### Configuration Management
- Online editing of `config.yaml`
- YAML syntax highlighting
- Search and navigation
- Save and reload configuration
### Log Viewing
- Real-time log stream
- Search and filtering
- Auto refresh
- Download error logs
- Mask management traffic
### Center Information
- Connection status check
- Version update check
- Available model list display
- Quick link entry
---
## Connection Instructions
### API Address Format
The following formats are all supported, and the system will automatically recognize them
```
localhost:8317
http://192.168.1.10:8317
https://example.com:8317
```
### Management Key
The management key is the key for verifying management operations and is different from the API key used by clients
### Remote Management
When accessing from a non-local browser, you need to enable remote management on the server (`allow-remote-management: true`)
---
## Interface Features
### Theme Switching
- Light mode
- Dark mode
- Follow system
### Language Support
- Simplified Chinese
- English
### Responsive Design
- Full functionality on desktop
- Mobile-adapted experience
- Collapsible sidebar
---
## FAQ
**Q: How to use this custom UI?**
A: Add the following configuration to your CLI Proxy API configuration file
```yaml
remote-management:
panel-github-repository: "https://github.com/kongkongyo/CLIProxyAPI-Web-Dashboard"
```
**Q: Cannot connect to the server?**
A: Please check the following
- Is the API address correct?
- Is the management key correct?
- Is the server started?
- Is remote access enabled?
**Q: Log page not displaying?**
A: You need to enable the "Log to file" function in "Basic Settings"
**Q: Some functions show "not supported"?**
A: The server version may be too old. Upgrade to the latest version of CLI Proxy API
**Q: OpenAI provider test failed?**
A: Tests are executed in the browser and may be subject to CORS restrictions. Failure does not necessarily mean it won't work on the server side
**Q: What is the difference between this version and the official version?**
A: There are two main differences:
1. **Interface Style**: Completely new visual design with more refined UI details
2. **Monitoring Center**: This is the only newly added feature module, providing powerful data visualization and monitoring capabilities, including KPI dashboard, model usage distribution, trend analysis, hourly charts, channel statistics, failure analysis, and advanced request logs
All other features remain consistent with the official version
---
## Related Links
- **Official Main Program**: https://github.com/router-for-me/CLIProxyAPI
- **Official WebUI**: https://github.com/router-for-me/Cli-Proxy-API-Management-Center
- **This Repository**: https://github.com/kongkongyo/CLIProxyAPI-Web-Dashboard
## License
MIT License

BIN
dashboard-preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

28
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@codemirror/lang-yaml": "^6.1.2", "@codemirror/lang-yaml": "^6.1.2",
"@tanstack/react-virtual": "^3.13.18",
"@uiw/react-codemirror": "^4.25.3", "@uiw/react-codemirror": "^4.25.3",
"axios": "^1.13.2", "axios": "^1.13.2",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
@@ -1868,6 +1869,33 @@
"win32" "win32"
] ]
}, },
"node_modules/@tanstack/react-virtual": {
"version": "3.13.18",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.18.tgz",
"integrity": "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.18",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz",
"integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",

View File

@@ -1,7 +1,7 @@
{ {
"name": "cli-proxy-webui-react", "name": "cli-proxy-webui-react",
"private": true, "private": true,
"version": "0.0.0", "version": "1.1.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -13,6 +13,7 @@
}, },
"dependencies": { "dependencies": {
"@codemirror/lang-yaml": "^6.1.2", "@codemirror/lang-yaml": "^6.1.2",
"@tanstack/react-virtual": "^3.13.18",
"@uiw/react-codemirror": "^4.25.3", "@uiw/react-codemirror": "^4.25.3",
"axios": "^1.13.2", "axios": "^1.13.2",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" fill="#FF9900"/>
<path d="M12 6L8 10h3v4H8l4 4 4-4h-3v-4h3l-4-4z" fill="#232F3E"/>
</svg>

After

Width:  |  Height:  |  Size: 244 B

View File

@@ -24,6 +24,7 @@ import {
IconSettings, IconSettings,
IconShield, IconShield,
IconTimer, IconTimer,
IconActivity,
} from '@/components/ui/icons'; } from '@/components/ui/icons';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline'; import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
import { import {
@@ -46,6 +47,7 @@ const sidebarIcons: Record<string, ReactNode> = {
config: <IconSettings size={18} />, config: <IconSettings size={18} />,
logs: <IconScrollText size={18} />, logs: <IconScrollText size={18} />,
system: <IconInfo size={18} />, system: <IconInfo size={18} />,
monitor: <IconActivity size={18} />,
}; };
// Header action icons - smaller size for header buttons // Header action icons - smaller size for header buttons
@@ -394,6 +396,7 @@ export function MainLayout() {
? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }] ? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }]
: []), : []),
{ path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system }, { path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system },
{ path: '/monitor', label: t('nav.monitor'), icon: sidebarIcons.monitor },
]; ];
const navOrder = navItems.map((item) => item.path); const navOrder = navItems.map((item) => item.path);
const getRouteOrder = (pathname: string) => { const getRouteOrder = (pathname: string) => {

View File

@@ -0,0 +1,409 @@
import { useMemo, useState, useCallback, Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { useDisableModel } from '@/hooks';
import { TimeRangeSelector, formatTimeRangeCaption, type TimeRange } from './TimeRangeSelector';
import { DisableModelModal } from './DisableModelModal';
import {
formatTimestamp,
getRateClassName,
filterDataByTimeRange,
getProviderDisplayParts,
type DateRange,
} from '@/utils/monitor';
import type { UsageData } from '@/pages/MonitorPage';
import styles from '@/pages/MonitorPage.module.scss';
interface ChannelStatsProps {
data: UsageData | null;
loading: boolean;
providerMap: Record<string, string>;
providerModels: Record<string, Set<string>>;
}
interface ModelStat {
requests: number;
success: number;
failed: number;
successRate: number;
recentRequests: { failed: boolean; timestamp: number }[];
lastTimestamp: number;
}
interface ChannelStat {
source: string;
displayName: string;
providerName: string | null;
maskedKey: string;
totalRequests: number;
successRequests: number;
failedRequests: number;
successRate: number;
lastRequestTime: number;
recentRequests: { failed: boolean; timestamp: number }[];
models: Record<string, ModelStat>;
}
export function ChannelStats({ data, loading, providerMap, providerModels }: ChannelStatsProps) {
const { t } = useTranslation();
const [expandedChannel, setExpandedChannel] = useState<string | null>(null);
const [filterChannel, setFilterChannel] = useState('');
const [filterModel, setFilterModel] = useState('');
const [filterStatus, setFilterStatus] = useState<'' | 'success' | 'failed'>('');
// 时间范围状态
const [timeRange, setTimeRange] = useState<TimeRange>(7);
const [customRange, setCustomRange] = useState<DateRange | undefined>();
// 使用禁用模型 Hook
const {
disableState,
disabling,
isModelDisabled,
handleDisableClick: onDisableClick,
handleConfirmDisable,
handleCancelDisable,
} = useDisableModel({ providerMap, providerModels });
// 处理时间范围变化
const handleTimeRangeChange = useCallback((range: TimeRange, custom?: DateRange) => {
setTimeRange(range);
if (custom) {
setCustomRange(custom);
}
}, []);
// 根据时间范围过滤数据
const timeFilteredData = useMemo(() => {
return filterDataByTimeRange(data, timeRange, customRange);
}, [data, timeRange, customRange]);
// 计算渠道统计数据
const channelStats = useMemo(() => {
if (!timeFilteredData?.apis) return [];
const stats: Record<string, ChannelStat> = {};
Object.values(timeFilteredData.apis).forEach((apiData) => {
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
modelData.details.forEach((detail) => {
const source = detail.source || 'unknown';
// 获取渠道显示信息
const { provider, masked } = getProviderDisplayParts(source, providerMap);
// 只统计在 providerMap 中存在的渠道
if (!provider) return;
const displayName = `${provider} (${masked})`;
const timestamp = detail.timestamp ? new Date(detail.timestamp).getTime() : 0;
if (!stats[displayName]) {
stats[displayName] = {
source,
displayName,
providerName: provider,
maskedKey: masked,
totalRequests: 0,
successRequests: 0,
failedRequests: 0,
successRate: 0,
lastRequestTime: 0,
recentRequests: [],
models: {},
};
}
stats[displayName].totalRequests++;
if (detail.failed) {
stats[displayName].failedRequests++;
} else {
stats[displayName].successRequests++;
}
// 更新最近请求时间
if (timestamp > stats[displayName].lastRequestTime) {
stats[displayName].lastRequestTime = timestamp;
}
// 收集请求状态
stats[displayName].recentRequests.push({ failed: detail.failed, timestamp });
// 模型统计
if (!stats[displayName].models[modelName]) {
stats[displayName].models[modelName] = {
requests: 0,
success: 0,
failed: 0,
successRate: 0,
recentRequests: [],
lastTimestamp: 0,
};
}
stats[displayName].models[modelName].requests++;
if (detail.failed) {
stats[displayName].models[modelName].failed++;
} else {
stats[displayName].models[modelName].success++;
}
stats[displayName].models[modelName].recentRequests.push({ failed: detail.failed, timestamp });
if (timestamp > stats[displayName].models[modelName].lastTimestamp) {
stats[displayName].models[modelName].lastTimestamp = timestamp;
}
});
});
});
// 计算成功率并排序请求
Object.values(stats).forEach((stat) => {
stat.successRate = stat.totalRequests > 0
? (stat.successRequests / stat.totalRequests) * 100
: 0;
// 按时间排序取最近12个
stat.recentRequests.sort((a, b) => a.timestamp - b.timestamp);
stat.recentRequests = stat.recentRequests.slice(-12);
Object.values(stat.models).forEach((model) => {
model.successRate = model.requests > 0
? (model.success / model.requests) * 100
: 0;
model.recentRequests.sort((a, b) => a.timestamp - b.timestamp);
model.recentRequests = model.recentRequests.slice(-12);
});
});
return Object.values(stats)
.filter((stat) => stat.totalRequests > 0)
.sort((a, b) => b.totalRequests - a.totalRequests)
.slice(0, 10);
}, [timeFilteredData, providerMap]);
// 获取所有渠道和模型列表
const { channels, models } = useMemo(() => {
const channelSet = new Set<string>();
const modelSet = new Set<string>();
channelStats.forEach((stat) => {
channelSet.add(stat.displayName);
Object.keys(stat.models).forEach((model) => modelSet.add(model));
});
return {
channels: Array.from(channelSet).sort(),
models: Array.from(modelSet).sort(),
};
}, [channelStats]);
// 过滤后的数据
const filteredStats = useMemo(() => {
return channelStats.filter((stat) => {
if (filterChannel && stat.displayName !== filterChannel) return false;
if (filterModel && !stat.models[filterModel]) return false;
if (filterStatus === 'success' && stat.failedRequests > 0) return false;
if (filterStatus === 'failed' && stat.failedRequests === 0) return false;
return true;
});
}, [channelStats, filterChannel, filterModel, filterStatus]);
// 切换展开状态
const toggleExpand = (displayName: string) => {
setExpandedChannel(expandedChannel === displayName ? null : displayName);
};
// 开始禁用流程(阻止事件冒泡)
const handleDisableClick = (source: string, model: string, e: React.MouseEvent) => {
e.stopPropagation();
onDisableClick(source, model);
};
return (
<>
<Card
title={t('monitor.channel.title')}
subtitle={
<span>
{formatTimeRangeCaption(timeRange, customRange, t)} · {t('monitor.channel.subtitle')}
<span style={{ color: 'var(--text-tertiary)' }}> · {t('monitor.channel.click_hint')}</span>
</span>
}
extra={
<TimeRangeSelector
value={timeRange}
onChange={handleTimeRangeChange}
customRange={customRange}
/>
}
>
{/* 筛选器 */}
<div className={styles.logFilters}>
<select
className={styles.logSelect}
value={filterChannel}
onChange={(e) => setFilterChannel(e.target.value)}
>
<option value="">{t('monitor.channel.all_channels')}</option>
{channels.map((channel) => (
<option key={channel} value={channel}>{channel}</option>
))}
</select>
<select
className={styles.logSelect}
value={filterModel}
onChange={(e) => setFilterModel(e.target.value)}
>
<option value="">{t('monitor.channel.all_models')}</option>
{models.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
<select
className={styles.logSelect}
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value as '' | 'success' | 'failed')}
>
<option value="">{t('monitor.channel.all_status')}</option>
<option value="success">{t('monitor.channel.only_success')}</option>
<option value="failed">{t('monitor.channel.only_failed')}</option>
</select>
</div>
{/* 表格 */}
<div className={styles.tableWrapper}>
{loading ? (
<div className={styles.emptyState}>{t('common.loading')}</div>
) : filteredStats.length === 0 ? (
<div className={styles.emptyState}>{t('monitor.no_data')}</div>
) : (
<table className={styles.table}>
<thead>
<tr>
<th>{t('monitor.channel.header_name')}</th>
<th>{t('monitor.channel.header_count')}</th>
<th>{t('monitor.channel.header_rate')}</th>
<th>{t('monitor.channel.header_recent')}</th>
<th>{t('monitor.channel.header_time')}</th>
</tr>
</thead>
<tbody>
{filteredStats.map((stat) => (
<Fragment key={stat.displayName}>
<tr
className={styles.expandable}
onClick={() => toggleExpand(stat.displayName)}
>
<td>
{stat.providerName ? (
<>
<span className={styles.channelName}>{stat.providerName}</span>
<span className={styles.channelSecret}> ({stat.maskedKey})</span>
</>
) : (
stat.maskedKey
)}
</td>
<td>{stat.totalRequests.toLocaleString()}</td>
<td className={getRateClassName(stat.successRate, styles)}>
{stat.successRate.toFixed(1)}%
</td>
<td>
<div className={styles.statusBars}>
{stat.recentRequests.map((req, i) => (
<div
key={i}
className={`${styles.statusBar} ${req.failed ? styles.failure : styles.success}`}
/>
))}
</div>
</td>
<td>{formatTimestamp(stat.lastRequestTime)}</td>
</tr>
{expandedChannel === stat.displayName && (
<tr key={`${stat.displayName}-detail`}>
<td colSpan={5} className={styles.expandDetail}>
<div className={styles.expandTableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>{t('monitor.channel.model')}</th>
<th>{t('monitor.channel.header_count')}</th>
<th>{t('monitor.channel.header_rate')}</th>
<th>{t('monitor.channel.success')}/{t('monitor.channel.failed')}</th>
<th>{t('monitor.channel.header_recent')}</th>
<th>{t('monitor.channel.header_time')}</th>
<th>{t('monitor.logs.header_actions')}</th>
</tr>
</thead>
<tbody>
{Object.entries(stat.models)
.sort((a, b) => {
const aDisabled = isModelDisabled(stat.source, a[0]);
const bDisabled = isModelDisabled(stat.source, b[0]);
// 已禁用的排在后面
if (aDisabled !== bDisabled) {
return aDisabled ? 1 : -1;
}
// 然后按请求数降序
return b[1].requests - a[1].requests;
})
.map(([modelName, modelStat]) => {
const disabled = isModelDisabled(stat.source, modelName);
return (
<tr key={modelName} className={disabled ? styles.modelDisabled : ''}>
<td>{modelName}</td>
<td>{modelStat.requests.toLocaleString()}</td>
<td className={getRateClassName(modelStat.successRate, styles)}>
{modelStat.successRate.toFixed(1)}%
</td>
<td>
<span className={styles.kpiSuccess}>{modelStat.success}</span>
{' / '}
<span className={styles.kpiFailure}>{modelStat.failed}</span>
</td>
<td>
<div className={styles.statusBars}>
{modelStat.recentRequests.map((req, i) => (
<div
key={i}
className={`${styles.statusBar} ${req.failed ? styles.failure : styles.success}`}
/>
))}
</div>
</td>
<td>{formatTimestamp(modelStat.lastTimestamp)}</td>
<td>
{disabled ? (
<span className={styles.disabledLabel}>{t('monitor.logs.removed')}</span>
) : stat.source && stat.source !== '-' && stat.source !== 'unknown' ? (
<button
className={styles.disableBtn}
onClick={(e) => handleDisableClick(stat.source, modelName, e)}
>
{t('monitor.logs.disable')}
</button>
) : '-'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
)}
</div>
</Card>
{/* 禁用确认弹窗 */}
<DisableModelModal
disableState={disableState}
disabling={disabling}
onConfirm={handleConfirmDisable}
onCancel={handleCancelDisable}
/>
</>
);
}

View File

@@ -0,0 +1,279 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Chart } from 'react-chartjs-2';
import type { UsageData } from '@/pages/MonitorPage';
import styles from '@/pages/MonitorPage.module.scss';
interface DailyTrendChartProps {
data: UsageData | null;
loading: boolean;
isDark: boolean;
timeRange: number;
}
interface DailyStat {
date: string;
requests: number;
successRequests: number;
failedRequests: number;
inputTokens: number;
outputTokens: number;
reasoningTokens: number;
cachedTokens: number;
}
export function DailyTrendChart({ data, loading, isDark, timeRange }: DailyTrendChartProps) {
const { t } = useTranslation();
// 按日期聚合数据
const dailyData = useMemo((): DailyStat[] => {
if (!data?.apis) return [];
const dailyStats: Record<string, {
requests: number;
successRequests: number;
failedRequests: number;
inputTokens: number;
outputTokens: number;
reasoningTokens: number;
cachedTokens: number;
}> = {};
// 辅助函数:获取本地日期字符串 YYYY-MM-DD
const getLocalDateString = (timestamp: string): string => {
const date = new Date(timestamp);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
Object.values(data.apis).forEach((apiData) => {
Object.values(apiData.models).forEach((modelData) => {
modelData.details.forEach((detail) => {
// 使用本地日期而非 UTC 日期
const date = getLocalDateString(detail.timestamp);
if (!dailyStats[date]) {
dailyStats[date] = {
requests: 0,
successRequests: 0,
failedRequests: 0,
inputTokens: 0,
outputTokens: 0,
reasoningTokens: 0,
cachedTokens: 0,
};
}
dailyStats[date].requests++;
if (detail.failed) {
dailyStats[date].failedRequests++;
} else {
dailyStats[date].successRequests++;
// 只统计成功请求的 Token
dailyStats[date].inputTokens += detail.tokens.input_tokens || 0;
dailyStats[date].outputTokens += detail.tokens.output_tokens || 0;
dailyStats[date].reasoningTokens += detail.tokens.reasoning_tokens || 0;
dailyStats[date].cachedTokens += detail.tokens.cached_tokens || 0;
}
});
});
});
// 转换为数组并按日期排序
return Object.entries(dailyStats)
.map(([date, stats]) => ({ date, ...stats }))
.sort((a, b) => a.date.localeCompare(b.date));
}, [data]);
// 图表数据
const chartData = useMemo(() => {
const labels = dailyData.map((item) => {
const date = new Date(item.date);
return `${date.getMonth() + 1}/${date.getDate()}`;
});
return {
labels,
datasets: [
{
type: 'line' as const,
label: t('monitor.trend.requests'),
data: dailyData.map((item) => item.requests),
borderColor: '#3b82f6',
backgroundColor: '#3b82f6',
borderWidth: 3,
fill: false,
tension: 0.35,
yAxisID: 'y1',
order: 0,
pointRadius: 3,
pointBackgroundColor: '#3b82f6',
},
{
type: 'bar' as const,
label: t('monitor.trend.input_tokens'),
data: dailyData.map((item) => item.inputTokens / 1000),
backgroundColor: 'rgba(34, 197, 94, 0.7)',
borderColor: 'rgba(34, 197, 94, 0.7)',
borderWidth: 1,
borderRadius: 0,
yAxisID: 'y',
order: 1,
stack: 'tokens',
},
{
type: 'bar' as const,
label: t('monitor.trend.output_tokens'),
data: dailyData.map((item) => item.outputTokens / 1000),
backgroundColor: 'rgba(249, 115, 22, 0.7)',
borderColor: 'rgba(249, 115, 22, 0.7)',
borderWidth: 1,
borderRadius: 4,
yAxisID: 'y',
order: 1,
stack: 'tokens',
},
],
};
}, [dailyData, t]);
// 图表配置
const chartOptions = useMemo(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index' as const,
intersect: false,
},
plugins: {
legend: {
display: true,
position: 'bottom' as const,
labels: {
color: isDark ? '#9ca3af' : '#6b7280',
usePointStyle: true,
padding: 16,
font: {
size: 11,
},
generateLabels: (chart: any) => {
return chart.data.datasets.map((dataset: any, i: number) => {
const isLine = dataset.type === 'line';
return {
text: dataset.label,
fillStyle: dataset.backgroundColor,
strokeStyle: dataset.borderColor,
lineWidth: 0,
hidden: !chart.isDatasetVisible(i),
datasetIndex: i,
pointStyle: isLine ? 'circle' : 'rect',
};
});
},
},
},
tooltip: {
backgroundColor: isDark ? '#374151' : '#ffffff',
titleColor: isDark ? '#f3f4f6' : '#111827',
bodyColor: isDark ? '#d1d5db' : '#4b5563',
borderColor: isDark ? '#4b5563' : '#e5e7eb',
borderWidth: 1,
padding: 12,
callbacks: {
label: (context: any) => {
const label = context.dataset.label || '';
const value = context.raw;
if (context.dataset.yAxisID === 'y1') {
return `${label}: ${value.toLocaleString()}`;
}
return `${label}: ${value.toFixed(1)}K`;
},
},
},
},
scales: {
x: {
grid: {
color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
},
ticks: {
color: isDark ? '#9ca3af' : '#6b7280',
font: {
size: 11,
},
},
},
y: {
type: 'linear' as const,
position: 'left' as const,
stacked: true,
grid: {
color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
},
ticks: {
color: isDark ? '#9ca3af' : '#6b7280',
font: {
size: 11,
},
callback: (value: string | number) => `${value}K`,
},
title: {
display: true,
text: 'Tokens (K)',
color: isDark ? '#9ca3af' : '#6b7280',
font: {
size: 11,
},
},
},
y1: {
type: 'linear' as const,
position: 'right' as const,
grid: {
drawOnChartArea: false,
},
ticks: {
color: isDark ? '#9ca3af' : '#6b7280',
font: {
size: 11,
},
},
title: {
display: true,
text: t('monitor.trend.requests'),
color: isDark ? '#9ca3af' : '#6b7280',
font: {
size: 11,
},
},
},
},
}), [isDark, t]);
const timeRangeLabel = timeRange === 1
? t('monitor.today')
: t('monitor.last_n_days', { n: timeRange });
return (
<div className={styles.chartCard}>
<div className={styles.chartHeader}>
<div>
<h3 className={styles.chartTitle}>{t('monitor.trend.title')}</h3>
<p className={styles.chartSubtitle}>
{timeRangeLabel} · {t('monitor.trend.subtitle')}
</p>
</div>
</div>
<div className={styles.chartContent}>
{loading || dailyData.length === 0 ? (
<div className={styles.chartEmpty}>
{loading ? t('common.loading') : t('monitor.no_data')}
</div>
) : (
<Chart type="bar" data={chartData} options={chartOptions} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,101 @@
/**
* 禁用模型确认弹窗组件
* 封装三次确认的 UI 逻辑
*/
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import type { DisableState } from '@/utils/monitor';
interface DisableModelModalProps {
/** 禁用状态 */
disableState: DisableState | null;
/** 是否正在禁用中 */
disabling: boolean;
/** 确认回调 */
onConfirm: () => void;
/** 取消回调 */
onCancel: () => void;
}
export function DisableModelModal({
disableState,
disabling,
onConfirm,
onCancel,
}: DisableModelModalProps) {
const { t, i18n } = useTranslation();
const isZh = i18n.language === 'zh-CN' || i18n.language === 'zh';
// 获取警告内容
const getWarningContent = () => {
if (!disableState) return null;
if (disableState.step === 1) {
return (
<p style={{ marginBottom: 16, lineHeight: 1.6 }}>
{isZh ? '确定要禁用 ' : 'Are you sure you want to disable '}
<strong>{disableState.displayName}</strong>
{isZh ? ' 吗?' : '?'}
</p>
);
}
if (disableState.step === 2) {
return (
<p style={{ marginBottom: 16, lineHeight: 1.6, color: 'var(--warning-color, #f59e0b)' }}>
{isZh
? '⚠️ 警告:此操作将从配置中移除该模型映射!'
: '⚠️ Warning: this removes the model mapping from config!'}
</p>
);
}
return (
<p style={{ marginBottom: 16, lineHeight: 1.6, color: 'var(--danger-color, #ef4444)' }}>
{isZh
? '🚨 最后确认:禁用后需要手动重新添加才能恢复!'
: "🚨 Final confirmation: you'll need to add it back manually later!"}
</p>
);
};
// 获取确认按钮文本
const getConfirmButtonText = () => {
if (!disableState) return '';
const btnTexts = isZh
? ['确认禁用 (3)', '我确定 (2)', '立即禁用 (1)']
: ['Confirm (3)', "I'm sure (2)", 'Disable now (1)'];
return btnTexts[disableState.step - 1] || btnTexts[0];
};
return (
<Modal
open={!!disableState}
onClose={onCancel}
title={t('monitor.logs.disable_confirm_title')}
width={400}
>
<div style={{ padding: '16px 0' }}>
{getWarningContent()}
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
<Button
variant="secondary"
onClick={onCancel}
disabled={disabling}
>
{t('common.cancel')}
</Button>
<Button
variant="danger"
onClick={onConfirm}
disabled={disabling}
>
{disabling ? t('monitor.logs.disabling') : getConfirmButtonText()}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,420 @@
import { useMemo, useState, useCallback, Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { useDisableModel } from '@/hooks';
import { TimeRangeSelector, formatTimeRangeCaption, type TimeRange } from './TimeRangeSelector';
import { DisableModelModal } from './DisableModelModal';
import {
formatTimestamp,
getRateClassName,
filterDataByTimeRange,
getProviderDisplayParts,
type DateRange,
} from '@/utils/monitor';
import type { UsageData } from '@/pages/MonitorPage';
import styles from '@/pages/MonitorPage.module.scss';
interface FailureAnalysisProps {
data: UsageData | null;
loading: boolean;
providerMap: Record<string, string>;
providerModels: Record<string, Set<string>>;
}
interface ModelFailureStat {
success: number;
failure: number;
total: number;
successRate: number;
recentRequests: { failed: boolean; timestamp: number }[];
lastTimestamp: number;
}
interface FailureStat {
source: string;
displayName: string;
providerName: string | null;
maskedKey: string;
failedCount: number;
lastFailTime: number;
models: Record<string, ModelFailureStat>;
}
export function FailureAnalysis({ data, loading, providerMap, providerModels }: FailureAnalysisProps) {
const { t } = useTranslation();
const [expandedChannel, setExpandedChannel] = useState<string | null>(null);
const [filterChannel, setFilterChannel] = useState('');
const [filterModel, setFilterModel] = useState('');
// 时间范围状态
const [timeRange, setTimeRange] = useState<TimeRange>(7);
const [customRange, setCustomRange] = useState<DateRange | undefined>();
// 使用禁用模型 Hook
const {
disableState,
disabling,
isModelDisabled,
handleDisableClick: onDisableClick,
handleConfirmDisable,
handleCancelDisable,
} = useDisableModel({ providerMap, providerModels });
// 处理时间范围变化
const handleTimeRangeChange = useCallback((range: TimeRange, custom?: DateRange) => {
setTimeRange(range);
if (custom) {
setCustomRange(custom);
}
}, []);
// 根据时间范围过滤数据
const timeFilteredData = useMemo(() => {
return filterDataByTimeRange(data, timeRange, customRange);
}, [data, timeRange, customRange]);
// 计算失败统计数据
const failureStats = useMemo(() => {
if (!timeFilteredData?.apis) return [];
// 首先收集有失败记录的渠道
const failedSources = new Set<string>();
Object.values(timeFilteredData.apis).forEach((apiData) => {
Object.values(apiData.models).forEach((modelData) => {
modelData.details.forEach((detail) => {
if (detail.failed) {
const source = detail.source || 'unknown';
const { provider } = getProviderDisplayParts(source, providerMap);
if (provider) {
failedSources.add(source);
}
}
});
});
});
// 统计这些渠道的所有请求
const stats: Record<string, FailureStat> = {};
Object.values(timeFilteredData.apis).forEach((apiData) => {
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
modelData.details.forEach((detail) => {
const source = detail.source || 'unknown';
// 只统计有失败记录的渠道
if (!failedSources.has(source)) return;
const { provider, masked } = getProviderDisplayParts(source, providerMap);
const displayName = provider ? `${provider} (${masked})` : masked;
const timestamp = detail.timestamp ? new Date(detail.timestamp).getTime() : 0;
if (!stats[displayName]) {
stats[displayName] = {
source,
displayName,
providerName: provider,
maskedKey: masked,
failedCount: 0,
lastFailTime: 0,
models: {},
};
}
if (detail.failed) {
stats[displayName].failedCount++;
if (timestamp > stats[displayName].lastFailTime) {
stats[displayName].lastFailTime = timestamp;
}
}
// 按模型统计
if (!stats[displayName].models[modelName]) {
stats[displayName].models[modelName] = {
success: 0,
failure: 0,
total: 0,
successRate: 0,
recentRequests: [],
lastTimestamp: 0,
};
}
stats[displayName].models[modelName].total++;
if (detail.failed) {
stats[displayName].models[modelName].failure++;
} else {
stats[displayName].models[modelName].success++;
}
stats[displayName].models[modelName].recentRequests.push({ failed: detail.failed, timestamp });
if (timestamp > stats[displayName].models[modelName].lastTimestamp) {
stats[displayName].models[modelName].lastTimestamp = timestamp;
}
});
});
});
// 计算成功率并排序请求
Object.values(stats).forEach((stat) => {
Object.values(stat.models).forEach((model) => {
model.successRate = model.total > 0
? (model.success / model.total) * 100
: 0;
model.recentRequests.sort((a, b) => a.timestamp - b.timestamp);
model.recentRequests = model.recentRequests.slice(-12);
});
});
return Object.values(stats)
.filter((stat) => stat.failedCount > 0)
.sort((a, b) => b.failedCount - a.failedCount)
.slice(0, 10);
}, [timeFilteredData, providerMap]);
// 获取所有渠道和模型列表
const { channels, models } = useMemo(() => {
const channelSet = new Set<string>();
const modelSet = new Set<string>();
failureStats.forEach((stat) => {
channelSet.add(stat.displayName);
Object.keys(stat.models).forEach((model) => modelSet.add(model));
});
return {
channels: Array.from(channelSet).sort(),
models: Array.from(modelSet).sort(),
};
}, [failureStats]);
// 过滤后的数据
const filteredStats = useMemo(() => {
return failureStats.filter((stat) => {
if (filterChannel && stat.displayName !== filterChannel) return false;
if (filterModel && !stat.models[filterModel]) return false;
return true;
});
}, [failureStats, filterChannel, filterModel]);
// 切换展开状态
const toggleExpand = (displayName: string) => {
setExpandedChannel(expandedChannel === displayName ? null : displayName);
};
// 获取主要失败模型前2个已禁用的排在后面
const getTopFailedModels = (source: string, modelsMap: Record<string, ModelFailureStat>) => {
return Object.entries(modelsMap)
.filter(([, stat]) => stat.failure > 0)
.sort((a, b) => {
const aDisabled = isModelDisabled(source, a[0]);
const bDisabled = isModelDisabled(source, b[0]);
// 已禁用的排在后面
if (aDisabled !== bDisabled) {
return aDisabled ? 1 : -1;
}
// 然后按失败数降序
return b[1].failure - a[1].failure;
})
.slice(0, 2);
};
// 开始禁用流程(阻止事件冒泡)
const handleDisableClick = (source: string, model: string, e: React.MouseEvent) => {
e.stopPropagation();
onDisableClick(source, model);
};
return (
<>
<Card
title={t('monitor.failure.title')}
subtitle={
<span>
{formatTimeRangeCaption(timeRange, customRange, t)} · {t('monitor.failure.subtitle')}
<span style={{ color: 'var(--text-tertiary)' }}> · {t('monitor.failure.click_hint')}</span>
</span>
}
extra={
<TimeRangeSelector
value={timeRange}
onChange={handleTimeRangeChange}
customRange={customRange}
/>
}
>
{/* 筛选器 */}
<div className={styles.logFilters}>
<select
className={styles.logSelect}
value={filterChannel}
onChange={(e) => setFilterChannel(e.target.value)}
>
<option value="">{t('monitor.channel.all_channels')}</option>
{channels.map((channel) => (
<option key={channel} value={channel}>{channel}</option>
))}
</select>
<select
className={styles.logSelect}
value={filterModel}
onChange={(e) => setFilterModel(e.target.value)}
>
<option value="">{t('monitor.channel.all_models')}</option>
{models.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
</div>
{/* 表格 */}
<div className={styles.tableWrapper}>
{loading ? (
<div className={styles.emptyState}>{t('common.loading')}</div>
) : filteredStats.length === 0 ? (
<div className={styles.emptyState}>{t('monitor.failure.no_failures')}</div>
) : (
<table className={styles.table}>
<thead>
<tr>
<th>{t('monitor.failure.header_name')}</th>
<th>{t('monitor.failure.header_count')}</th>
<th>{t('monitor.failure.header_time')}</th>
<th>{t('monitor.failure.header_models')}</th>
</tr>
</thead>
<tbody>
{filteredStats.map((stat) => {
const topModels = getTopFailedModels(stat.source, stat.models);
const totalFailedModels = Object.values(stat.models).filter(m => m.failure > 0).length;
return (
<Fragment key={stat.displayName}>
<tr
className={styles.expandable}
onClick={() => toggleExpand(stat.displayName)}
>
<td>
{stat.providerName ? (
<>
<span className={styles.channelName}>{stat.providerName}</span>
<span className={styles.channelSecret}> ({stat.maskedKey})</span>
</>
) : (
stat.maskedKey
)}
</td>
<td className={styles.kpiFailure}>{stat.failedCount.toLocaleString()}</td>
<td>{formatTimestamp(stat.lastFailTime)}</td>
<td>
{topModels.map(([model, modelStat]) => {
const percent = ((modelStat.failure / stat.failedCount) * 100).toFixed(0);
const shortModel = model.length > 16 ? model.slice(0, 13) + '...' : model;
const disabled = isModelDisabled(stat.source, model);
return (
<span
key={model}
className={`${styles.failureModelTag} ${disabled ? styles.modelDisabled : ''}`}
title={`${model}: ${modelStat.failure} (${percent}%)${disabled ? ` - ${t('monitor.logs.removed')}` : ''}`}
>
{shortModel}
</span>
);
})}
{totalFailedModels > 2 && (
<span className={styles.moreModelsHint}>
+{totalFailedModels - 2}
</span>
)}
</td>
</tr>
{expandedChannel === stat.displayName && (
<tr key={`${stat.displayName}-detail`}>
<td colSpan={4} className={styles.expandDetail}>
<div className={styles.expandTableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>{t('monitor.channel.model')}</th>
<th>{t('monitor.channel.header_count')}</th>
<th>{t('monitor.channel.header_rate')}</th>
<th>{t('monitor.channel.success')}/{t('monitor.channel.failed')}</th>
<th>{t('monitor.channel.header_recent')}</th>
<th>{t('monitor.channel.header_time')}</th>
<th>{t('monitor.logs.header_actions')}</th>
</tr>
</thead>
<tbody>
{Object.entries(stat.models)
.filter(([, m]) => m.failure > 0)
.sort((a, b) => {
const aDisabled = isModelDisabled(stat.source, a[0]);
const bDisabled = isModelDisabled(stat.source, b[0]);
// 已禁用的排在后面
if (aDisabled !== bDisabled) {
return aDisabled ? 1 : -1;
}
// 然后按失败数降序
return b[1].failure - a[1].failure;
})
.map(([modelName, modelStat]) => {
const disabled = isModelDisabled(stat.source, modelName);
return (
<tr key={modelName} className={disabled ? styles.modelDisabled : ''}>
<td>{modelName}</td>
<td>{modelStat.total.toLocaleString()}</td>
<td className={getRateClassName(modelStat.successRate, styles)}>
{modelStat.successRate.toFixed(1)}%
</td>
<td>
<span className={styles.kpiSuccess}>{modelStat.success}</span>
{' / '}
<span className={styles.kpiFailure}>{modelStat.failure}</span>
</td>
<td>
<div className={styles.statusBars}>
{modelStat.recentRequests.map((req, i) => (
<div
key={i}
className={`${styles.statusBar} ${req.failed ? styles.failure : styles.success}`}
/>
))}
</div>
</td>
<td>{formatTimestamp(modelStat.lastTimestamp)}</td>
<td>
{disabled ? (
<span className={styles.disabledLabel}>{t('monitor.logs.removed')}</span>
) : stat.source && stat.source !== '-' && stat.source !== 'unknown' ? (
<button
className={styles.disableBtn}
onClick={(e) => handleDisableClick(stat.source, modelName, e)}
>
{t('monitor.logs.disable')}
</button>
) : '-'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
)}
</div>
</Card>
{/* 禁用确认弹窗 */}
<DisableModelModal
disableState={disableState}
disabling={disabling}
onConfirm={handleConfirmDisable}
onCancel={handleCancelDisable}
/>
</>
);
}

View File

@@ -0,0 +1,314 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Chart } from 'react-chartjs-2';
import type { UsageData } from '@/pages/MonitorPage';
import styles from '@/pages/MonitorPage.module.scss';
interface HourlyModelChartProps {
data: UsageData | null;
loading: boolean;
isDark: boolean;
}
// 颜色调色板
const COLORS = [
'rgba(59, 130, 246, 0.7)', // 蓝色
'rgba(34, 197, 94, 0.7)', // 绿色
'rgba(249, 115, 22, 0.7)', // 橙色
'rgba(139, 92, 246, 0.7)', // 紫色
'rgba(236, 72, 153, 0.7)', // 粉色
'rgba(6, 182, 212, 0.7)', // 青色
];
type HourRange = 6 | 12 | 24;
export function HourlyModelChart({ data, loading, isDark }: HourlyModelChartProps) {
const { t } = useTranslation();
const [hourRange, setHourRange] = useState<HourRange>(12);
// 按小时聚合数据
const hourlyData = useMemo(() => {
if (!data?.apis) return { hours: [], models: [], modelData: {} as Record<string, number[]>, successRates: [] };
const now = new Date();
let cutoffTime: Date;
let hoursCount: number;
cutoffTime = new Date(now.getTime() - hourRange * 60 * 60 * 1000);
cutoffTime.setMinutes(0, 0, 0);
hoursCount = hourRange + 1;
// 生成所有小时的时间点
const allHours: string[] = [];
for (let i = 0; i < hoursCount; i++) {
const hourTime = new Date(cutoffTime.getTime() + i * 60 * 60 * 1000);
const hourKey = hourTime.toISOString().slice(0, 13); // YYYY-MM-DDTHH
allHours.push(hourKey);
}
// 收集每小时每个模型的请求数
const hourlyStats: Record<string, Record<string, { success: number; failed: number }>> = {};
const modelSet = new Set<string>();
// 初始化所有小时
allHours.forEach((hour) => {
hourlyStats[hour] = {};
});
Object.values(data.apis).forEach((apiData) => {
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
modelSet.add(modelName);
modelData.details.forEach((detail) => {
const timestamp = new Date(detail.timestamp);
if (timestamp < cutoffTime) return;
const hourKey = timestamp.toISOString().slice(0, 13); // YYYY-MM-DDTHH
if (!hourlyStats[hourKey]) {
hourlyStats[hourKey] = {};
}
if (!hourlyStats[hourKey][modelName]) {
hourlyStats[hourKey][modelName] = { success: 0, failed: 0 };
}
if (detail.failed) {
hourlyStats[hourKey][modelName].failed++;
} else {
hourlyStats[hourKey][modelName].success++;
}
});
});
});
// 获取排序后的小时列表
const hours = allHours.sort();
// 计算每个模型的总请求数,取 Top 6
const modelTotals: Record<string, number> = {};
hours.forEach((hour) => {
Object.entries(hourlyStats[hour]).forEach(([model, stats]) => {
modelTotals[model] = (modelTotals[model] || 0) + stats.success + stats.failed;
});
});
const topModels = Object.entries(modelTotals)
.sort((a, b) => b[1] - a[1])
.slice(0, 6)
.map(([name]) => name);
// 构建每个模型的数据数组
const modelData: Record<string, number[]> = {};
topModels.forEach((model) => {
modelData[model] = hours.map((hour) => {
const stats = hourlyStats[hour][model];
return stats ? stats.success + stats.failed : 0;
});
});
// 计算每小时的成功率
const successRates = hours.map((hour) => {
let totalSuccess = 0;
let totalRequests = 0;
Object.values(hourlyStats[hour]).forEach((stats) => {
totalSuccess += stats.success;
totalRequests += stats.success + stats.failed;
});
return totalRequests > 0 ? (totalSuccess / totalRequests) * 100 : 0;
});
return { hours, models: topModels, modelData, successRates };
}, [data, hourRange]);
// 获取时间范围标签
const hourRangeLabel = useMemo(() => {
if (hourRange === 6) return t('monitor.hourly.last_6h');
if (hourRange === 12) return t('monitor.hourly.last_12h');
return t('monitor.hourly.last_24h');
}, [hourRange, t]);
// 图表数据
const chartData = useMemo(() => {
const labels = hourlyData.hours.map((hour) => {
const date = new Date(hour + ':00:00Z'); // 添加 Z 表示 UTC 时间,确保正确转换为本地时间显示
return `${date.getHours()}:00`;
});
// 成功率折线放在最前面
const datasets: any[] = [{
type: 'line' as const,
label: t('monitor.hourly.success_rate'),
data: hourlyData.successRates,
borderColor: '#4ef0c3',
backgroundColor: '#4ef0c3',
borderWidth: 2.5,
tension: 0.4,
yAxisID: 'y1',
stack: '',
pointRadius: 3,
pointBackgroundColor: '#4ef0c3',
pointBorderColor: '#4ef0c3',
}];
// 添加模型柱状图
hourlyData.models.forEach((model, index) => {
datasets.push({
type: 'bar' as const,
label: model,
data: hourlyData.modelData[model],
backgroundColor: COLORS[index % COLORS.length],
borderColor: COLORS[index % COLORS.length],
borderWidth: 1,
borderRadius: 4,
stack: 'models',
yAxisID: 'y',
});
});
return { labels, datasets };
}, [hourlyData, t]);
// 图表配置
const chartOptions = useMemo(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index' as const,
intersect: false,
},
plugins: {
legend: {
display: true,
position: 'bottom' as const,
labels: {
color: isDark ? '#9ca3af' : '#6b7280',
usePointStyle: true,
padding: 12,
font: {
size: 11,
},
generateLabels: (chart: any) => {
return chart.data.datasets.map((dataset: any, i: number) => {
const isLine = dataset.type === 'line';
return {
text: dataset.label,
fillStyle: dataset.backgroundColor,
strokeStyle: dataset.borderColor,
lineWidth: 0,
hidden: !chart.isDatasetVisible(i),
datasetIndex: i,
pointStyle: isLine ? 'circle' : 'rect',
};
});
},
},
},
tooltip: {
backgroundColor: isDark ? '#374151' : '#ffffff',
titleColor: isDark ? '#f3f4f6' : '#111827',
bodyColor: isDark ? '#d1d5db' : '#4b5563',
borderColor: isDark ? '#4b5563' : '#e5e7eb',
borderWidth: 1,
padding: 12,
},
},
scales: {
x: {
stacked: true,
grid: {
color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
},
ticks: {
color: isDark ? '#9ca3af' : '#6b7280',
font: {
size: 11,
},
},
},
y: {
stacked: true,
position: 'left' as const,
grid: {
color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
},
ticks: {
color: isDark ? '#9ca3af' : '#6b7280',
font: {
size: 11,
},
},
title: {
display: true,
text: t('monitor.hourly.requests'),
color: isDark ? '#9ca3af' : '#6b7280',
font: {
size: 11,
},
},
},
y1: {
position: 'right' as const,
min: 0,
max: 100,
grid: {
drawOnChartArea: false,
},
ticks: {
color: isDark ? '#9ca3af' : '#6b7280',
font: {
size: 11,
},
callback: (value: string | number) => `${value}%`,
},
title: {
display: true,
text: t('monitor.hourly.success_rate'),
color: isDark ? '#9ca3af' : '#6b7280',
font: {
size: 11,
},
},
},
},
}), [isDark, t]);
return (
<div className={styles.chartCard}>
<div className={styles.chartHeader}>
<div>
<h3 className={styles.chartTitle}>{t('monitor.hourly_model.title')}</h3>
<p className={styles.chartSubtitle}>
{hourRangeLabel}
</p>
</div>
<div className={styles.chartControls}>
<button
className={`${styles.chartControlBtn} ${hourRange === 6 ? styles.active : ''}`}
onClick={() => setHourRange(6)}
>
{t('monitor.hourly.last_6h')}
</button>
<button
className={`${styles.chartControlBtn} ${hourRange === 12 ? styles.active : ''}`}
onClick={() => setHourRange(12)}
>
{t('monitor.hourly.last_12h')}
</button>
<button
className={`${styles.chartControlBtn} ${hourRange === 24 ? styles.active : ''}`}
onClick={() => setHourRange(24)}
>
{t('monitor.hourly.last_24h')}
</button>
</div>
</div>
<div className={styles.chartContent}>
{loading || hourlyData.hours.length === 0 ? (
<div className={styles.chartEmpty}>
{loading ? t('common.loading') : t('monitor.no_data')}
</div>
) : (
<Chart type="bar" data={chartData} options={chartOptions} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,274 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Chart } from 'react-chartjs-2';
import type { UsageData } from '@/pages/MonitorPage';
import styles from '@/pages/MonitorPage.module.scss';
interface HourlyTokenChartProps {
data: UsageData | null;
loading: boolean;
isDark: boolean;
}
type HourRange = 6 | 12 | 24;
export function HourlyTokenChart({ data, loading, isDark }: HourlyTokenChartProps) {
const { t } = useTranslation();
const [hourRange, setHourRange] = useState<HourRange>(12);
// 按小时聚合 Token 数据
const hourlyData = useMemo(() => {
if (!data?.apis) return { hours: [], totalTokens: [], inputTokens: [], outputTokens: [], reasoningTokens: [], cachedTokens: [] };
const now = new Date();
let cutoffTime: Date;
let hoursCount: number;
cutoffTime = new Date(now.getTime() - hourRange * 60 * 60 * 1000);
cutoffTime.setMinutes(0, 0, 0);
hoursCount = hourRange + 1;
// 生成所有小时的时间点
const allHours: string[] = [];
for (let i = 0; i < hoursCount; i++) {
const hourTime = new Date(cutoffTime.getTime() + i * 60 * 60 * 1000);
const hourKey = hourTime.toISOString().slice(0, 13); // YYYY-MM-DDTHH
allHours.push(hourKey);
}
// 初始化所有小时的数据为0
const hourlyStats: Record<string, {
total: number;
input: number;
output: number;
reasoning: number;
cached: number;
}> = {};
allHours.forEach((hour) => {
hourlyStats[hour] = { total: 0, input: 0, output: 0, reasoning: 0, cached: 0 };
});
// 收集每小时的 Token 数据(只统计成功请求)
Object.values(data.apis).forEach((apiData) => {
Object.values(apiData.models).forEach((modelData) => {
modelData.details.forEach((detail) => {
// 跳过失败请求,失败请求的 Token 数据不准确
if (detail.failed) return;
const timestamp = new Date(detail.timestamp);
if (timestamp < cutoffTime) return;
const hourKey = timestamp.toISOString().slice(0, 13); // YYYY-MM-DDTHH
if (!hourlyStats[hourKey]) {
hourlyStats[hourKey] = { total: 0, input: 0, output: 0, reasoning: 0, cached: 0 };
}
hourlyStats[hourKey].total += detail.tokens.total_tokens || 0;
hourlyStats[hourKey].input += detail.tokens.input_tokens || 0;
hourlyStats[hourKey].output += detail.tokens.output_tokens || 0;
hourlyStats[hourKey].reasoning += detail.tokens.reasoning_tokens || 0;
hourlyStats[hourKey].cached += detail.tokens.cached_tokens || 0;
});
});
});
// 获取排序后的小时列表
const hours = allHours.sort();
return {
hours,
totalTokens: hours.map((h) => (hourlyStats[h]?.total || 0) / 1000),
inputTokens: hours.map((h) => (hourlyStats[h]?.input || 0) / 1000),
outputTokens: hours.map((h) => (hourlyStats[h]?.output || 0) / 1000),
reasoningTokens: hours.map((h) => (hourlyStats[h]?.reasoning || 0) / 1000),
cachedTokens: hours.map((h) => (hourlyStats[h]?.cached || 0) / 1000),
};
}, [data, hourRange]);
// 获取时间范围标签
const hourRangeLabel = useMemo(() => {
if (hourRange === 6) return t('monitor.hourly.last_6h');
if (hourRange === 12) return t('monitor.hourly.last_12h');
return t('monitor.hourly.last_24h');
}, [hourRange, t]);
// 图表数据
const chartData = useMemo(() => {
const labels = hourlyData.hours.map((hour) => {
const date = new Date(hour + ':00:00Z'); // 添加 Z 表示 UTC 时间,确保正确转换为本地时间显示
return `${date.getHours()}:00`;
});
return {
labels,
datasets: [
{
type: 'line' as const,
label: t('monitor.hourly_token.input'),
data: hourlyData.inputTokens,
borderColor: '#22c55e',
backgroundColor: '#22c55e',
borderWidth: 2,
tension: 0.4,
yAxisID: 'y',
order: 0,
pointRadius: 3,
pointBackgroundColor: '#22c55e',
},
{
type: 'line' as const,
label: t('monitor.hourly_token.output'),
data: hourlyData.outputTokens,
borderColor: '#f97316',
backgroundColor: '#f97316',
borderWidth: 2,
tension: 0.4,
yAxisID: 'y',
order: 0,
pointRadius: 3,
pointBackgroundColor: '#f97316',
},
{
type: 'bar' as const,
label: t('monitor.hourly_token.total'),
data: hourlyData.totalTokens,
backgroundColor: 'rgba(59, 130, 246, 0.6)',
borderColor: 'rgba(59, 130, 246, 0.6)',
borderWidth: 1,
borderRadius: 4,
yAxisID: 'y',
order: 1,
},
],
};
}, [hourlyData, t]);
// 图表配置
const chartOptions = useMemo(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index' as const,
intersect: false,
},
plugins: {
legend: {
display: true,
position: 'bottom' as const,
labels: {
color: isDark ? '#9ca3af' : '#6b7280',
usePointStyle: true,
padding: 12,
font: {
size: 11,
},
generateLabels: (chart: any) => {
return chart.data.datasets.map((dataset: any, i: number) => {
const isLine = dataset.type === 'line';
return {
text: dataset.label,
fillStyle: dataset.backgroundColor,
strokeStyle: dataset.borderColor,
lineWidth: 0,
hidden: !chart.isDatasetVisible(i),
datasetIndex: i,
pointStyle: isLine ? 'circle' : 'rect',
};
});
},
},
},
tooltip: {
backgroundColor: isDark ? '#374151' : '#ffffff',
titleColor: isDark ? '#f3f4f6' : '#111827',
bodyColor: isDark ? '#d1d5db' : '#4b5563',
borderColor: isDark ? '#4b5563' : '#e5e7eb',
borderWidth: 1,
padding: 12,
callbacks: {
label: (context: any) => {
const label = context.dataset.label || '';
const value = context.raw;
return `${label}: ${value.toFixed(1)}K`;
},
},
},
},
scales: {
x: {
grid: {
color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
},
ticks: {
color: isDark ? '#9ca3af' : '#6b7280',
font: {
size: 11,
},
},
},
y: {
position: 'left' as const,
grid: {
color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
},
ticks: {
color: isDark ? '#9ca3af' : '#6b7280',
font: {
size: 11,
},
callback: (value: string | number) => `${value}K`,
},
title: {
display: true,
text: 'Tokens (K)',
color: isDark ? '#9ca3af' : '#6b7280',
font: {
size: 11,
},
},
},
},
}), [isDark]);
return (
<div className={styles.chartCard}>
<div className={styles.chartHeader}>
<div>
<h3 className={styles.chartTitle}>{t('monitor.hourly_token.title')}</h3>
<p className={styles.chartSubtitle}>
{hourRangeLabel}
</p>
</div>
<div className={styles.chartControls}>
<button
className={`${styles.chartControlBtn} ${hourRange === 6 ? styles.active : ''}`}
onClick={() => setHourRange(6)}
>
{t('monitor.hourly.last_6h')}
</button>
<button
className={`${styles.chartControlBtn} ${hourRange === 12 ? styles.active : ''}`}
onClick={() => setHourRange(12)}
>
{t('monitor.hourly.last_12h')}
</button>
<button
className={`${styles.chartControlBtn} ${hourRange === 24 ? styles.active : ''}`}
onClick={() => setHourRange(24)}
>
{t('monitor.hourly.last_24h')}
</button>
</div>
</div>
<div className={styles.chartContent}>
{loading || hourlyData.hours.length === 0 ? (
<div className={styles.chartEmpty}>
{loading ? t('common.loading') : t('monitor.no_data')}
</div>
) : (
<Chart type="bar" data={chartData} options={chartOptions} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,201 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { UsageData } from '@/pages/MonitorPage';
import styles from '@/pages/MonitorPage.module.scss';
interface KpiCardsProps {
data: UsageData | null;
loading: boolean;
timeRange: number;
}
// 格式化数字
function formatNumber(num: number): string {
if (num >= 1000000000) {
return (num / 1000000000).toFixed(2) + 'B';
}
if (num >= 1000000) {
return (num / 1000000).toFixed(2) + 'M';
}
if (num >= 1000) {
return (num / 1000).toFixed(2) + 'K';
}
return num.toLocaleString();
}
export function KpiCards({ data, loading, timeRange }: KpiCardsProps) {
const { t } = useTranslation();
// 计算统计数据
const stats = useMemo(() => {
if (!data?.apis) {
return {
totalRequests: 0,
successRequests: 0,
failedRequests: 0,
successRate: 0,
totalTokens: 0,
inputTokens: 0,
outputTokens: 0,
reasoningTokens: 0,
cachedTokens: 0,
avgTpm: 0,
avgRpm: 0,
avgRpd: 0,
};
}
let totalRequests = 0;
let successRequests = 0;
let failedRequests = 0;
let totalTokens = 0;
let inputTokens = 0;
let outputTokens = 0;
let reasoningTokens = 0;
let cachedTokens = 0;
// 收集所有时间戳用于计算 TPM/RPM
const timestamps: number[] = [];
Object.values(data.apis).forEach((apiData) => {
Object.values(apiData.models).forEach((modelData) => {
modelData.details.forEach((detail) => {
totalRequests++;
if (detail.failed) {
failedRequests++;
} else {
successRequests++;
}
totalTokens += detail.tokens.total_tokens || 0;
inputTokens += detail.tokens.input_tokens || 0;
outputTokens += detail.tokens.output_tokens || 0;
reasoningTokens += detail.tokens.reasoning_tokens || 0;
cachedTokens += detail.tokens.cached_tokens || 0;
timestamps.push(new Date(detail.timestamp).getTime());
});
});
});
const successRate = totalRequests > 0 ? (successRequests / totalRequests) * 100 : 0;
// 计算 TPM 和 RPM基于实际时间跨度
let avgTpm = 0;
let avgRpm = 0;
let avgRpd = 0;
if (timestamps.length > 0) {
const minTime = Math.min(...timestamps);
const maxTime = Math.max(...timestamps);
const timeSpanMinutes = Math.max((maxTime - minTime) / (1000 * 60), 1);
const timeSpanDays = Math.max(timeSpanMinutes / (60 * 24), 1);
avgTpm = Math.round(totalTokens / timeSpanMinutes);
avgRpm = Math.round(totalRequests / timeSpanMinutes * 10) / 10;
avgRpd = Math.round(totalRequests / timeSpanDays);
}
return {
totalRequests,
successRequests,
failedRequests,
successRate,
totalTokens,
inputTokens,
outputTokens,
reasoningTokens,
cachedTokens,
avgTpm,
avgRpm,
avgRpd,
};
}, [data]);
const timeRangeLabel = timeRange === 1
? t('monitor.today')
: t('monitor.last_n_days', { n: timeRange });
return (
<div className={styles.kpiGrid}>
{/* 请求数 */}
<div className={styles.kpiCard}>
<div className={styles.kpiTitle}>
<span className={styles.kpiLabel}>{t('monitor.kpi.requests')}</span>
<span className={styles.kpiTag}>{timeRangeLabel}</span>
</div>
<div className={styles.kpiValue}>
{loading ? '--' : formatNumber(stats.totalRequests)}
</div>
<div className={styles.kpiMeta}>
<span className={styles.kpiSuccess}>
{t('monitor.kpi.success')}: {loading ? '--' : stats.successRequests.toLocaleString()}
</span>
<span className={styles.kpiFailure}>
{t('monitor.kpi.failed')}: {loading ? '--' : stats.failedRequests.toLocaleString()}
</span>
<span>
{t('monitor.kpi.rate')}: {loading ? '--' : stats.successRate.toFixed(1)}%
</span>
</div>
</div>
{/* Tokens */}
<div className={`${styles.kpiCard} ${styles.green}`}>
<div className={styles.kpiTitle}>
<span className={styles.kpiLabel}>{t('monitor.kpi.tokens')}</span>
<span className={styles.kpiTag}>{timeRangeLabel}</span>
</div>
<div className={styles.kpiValue}>
{loading ? '--' : formatNumber(stats.totalTokens)}
</div>
<div className={styles.kpiMeta}>
<span>{t('monitor.kpi.input')}: {loading ? '--' : formatNumber(stats.inputTokens)}</span>
<span>{t('monitor.kpi.output')}: {loading ? '--' : formatNumber(stats.outputTokens)}</span>
</div>
</div>
{/* 平均 TPM */}
<div className={`${styles.kpiCard} ${styles.purple}`}>
<div className={styles.kpiTitle}>
<span className={styles.kpiLabel}>{t('monitor.kpi.avg_tpm')}</span>
<span className={styles.kpiTag}>{timeRangeLabel}</span>
</div>
<div className={styles.kpiValue}>
{loading ? '--' : formatNumber(stats.avgTpm)}
</div>
<div className={styles.kpiMeta}>
<span>{t('monitor.kpi.tokens_per_minute')}</span>
</div>
</div>
{/* 平均 RPM */}
<div className={`${styles.kpiCard} ${styles.orange}`}>
<div className={styles.kpiTitle}>
<span className={styles.kpiLabel}>{t('monitor.kpi.avg_rpm')}</span>
<span className={styles.kpiTag}>{timeRangeLabel}</span>
</div>
<div className={styles.kpiValue}>
{loading ? '--' : stats.avgRpm.toFixed(1)}
</div>
<div className={styles.kpiMeta}>
<span>{t('monitor.kpi.requests_per_minute')}</span>
</div>
</div>
{/* 日均 RPD */}
<div className={`${styles.kpiCard} ${styles.cyan}`}>
<div className={styles.kpiTitle}>
<span className={styles.kpiLabel}>{t('monitor.kpi.avg_rpd')}</span>
<span className={styles.kpiTag}>{timeRangeLabel}</span>
</div>
<div className={styles.kpiValue}>
{loading ? '--' : formatNumber(stats.avgRpd)}
</div>
<div className={styles.kpiMeta}>
<span>{t('monitor.kpi.requests_per_day')}</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,205 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Doughnut } from 'react-chartjs-2';
import type { UsageData } from '@/pages/MonitorPage';
import styles from '@/pages/MonitorPage.module.scss';
interface ModelDistributionChartProps {
data: UsageData | null;
loading: boolean;
isDark: boolean;
timeRange: number;
}
// 颜色调色板
const COLORS = [
'#3b82f6', // 蓝色
'#22c55e', // 绿色
'#f97316', // 橙色
'#8b5cf6', // 紫色
'#ec4899', // 粉色
'#06b6d4', // 青色
'#eab308', // 黄色
'#ef4444', // 红色
'#14b8a6', // 青绿
'#6366f1', // 靛蓝
];
type ViewMode = 'request' | 'token';
export function ModelDistributionChart({ data, loading, isDark, timeRange }: ModelDistributionChartProps) {
const { t } = useTranslation();
const [viewMode, setViewMode] = useState<ViewMode>('request');
const timeRangeLabel = timeRange === 1
? t('monitor.today')
: t('monitor.last_n_days', { n: timeRange });
// 计算模型分布数据
const distributionData = useMemo(() => {
if (!data?.apis) return [];
const modelStats: Record<string, { requests: number; tokens: number }> = {};
Object.values(data.apis).forEach((apiData) => {
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
if (!modelStats[modelName]) {
modelStats[modelName] = { requests: 0, tokens: 0 };
}
modelData.details.forEach((detail) => {
modelStats[modelName].requests++;
modelStats[modelName].tokens += detail.tokens.total_tokens || 0;
});
});
});
// 转换为数组并排序
const sorted = Object.entries(modelStats)
.map(([name, stats]) => ({
name,
requests: stats.requests,
tokens: stats.tokens,
}))
.sort((a, b) => {
if (viewMode === 'request') {
return b.requests - a.requests;
}
return b.tokens - a.tokens;
});
// 取 Top 10
return sorted.slice(0, 10);
}, [data, viewMode]);
// 计算总数
const total = useMemo(() => {
return distributionData.reduce((sum, item) => {
return sum + (viewMode === 'request' ? item.requests : item.tokens);
}, 0);
}, [distributionData, viewMode]);
// 图表数据
const chartData = useMemo(() => {
return {
labels: distributionData.map((item) => item.name),
datasets: [
{
data: distributionData.map((item) =>
viewMode === 'request' ? item.requests : item.tokens
),
backgroundColor: COLORS.slice(0, distributionData.length),
borderColor: isDark ? '#1f2937' : '#ffffff',
borderWidth: 2,
},
],
};
}, [distributionData, viewMode, isDark]);
// 图表配置
const chartOptions = useMemo(() => ({
responsive: true,
maintainAspectRatio: false,
cutout: '65%',
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: isDark ? '#374151' : '#ffffff',
titleColor: isDark ? '#f3f4f6' : '#111827',
bodyColor: isDark ? '#d1d5db' : '#4b5563',
borderColor: isDark ? '#4b5563' : '#e5e7eb',
borderWidth: 1,
padding: 12,
callbacks: {
label: (context: any) => {
const value = context.raw;
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : 0;
if (viewMode === 'request') {
return `${value.toLocaleString()} ${t('monitor.requests')} (${percentage}%)`;
}
return `${value.toLocaleString()} tokens (${percentage}%)`;
},
},
},
},
}), [isDark, total, viewMode, t]);
// 格式化数值
const formatValue = (value: number) => {
if (value >= 1000000) {
return (value / 1000000).toFixed(1) + 'M';
}
if (value >= 1000) {
return (value / 1000).toFixed(1) + 'K';
}
return value.toString();
};
return (
<div className={styles.chartCard}>
<div className={styles.chartHeader}>
<div>
<h3 className={styles.chartTitle}>{t('monitor.distribution.title')}</h3>
<p className={styles.chartSubtitle}>
{timeRangeLabel} · {viewMode === 'request' ? t('monitor.distribution.by_requests') : t('monitor.distribution.by_tokens')}
{' · Top 10'}
</p>
</div>
<div className={styles.chartControls}>
<button
className={`${styles.chartControlBtn} ${viewMode === 'request' ? styles.active : ''}`}
onClick={() => setViewMode('request')}
>
{t('monitor.distribution.requests')}
</button>
<button
className={`${styles.chartControlBtn} ${viewMode === 'token' ? styles.active : ''}`}
onClick={() => setViewMode('token')}
>
{t('monitor.distribution.tokens')}
</button>
</div>
</div>
{loading || distributionData.length === 0 ? (
<div className={styles.chartContent}>
<div className={styles.chartEmpty}>
{loading ? t('common.loading') : t('monitor.no_data')}
</div>
</div>
) : (
<div className={styles.distributionContent}>
<div className={styles.donutWrapper}>
<Doughnut data={chartData} options={chartOptions} />
<div className={styles.donutCenter}>
<div className={styles.donutLabel}>
{viewMode === 'request' ? t('monitor.distribution.request_share') : t('monitor.distribution.token_share')}
</div>
</div>
</div>
<div className={styles.legendList}>
{distributionData.map((item, index) => {
const value = viewMode === 'request' ? item.requests : item.tokens;
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0';
return (
<div key={item.name} className={styles.legendItem}>
<span
className={styles.legendDot}
style={{ backgroundColor: COLORS[index] }}
/>
<span className={styles.legendName} title={item.name}>
{item.name}
</span>
<span className={styles.legendValue}>
{formatValue(value)} ({percentage}%)
</span>
</div>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,672 @@
import { useMemo, useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Card } from '@/components/ui/Card';
import { usageApi } from '@/services/api';
import { useDisableModel } from '@/hooks';
import { TimeRangeSelector, formatTimeRangeCaption, type TimeRange } from './TimeRangeSelector';
import { DisableModelModal } from './DisableModelModal';
import { UnsupportedDisableModal } from './UnsupportedDisableModal';
import {
maskSecret,
formatProviderDisplay,
formatTimestamp,
getRateClassName,
getProviderDisplayParts,
type DateRange,
} from '@/utils/monitor';
import type { UsageData } from '@/pages/MonitorPage';
import styles from '@/pages/MonitorPage.module.scss';
interface RequestLogsProps {
data: UsageData | null;
loading: boolean;
providerMap: Record<string, string>;
providerTypeMap: Record<string, string>;
apiFilter: string;
}
interface LogEntry {
id: string;
timestamp: string;
timestampMs: number;
apiKey: string;
model: string;
source: string;
displayName: string;
providerName: string | null;
providerType: string;
maskedKey: string;
failed: boolean;
inputTokens: number;
outputTokens: number;
totalTokens: number;
}
interface ChannelModelRequest {
failed: boolean;
timestamp: number;
}
// 预计算的统计数据缓存
interface PrecomputedStats {
recentRequests: ChannelModelRequest[];
successRate: string;
totalCount: number;
}
// 虚拟滚动行高
const ROW_HEIGHT = 40;
export function RequestLogs({ data, loading: parentLoading, providerMap, providerTypeMap, apiFilter }: RequestLogsProps) {
const { t } = useTranslation();
const [filterApi, setFilterApi] = useState('');
const [filterModel, setFilterModel] = useState('');
const [filterSource, setFilterSource] = useState('');
const [filterStatus, setFilterStatus] = useState<'' | 'success' | 'failed'>('');
const [filterProviderType, setFilterProviderType] = useState('');
const [autoRefresh, setAutoRefresh] = useState(10);
const [countdown, setCountdown] = useState(0);
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
// 用 ref 存储 fetchLogData避免作为定时器 useEffect 的依赖
const fetchLogDataRef = useRef<() => Promise<void>>(() => Promise.resolve());
// 虚拟滚动容器 ref
const tableContainerRef = useRef<HTMLDivElement>(null);
// 固定表头容器 ref
const headerRef = useRef<HTMLDivElement>(null);
// 同步表头和内容的水平滚动
const handleScroll = useCallback(() => {
if (tableContainerRef.current && headerRef.current) {
headerRef.current.scrollLeft = tableContainerRef.current.scrollLeft;
}
}, []);
// 时间范围状态
const [timeRange, setTimeRange] = useState<TimeRange>(7);
const [customRange, setCustomRange] = useState<DateRange | undefined>();
// 日志独立数据状态
const [logData, setLogData] = useState<UsageData | null>(null);
const [logLoading, setLogLoading] = useState(false);
const [isFirstLoad, setIsFirstLoad] = useState(true);
// 使用禁用模型 Hook
const {
disableState,
unsupportedState,
disabling,
isModelDisabled,
handleDisableClick,
handleConfirmDisable,
handleCancelDisable,
handleCloseUnsupported,
} = useDisableModel({ providerMap, providerTypeMap });
// 处理时间范围变化
const handleTimeRangeChange = useCallback((range: TimeRange, custom?: DateRange) => {
setTimeRange(range);
if (custom) {
setCustomRange(custom);
}
}, []);
// 使用日志独立数据或父组件数据
const effectiveData = logData || data;
// 只在首次加载且没有数据时显示 loading 状态
const showLoading = (parentLoading && isFirstLoad && !effectiveData) || (logLoading && !effectiveData);
// 当父组件数据加载完成时,标记首次加载完成
useEffect(() => {
if (!parentLoading && data) {
setIsFirstLoad(false);
}
}, [parentLoading, data]);
// 独立获取日志数据
const fetchLogData = useCallback(async () => {
setLogLoading(true);
try {
const response = await usageApi.getUsage();
const usageData = response?.usage ?? response;
// 应用时间范围过滤
if (usageData?.apis) {
const apis = usageData.apis as UsageData['apis'];
const now = new Date();
let cutoffStart: Date;
let cutoffEnd: Date = new Date(now.getTime());
cutoffEnd.setHours(23, 59, 59, 999);
if (timeRange === 'custom' && customRange) {
cutoffStart = customRange.start;
cutoffEnd = customRange.end;
} else if (typeof timeRange === 'number') {
cutoffStart = new Date(now.getTime() - timeRange * 24 * 60 * 60 * 1000);
cutoffStart.setHours(0, 0, 0, 0);
} else {
cutoffStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
cutoffStart.setHours(0, 0, 0, 0);
}
const filtered: UsageData = { apis: {} };
Object.entries(apis).forEach(([apiKey, apiData]) => {
// 如果有 API 过滤器,检查是否匹配
if (apiFilter && !apiKey.toLowerCase().includes(apiFilter.toLowerCase())) {
return;
}
if (!apiData?.models) return;
const filteredModels: Record<string, { details: UsageData['apis'][string]['models'][string]['details'] }> = {};
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
if (!modelData?.details || !Array.isArray(modelData.details)) return;
const filteredDetails = modelData.details.filter((detail) => {
const timestamp = new Date(detail.timestamp);
return timestamp >= cutoffStart && timestamp <= cutoffEnd;
});
if (filteredDetails.length > 0) {
filteredModels[modelName] = { details: filteredDetails };
}
});
if (Object.keys(filteredModels).length > 0) {
filtered.apis[apiKey] = { models: filteredModels };
}
});
setLogData(filtered);
}
} catch (err) {
console.error('日志刷新失败:', err);
} finally {
setLogLoading(false);
}
}, [timeRange, customRange, apiFilter]);
// 同步 fetchLogData 到 ref确保定时器始终调用最新版本
useEffect(() => {
fetchLogDataRef.current = fetchLogData;
}, [fetchLogData]);
// 统一的自动刷新定时器管理
useEffect(() => {
// 清理旧定时器
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
// 禁用自动刷新时
if (autoRefresh <= 0) {
setCountdown(0);
return;
}
// 设置初始倒计时
setCountdown(autoRefresh);
// 创建新定时器
countdownRef.current = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
// 倒计时结束,触发刷新并重置倒计时
fetchLogDataRef.current();
return autoRefresh;
}
return prev - 1;
});
}, 1000);
// 组件卸载或 autoRefresh 变化时清理
return () => {
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
};
}, [autoRefresh]);
// 时间范围变化时立即刷新数据
useEffect(() => {
fetchLogData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeRange, customRange]);
// 获取倒计时显示文本
const getCountdownText = () => {
if (logLoading) {
return t('monitor.logs.refreshing');
}
if (autoRefresh === 0) {
return t('monitor.logs.manual_refresh');
}
if (countdown > 0) {
return t('monitor.logs.refresh_in_seconds', { seconds: countdown });
}
return t('monitor.logs.refreshing');
};
// 将数据转换为日志条目
const logEntries = useMemo(() => {
if (!effectiveData?.apis) return [];
const entries: LogEntry[] = [];
let idCounter = 0;
Object.entries(effectiveData.apis).forEach(([apiKey, apiData]) => {
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
modelData.details.forEach((detail) => {
const source = detail.source || 'unknown';
const { provider, masked } = getProviderDisplayParts(source, providerMap);
const displayName = provider ? `${provider} (${masked})` : masked;
const timestampMs = detail.timestamp ? new Date(detail.timestamp).getTime() : 0;
// 获取提供商类型
const providerType = providerTypeMap[source] || '--';
entries.push({
id: `${idCounter++}`,
timestamp: detail.timestamp,
timestampMs,
apiKey,
model: modelName,
source,
displayName,
providerName: provider,
providerType,
maskedKey: masked,
failed: detail.failed,
inputTokens: detail.tokens.input_tokens || 0,
outputTokens: detail.tokens.output_tokens || 0,
totalTokens: detail.tokens.total_tokens || 0,
});
});
});
});
// 按时间倒序排序
return entries.sort((a, b) => b.timestampMs - a.timestampMs);
}, [effectiveData, providerMap, providerTypeMap]);
// 预计算所有条目的统计数据(一次性计算,避免渲染时重复计算)
const precomputedStats = useMemo(() => {
const statsMap = new Map<string, PrecomputedStats>();
// 首先按渠道+模型分组,并按时间排序
const channelModelGroups: Record<string, { entry: LogEntry; index: number }[]> = {};
logEntries.forEach((entry, index) => {
const key = `${entry.source}|||${entry.model}`;
if (!channelModelGroups[key]) {
channelModelGroups[key] = [];
}
channelModelGroups[key].push({ entry, index });
});
// 对每个分组按时间正序排序(用于计算累计统计)
Object.values(channelModelGroups).forEach((group) => {
group.sort((a, b) => a.entry.timestampMs - b.entry.timestampMs);
});
// 计算每个条目的统计数据
Object.entries(channelModelGroups).forEach(([, group]) => {
let successCount = 0;
let totalCount = 0;
const recentRequests: ChannelModelRequest[] = [];
group.forEach(({ entry }) => {
totalCount++;
if (!entry.failed) {
successCount++;
}
// 维护最近 10 次请求
recentRequests.push({ failed: entry.failed, timestamp: entry.timestampMs });
if (recentRequests.length > 10) {
recentRequests.shift();
}
// 计算成功率
const successRate = totalCount > 0 ? ((successCount / totalCount) * 100).toFixed(1) : '0.0';
// 存储该条目的统计数据
statsMap.set(entry.id, {
recentRequests: [...recentRequests],
successRate,
totalCount,
});
});
});
return statsMap;
}, [logEntries]);
// 获取筛选选项
const { apis, models, sources, providerTypes } = useMemo(() => {
const apiSet = new Set<string>();
const modelSet = new Set<string>();
const sourceSet = new Set<string>();
const providerTypeSet = new Set<string>();
logEntries.forEach((entry) => {
apiSet.add(entry.apiKey);
modelSet.add(entry.model);
sourceSet.add(entry.source);
if (entry.providerType && entry.providerType !== '--') {
providerTypeSet.add(entry.providerType);
}
});
return {
apis: Array.from(apiSet).sort(),
models: Array.from(modelSet).sort(),
sources: Array.from(sourceSet).sort(),
providerTypes: Array.from(providerTypeSet).sort(),
};
}, [logEntries]);
// 过滤后的数据
const filteredEntries = useMemo(() => {
return logEntries.filter((entry) => {
if (filterApi && entry.apiKey !== filterApi) return false;
if (filterModel && entry.model !== filterModel) return false;
if (filterSource && entry.source !== filterSource) return false;
if (filterStatus === 'success' && entry.failed) return false;
if (filterStatus === 'failed' && !entry.failed) return false;
if (filterProviderType && entry.providerType !== filterProviderType) return false;
return true;
});
}, [logEntries, filterApi, filterModel, filterSource, filterStatus, filterProviderType]);
// 虚拟滚动配置
const rowVirtualizer = useVirtualizer({
count: filteredEntries.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 10, // 预渲染上下各 10 行
});
// 格式化数字
const formatNumber = (num: number) => {
return num.toLocaleString('zh-CN');
};
// 获取预计算的统计数据
const getStats = (entry: LogEntry): PrecomputedStats => {
return precomputedStats.get(entry.id) || {
recentRequests: [],
successRate: '0.0',
totalCount: 0,
};
};
// 渲染单行
const renderRow = (entry: LogEntry) => {
const stats = getStats(entry);
const rateValue = parseFloat(stats.successRate);
const disabled = isModelDisabled(entry.source, entry.model);
return (
<>
<td title={entry.apiKey}>
{maskSecret(entry.apiKey)}
</td>
<td>{entry.providerType}</td>
<td title={entry.model}>
{entry.model}
</td>
<td title={entry.source}>
{entry.providerName ? (
<>
<span className={styles.channelName}>{entry.providerName}</span>
<span className={styles.channelSecret}> ({entry.maskedKey})</span>
</>
) : (
entry.maskedKey
)}
</td>
<td>
<span className={`${styles.statusPill} ${entry.failed ? styles.failed : styles.success}`}>
{entry.failed ? t('monitor.logs.failed') : t('monitor.logs.success')}
</span>
</td>
<td>
<div className={styles.statusBars}>
{stats.recentRequests.map((req, idx) => (
<div
key={idx}
className={`${styles.statusBar} ${req.failed ? styles.failure : styles.success}`}
/>
))}
</div>
</td>
<td className={getRateClassName(rateValue, styles)}>
{stats.successRate}%
</td>
<td>{formatNumber(stats.totalCount)}</td>
<td>{formatNumber(entry.inputTokens)}</td>
<td>{formatNumber(entry.outputTokens)}</td>
<td>{formatNumber(entry.totalTokens)}</td>
<td>{formatTimestamp(entry.timestamp)}</td>
<td>
{entry.source && entry.source !== '-' && entry.source !== 'unknown' ? (
disabled ? (
<span className={styles.disabledLabel}>
{t('monitor.logs.disabled')}
</span>
) : (
<button
className={styles.disableBtn}
title={t('monitor.logs.disable_model')}
onClick={() => handleDisableClick(entry.source, entry.model)}
>
{t('monitor.logs.disable')}
</button>
)
) : (
'-'
)}
</td>
</>
);
};
return (
<>
<Card
title={t('monitor.logs.title')}
subtitle={
<span>
{formatTimeRangeCaption(timeRange, customRange, t)} · {t('monitor.logs.total_count', { count: logEntries.length })}
<span style={{ color: 'var(--text-tertiary)' }}> · {t('monitor.logs.scroll_hint')}</span>
</span>
}
extra={
<TimeRangeSelector
value={timeRange}
onChange={handleTimeRangeChange}
customRange={customRange}
/>
}
>
{/* 筛选器 */}
<div className={styles.logFilters}>
<select
className={styles.logSelect}
value={filterApi}
onChange={(e) => setFilterApi(e.target.value)}
>
<option value="">{t('monitor.logs.all_apis')}</option>
{apis.map((api) => (
<option key={api} value={api}>
{maskSecret(api)}
</option>
))}
</select>
<select
className={styles.logSelect}
value={filterProviderType}
onChange={(e) => setFilterProviderType(e.target.value)}
>
<option value="">{t('monitor.logs.all_provider_types')}</option>
{providerTypes.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</select>
<select
className={styles.logSelect}
value={filterModel}
onChange={(e) => setFilterModel(e.target.value)}
>
<option value="">{t('monitor.logs.all_models')}</option>
{models.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
<select
className={styles.logSelect}
value={filterSource}
onChange={(e) => setFilterSource(e.target.value)}
>
<option value="">{t('monitor.logs.all_sources')}</option>
{sources.map((source) => (
<option key={source} value={source}>
{formatProviderDisplay(source, providerMap)}
</option>
))}
</select>
<select
className={styles.logSelect}
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value as '' | 'success' | 'failed')}
>
<option value="">{t('monitor.logs.all_status')}</option>
<option value="success">{t('monitor.logs.success')}</option>
<option value="failed">{t('monitor.logs.failed')}</option>
</select>
<span className={styles.logLastUpdate}>
{getCountdownText()}
</span>
<select
className={styles.logSelect}
value={autoRefresh}
onChange={(e) => setAutoRefresh(Number(e.target.value))}
>
<option value="0">{t('monitor.logs.manual_refresh')}</option>
<option value="5">{t('monitor.logs.refresh_5s')}</option>
<option value="10">{t('monitor.logs.refresh_10s')}</option>
<option value="15">{t('monitor.logs.refresh_15s')}</option>
<option value="30">{t('monitor.logs.refresh_30s')}</option>
<option value="60">{t('monitor.logs.refresh_60s')}</option>
</select>
</div>
{/* 虚拟滚动表格 */}
<div className={styles.tableWrapper}>
{showLoading ? (
<div className={styles.emptyState}>{t('common.loading')}</div>
) : filteredEntries.length === 0 ? (
<div className={styles.emptyState}>{t('monitor.no_data')}</div>
) : (
<>
{/* 固定表头 */}
<div ref={headerRef} className={styles.stickyHeader}>
<table className={`${styles.table} ${styles.virtualTable}`}>
<thead>
<tr>
<th>{t('monitor.logs.header_api')}</th>
<th>{t('monitor.logs.header_request_type')}</th>
<th>{t('monitor.logs.header_model')}</th>
<th>{t('monitor.logs.header_source')}</th>
<th>{t('monitor.logs.header_status')}</th>
<th>{t('monitor.logs.header_recent')}</th>
<th>{t('monitor.logs.header_rate')}</th>
<th>{t('monitor.logs.header_count')}</th>
<th>{t('monitor.logs.header_input')}</th>
<th>{t('monitor.logs.header_output')}</th>
<th>{t('monitor.logs.header_total')}</th>
<th>{t('monitor.logs.header_time')}</th>
<th>{t('monitor.logs.header_actions')}</th>
</tr>
</thead>
</table>
</div>
{/* 虚拟滚动容器 */}
<div
ref={tableContainerRef}
className={styles.virtualScrollContainer}
style={{
height: 'calc(100vh - 420px)',
minHeight: '360px',
overflow: 'auto',
}}
onScroll={handleScroll}
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
<table className={`${styles.table} ${styles.virtualTable}`}>
<tbody>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const entry = filteredEntries[virtualRow.index];
return (
<tr
key={entry.id}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
display: 'table',
tableLayout: 'fixed',
}}
>
{renderRow(entry)}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</>
)}
</div>
{/* 统计信息 */}
{filteredEntries.length > 0 && (
<div style={{ textAlign: 'center', fontSize: 12, color: 'var(--text-tertiary)', marginTop: 8 }}>
{t('monitor.logs.total_count', { count: filteredEntries.length })}
</div>
)}
</Card>
{/* 禁用确认弹窗 */}
<DisableModelModal
disableState={disableState}
disabling={disabling}
onConfirm={handleConfirmDisable}
onCancel={handleCancelDisable}
/>
{/* 不支持自动禁用提示弹窗 */}
<UnsupportedDisableModal
state={unsupportedState}
onClose={handleCloseUnsupported}
/>
</>
);
}

View File

@@ -0,0 +1,158 @@
import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import styles from '@/pages/MonitorPage.module.scss';
export type TimeRange = 1 | 7 | 14 | 30 | 'custom';
interface DateRange {
start: Date;
end: Date;
}
interface TimeRangeSelectorProps {
value: TimeRange;
onChange: (range: TimeRange, customRange?: DateRange) => void;
customRange?: DateRange;
}
export function TimeRangeSelector({ value, onChange, customRange }: TimeRangeSelectorProps) {
const { t } = useTranslation();
const [showCustom, setShowCustom] = useState(value === 'custom');
const [startDate, setStartDate] = useState(() => {
if (customRange?.start) {
return formatDateForInput(customRange.start);
}
const date = new Date();
date.setDate(date.getDate() - 7);
return formatDateForInput(date);
});
const [endDate, setEndDate] = useState(() => {
if (customRange?.end) {
return formatDateForInput(customRange.end);
}
return formatDateForInput(new Date());
});
const handleTimeClick = useCallback((range: TimeRange) => {
if (range === 'custom') {
setShowCustom(true);
onChange(range);
} else {
setShowCustom(false);
onChange(range);
}
}, [onChange]);
const handleApplyCustom = useCallback(() => {
if (startDate && endDate) {
const start = new Date(startDate);
start.setHours(0, 0, 0, 0);
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
if (start <= end) {
onChange('custom', { start, end });
}
}
}, [startDate, endDate, onChange]);
return (
<div className={styles.timeRangeSelector}>
<div className={styles.timeButtons}>
{([1, 7, 14, 30, 'custom'] as TimeRange[]).map((range) => (
<button
key={range}
className={`${styles.timeButton} ${value === range ? styles.active : ''}`}
onClick={() => handleTimeClick(range)}
>
{range === 1
? t('monitor.time.today')
: range === 'custom'
? t('monitor.time.custom')
: t('monitor.time.last_n_days', { n: range })}
</button>
))}
</div>
{showCustom && (
<div className={styles.customDatePicker}>
<input
type="date"
className={styles.dateInput}
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
<span className={styles.dateSeparator}>{t('monitor.time.to')}</span>
<input
type="date"
className={styles.dateInput}
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
<button className={styles.dateApplyBtn} onClick={handleApplyCustom}>
{t('monitor.time.apply')}
</button>
</div>
)}
</div>
);
}
function formatDateForInput(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// 根据时间范围过滤数据的工具函数
export function filterByTimeRange<T extends { timestamp?: string }>(
items: T[],
range: TimeRange,
customRange?: DateRange
): T[] {
const now = new Date();
let cutoffStart: Date;
let cutoffEnd: Date = new Date(now.getTime());
cutoffEnd.setHours(23, 59, 59, 999);
if (range === 'custom' && customRange) {
cutoffStart = customRange.start;
cutoffEnd = customRange.end;
} else if (typeof range === 'number') {
cutoffStart = new Date(now.getTime() - range * 24 * 60 * 60 * 1000);
cutoffStart.setHours(0, 0, 0, 0);
} else {
// 默认7天
cutoffStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
cutoffStart.setHours(0, 0, 0, 0);
}
return items.filter((item) => {
if (!item.timestamp) return false;
const timestamp = new Date(item.timestamp);
return timestamp >= cutoffStart && timestamp <= cutoffEnd;
});
}
// 格式化时间范围显示
export function formatTimeRangeCaption(
range: TimeRange,
customRange?: DateRange,
t?: (key: string, options?: any) => string
): string {
if (range === 'custom' && customRange) {
const startStr = formatDateForDisplay(customRange.start);
const endStr = formatDateForDisplay(customRange.end);
return `${startStr} - ${endStr}`;
}
if (range === 1) {
return t ? t('monitor.time.today') : '今天';
}
return t ? t('monitor.time.last_n_days', { n: range }) : `最近 ${range}`;
}
function formatDateForDisplay(date: Date): string {
const month = date.getMonth() + 1;
const day = date.getDate();
return `${month}/${day}`;
}

View File

@@ -0,0 +1,82 @@
/**
* 不支持自动禁用提示弹窗组件
* 显示手动操作指南
*/
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import type { UnsupportedDisableState } from '@/hooks/useDisableModel';
interface UnsupportedDisableModalProps {
/** 不支持禁用的状态 */
state: UnsupportedDisableState | null;
/** 关闭回调 */
onClose: () => void;
}
export function UnsupportedDisableModal({
state,
onClose,
}: UnsupportedDisableModalProps) {
const { t } = useTranslation();
if (!state) return null;
return (
<Modal
open={!!state}
onClose={onClose}
title={t('monitor.logs.disable_unsupported_title')}
width={450}
>
<div style={{ padding: '16px 0' }}>
{/* 提示信息 */}
<p style={{
marginBottom: 16,
lineHeight: 1.6,
color: 'var(--warning-color, #f59e0b)',
fontWeight: 500,
}}>
{t('monitor.logs.disable_unsupported_desc', { providerType: state.providerType })}
</p>
{/* 手动操作指南 */}
<div style={{
padding: '12px 16px',
background: 'var(--bg-tertiary)',
borderRadius: '8px',
marginBottom: 16,
}}>
<p style={{
fontWeight: 600,
marginBottom: 8,
color: 'var(--text-primary)',
}}>
{t('monitor.logs.disable_unsupported_guide_title')}
</p>
<ul style={{
margin: 0,
padding: 0,
listStyle: 'none',
fontSize: 13,
lineHeight: 1.8,
color: 'var(--text-secondary)',
}}>
<li>{t('monitor.logs.disable_unsupported_guide_step1')}</li>
<li>{t('monitor.logs.disable_unsupported_guide_step2', { providerType: state.providerType })}</li>
<li>{t('monitor.logs.disable_unsupported_guide_step3', { model: state.model })}</li>
<li>{t('monitor.logs.disable_unsupported_guide_step4')}</li>
</ul>
</div>
{/* 关闭按钮 */}
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="primary" onClick={onClose}>
{t('monitor.logs.disable_unsupported_close')}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,8 @@
export { KpiCards } from './KpiCards';
export { ModelDistributionChart } from './ModelDistributionChart';
export { DailyTrendChart } from './DailyTrendChart';
export { HourlyModelChart } from './HourlyModelChart';
export { HourlyTokenChart } from './HourlyTokenChart';
export { ChannelStats } from './ChannelStats';
export { FailureAnalysis } from './FailureAnalysis';
export { RequestLogs } from './RequestLogs';

View File

@@ -5,5 +5,5 @@
export { QuotaSection } from './QuotaSection'; export { QuotaSection } from './QuotaSection';
export { QuotaCard } from './QuotaCard'; export { QuotaCard } from './QuotaCard';
export { useQuotaLoader } from './useQuotaLoader'; export { useQuotaLoader } from './useQuotaLoader';
export { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs'; export { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG, KIRO_CONFIG } from './quotaConfigs';
export type { QuotaConfig } from './quotaConfigs'; export type { QuotaConfig } from './quotaConfigs';

View File

@@ -18,6 +18,7 @@ import type {
GeminiCliParsedBucket, GeminiCliParsedBucket,
GeminiCliQuotaBucketState, GeminiCliQuotaBucketState,
GeminiCliQuotaState, GeminiCliQuotaState,
KiroQuotaState,
} from '@/types'; } from '@/types';
import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api'; import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api';
import { import {
@@ -27,6 +28,8 @@ import {
CODEX_REQUEST_HEADERS, CODEX_REQUEST_HEADERS,
GEMINI_CLI_QUOTA_URL, GEMINI_CLI_QUOTA_URL,
GEMINI_CLI_REQUEST_HEADERS, GEMINI_CLI_REQUEST_HEADERS,
KIRO_QUOTA_URL,
KIRO_REQUEST_HEADERS,
normalizeAuthIndexValue, normalizeAuthIndexValue,
normalizeNumberValue, normalizeNumberValue,
normalizePlanType, normalizePlanType,
@@ -35,6 +38,7 @@ import {
parseAntigravityPayload, parseAntigravityPayload,
parseCodexUsagePayload, parseCodexUsagePayload,
parseGeminiCliQuotaPayload, parseGeminiCliQuotaPayload,
parseKiroQuotaPayload,
resolveCodexChatgptAccountId, resolveCodexChatgptAccountId,
resolveCodexPlanType, resolveCodexPlanType,
resolveGeminiCliProjectId, resolveGeminiCliProjectId,
@@ -48,6 +52,7 @@ import {
isCodexFile, isCodexFile,
isDisabledAuthFile, isDisabledAuthFile,
isGeminiCliFile, isGeminiCliFile,
isKiroFile,
isRuntimeOnlyAuthFile, isRuntimeOnlyAuthFile,
} from '@/utils/quota'; } from '@/utils/quota';
import type { QuotaRenderHelpers } from './QuotaCard'; import type { QuotaRenderHelpers } from './QuotaCard';
@@ -55,7 +60,7 @@ import styles from '@/pages/QuotaPage.module.scss';
type QuotaUpdater<T> = T | ((prev: T) => T); type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaType = 'antigravity' | 'codex' | 'gemini-cli'; type QuotaType = 'antigravity' | 'codex' | 'gemini-cli' | 'kiro';
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn'; const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
@@ -63,9 +68,11 @@ export interface QuotaStore {
antigravityQuota: Record<string, AntigravityQuotaState>; antigravityQuota: Record<string, AntigravityQuotaState>;
codexQuota: Record<string, CodexQuotaState>; codexQuota: Record<string, CodexQuotaState>;
geminiCliQuota: Record<string, GeminiCliQuotaState>; geminiCliQuota: Record<string, GeminiCliQuotaState>;
kiroQuota: Record<string, KiroQuotaState>;
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void; setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void; setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void; setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
setKiroQuota: (updater: QuotaUpdater<Record<string, KiroQuotaState>>) => void;
clearQuotaCache: () => void; clearQuotaCache: () => void;
} }
@@ -630,3 +637,291 @@ export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaB
gridClassName: styles.geminiCliGrid, gridClassName: styles.geminiCliGrid,
renderQuotaItems: renderGeminiCliItems, renderQuotaItems: renderGeminiCliItems,
}; };
// Kiro quota data structure from API
interface KiroQuotaData {
// Base quota (原本额度)
baseUsage: number | null;
baseLimit: number | null;
baseRemaining: number | null;
// Free trial/bonus quota (赠送额度)
bonusUsage: number | null;
bonusLimit: number | null;
bonusRemaining: number | null;
bonusStatus?: string;
// Total (合计)
currentUsage: number | null;
usageLimit: number | null;
remainingCredits: number | null;
nextReset?: string;
subscriptionType?: string;
}
const fetchKiroQuota = async (
file: AuthFileItem,
t: TFunction
): Promise<KiroQuotaData> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
if (!authIndex) {
throw new Error(t('kiro_quota.missing_auth_index'));
}
const result = await apiCallApi.request({
authIndex,
method: 'GET',
url: KIRO_QUOTA_URL,
header: { ...KIRO_REQUEST_HEADERS }
});
if (result.statusCode < 200 || result.statusCode >= 300) {
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
}
const payload = parseKiroQuotaPayload(result.body ?? result.bodyText);
if (!payload) {
throw new Error(t('kiro_quota.empty_data'));
}
// Extract usage data from usageBreakdownList (separating base and bonus)
const breakdownList = payload.usageBreakdownList ?? [];
let baseLimit = 0;
let baseUsage = 0;
let bonusLimit = 0;
let bonusUsage = 0;
let bonusStatus: string | undefined;
for (const breakdown of breakdownList) {
// Add base quota
const limit = normalizeNumberValue(breakdown.usageLimitWithPrecision ?? breakdown.usageLimit);
const usage = normalizeNumberValue(breakdown.currentUsageWithPrecision ?? breakdown.currentUsage);
if (limit !== null) baseLimit += limit;
if (usage !== null) baseUsage += usage;
// Add free trial quota if available (e.g., 500 bonus credits)
const freeTrialInfo = breakdown.freeTrialInfo;
if (freeTrialInfo) {
const freeLimit = normalizeNumberValue(freeTrialInfo.usageLimitWithPrecision ?? freeTrialInfo.usageLimit);
const freeUsage = normalizeNumberValue(freeTrialInfo.currentUsageWithPrecision ?? freeTrialInfo.currentUsage);
if (freeLimit !== null) bonusLimit += freeLimit;
if (freeUsage !== null) bonusUsage += freeUsage;
if (freeTrialInfo.freeTrialStatus) {
bonusStatus = freeTrialInfo.freeTrialStatus;
}
}
}
const totalLimit = baseLimit + bonusLimit;
const totalUsage = baseUsage + bonusUsage;
// Calculate next reset time
// Note: nextDateReset from Kiro API is in SECONDS (e.g., 1.769904E9 = 1769904000)
// JavaScript Date() requires milliseconds, so multiply by 1000
let nextReset: string | undefined;
if (payload.nextDateReset) {
// API returns seconds timestamp (scientific notation like 1.769904E9)
const timestampSeconds = payload.nextDateReset;
const resetDate = new Date(timestampSeconds * 1000);
if (!isNaN(resetDate.getTime())) {
nextReset = resetDate.toISOString();
}
}
// Get subscription type
const subscriptionType = payload.subscriptionInfo?.subscriptionTitle ?? payload.subscriptionInfo?.type;
return {
baseUsage,
baseLimit,
baseRemaining: baseLimit > 0 ? Math.max(0, baseLimit - baseUsage) : null,
bonusUsage,
bonusLimit,
bonusRemaining: bonusLimit > 0 ? Math.max(0, bonusLimit - bonusUsage) : null,
bonusStatus,
currentUsage: totalUsage,
usageLimit: totalLimit,
remainingCredits: totalLimit > 0 ? Math.max(0, totalLimit - totalUsage) : null,
nextReset,
subscriptionType
};
};
const renderKiroItems = (
quota: KiroQuotaState,
t: TFunction,
helpers: QuotaRenderHelpers
): ReactNode => {
const { styles: styleMap, QuotaProgressBar } = helpers;
const { createElement: h, Fragment } = React;
const nodes: ReactNode[] = [];
// Show subscription type if available
if (quota.subscriptionType) {
nodes.push(
h(
'div',
{ key: 'subscription', className: styleMap.codexPlan },
h('span', { className: styleMap.codexPlanLabel }, t('kiro_quota.subscription_label')),
h('span', { className: styleMap.codexPlanValue }, quota.subscriptionType)
)
);
}
const usageLimit = quota.usageLimit;
if (usageLimit === null || usageLimit === 0) {
nodes.push(
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('kiro_quota.empty_data'))
);
return h(Fragment, null, ...nodes);
}
const resetLabel = formatQuotaResetTime(quota.nextReset);
// Base quota display (原本额度)
const baseLimit = quota.baseLimit;
const baseRemaining = quota.baseRemaining;
if (baseLimit !== null && baseLimit > 0) {
const baseRemainingPercent = baseRemaining !== null && baseLimit > 0
? Math.round((baseRemaining / baseLimit) * 100)
: 0;
nodes.push(
h(
'div',
{ key: 'base-credits', className: styleMap.quotaRow },
h(
'div',
{ className: styleMap.quotaRowHeader },
h('span', { className: styleMap.quotaModel }, t('kiro_quota.base_credits_label')),
h(
'div',
{ className: styleMap.quotaMeta },
h('span', { className: styleMap.quotaPercent }, `${baseRemainingPercent}%`),
baseRemaining !== null
? h('span', { className: styleMap.quotaAmount }, t('kiro_quota.remaining_credits', { count: Math.round(baseRemaining) }))
: null,
h('span', { className: styleMap.quotaReset }, resetLabel)
)
),
h(QuotaProgressBar, { percent: baseRemainingPercent, highThreshold: 60, mediumThreshold: 20 })
)
);
}
// Bonus quota display (赠送额度)
const bonusLimit = quota.bonusLimit;
const bonusRemaining = quota.bonusRemaining;
if (bonusLimit !== null && bonusLimit > 0) {
const bonusRemainingPercent = bonusRemaining !== null && bonusLimit > 0
? Math.round((bonusRemaining / bonusLimit) * 100)
: 0;
nodes.push(
h(
'div',
{ key: 'bonus-credits', className: styleMap.quotaRow },
h(
'div',
{ className: styleMap.quotaRowHeader },
h('span', { className: styleMap.quotaModel }, t('kiro_quota.bonus_credits_label')),
h(
'div',
{ className: styleMap.quotaMeta },
h('span', { className: styleMap.quotaPercent }, `${bonusRemainingPercent}%`),
bonusRemaining !== null
? h('span', { className: styleMap.quotaAmount }, t('kiro_quota.remaining_credits', { count: Math.round(bonusRemaining) }))
: null
)
),
h(QuotaProgressBar, { percent: bonusRemainingPercent, highThreshold: 60, mediumThreshold: 20 })
)
);
}
// Total credits display (合计)
const currentUsage = quota.currentUsage;
const remainingCredits = quota.remainingCredits;
const totalRemainingPercent = currentUsage !== null && usageLimit > 0
? Math.max(0, 100 - Math.round((currentUsage / usageLimit) * 100))
: 0;
nodes.push(
h(
'div',
{ key: 'total-credits', className: styleMap.quotaRow },
h(
'div',
{ className: styleMap.quotaRowHeader },
h('span', { className: styleMap.quotaModel }, t('kiro_quota.total_credits_label')),
h(
'div',
{ className: styleMap.quotaMeta },
h('span', { className: styleMap.quotaPercent }, `${totalRemainingPercent}%`),
remainingCredits !== null
? h('span', { className: styleMap.quotaAmount }, t('kiro_quota.remaining_credits', { count: Math.round(remainingCredits) }))
: null
)
),
h(QuotaProgressBar, { percent: totalRemainingPercent, highThreshold: 60, mediumThreshold: 20 })
)
);
return h(Fragment, null, ...nodes);
};
export const KIRO_CONFIG: QuotaConfig<KiroQuotaState, KiroQuotaData> = {
type: 'kiro',
i18nPrefix: 'kiro_quota',
filterFn: (file) => isKiroFile(file),
fetchQuota: fetchKiroQuota,
storeSelector: (state) => state.kiroQuota,
storeSetter: 'setKiroQuota',
buildLoadingState: () => ({
status: 'loading',
baseUsage: null,
baseLimit: null,
baseRemaining: null,
bonusUsage: null,
bonusLimit: null,
bonusRemaining: null,
currentUsage: null,
usageLimit: null,
remainingCredits: null
}),
buildSuccessState: (data) => ({
status: 'success',
baseUsage: data.baseUsage,
baseLimit: data.baseLimit,
baseRemaining: data.baseRemaining,
bonusUsage: data.bonusUsage,
bonusLimit: data.bonusLimit,
bonusRemaining: data.bonusRemaining,
bonusStatus: data.bonusStatus,
currentUsage: data.currentUsage,
usageLimit: data.usageLimit,
remainingCredits: data.remainingCredits,
nextReset: data.nextReset,
subscriptionType: data.subscriptionType
}),
buildErrorState: (message, status) => ({
status: 'error',
baseUsage: null,
baseLimit: null,
baseRemaining: null,
bonusUsage: null,
bonusLimit: null,
bonusRemaining: null,
currentUsage: null,
usageLimit: null,
remainingCredits: null,
error: message,
errorStatus: status
}),
cardClassName: styles.kiroCard,
controlsClassName: styles.kiroControls,
controlClassName: styles.kiroControl,
gridClassName: styles.kiroGrid,
renderQuotaItems: renderKiroItems
};

View File

@@ -2,16 +2,20 @@ import type { PropsWithChildren, ReactNode } from 'react';
interface CardProps { interface CardProps {
title?: ReactNode; title?: ReactNode;
subtitle?: ReactNode;
extra?: ReactNode; extra?: ReactNode;
className?: string; className?: string;
} }
export function Card({ title, extra, children, className }: PropsWithChildren<CardProps>) { export function Card({ title, subtitle, extra, children, className }: PropsWithChildren<CardProps>) {
return ( return (
<div className={className ? `card ${className}` : 'card'}> <div className={className ? `card ${className}` : 'card'}>
{(title || extra) && ( {(title || extra) && (
<div className="card-header"> <div className="card-header">
<div className="title">{title}</div> <div className="card-title-group">
<div className="title">{title}</div>
{subtitle && <div className="subtitle">{subtitle}</div>}
</div>
{extra} {extra}
</div> </div>
)} )}

View File

@@ -322,3 +322,11 @@ export function IconLayoutDashboard({ size = 20, ...props }: IconProps) {
</svg> </svg>
); );
} }
export function IconActivity({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" />
</svg>
);
}

View File

@@ -9,3 +9,5 @@ export { useInterval } from './useInterval';
export { useMediaQuery } from './useMediaQuery'; export { useMediaQuery } from './useMediaQuery';
export { usePagination } from './usePagination'; export { usePagination } from './usePagination';
export { useHeaderRefresh } from './useHeaderRefresh'; export { useHeaderRefresh } from './useHeaderRefresh';
export { useDisableModel } from './useDisableModel';
export type { UseDisableModelOptions, UseDisableModelReturn } from './useDisableModel';

View File

@@ -0,0 +1,199 @@
/**
* 禁用模型 Hook
* 封装禁用模型的状态管理和业务逻辑
*/
import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { providersApi } from '@/services/api';
import { useDisabledModelsStore } from '@/stores';
import {
resolveProvider,
createDisableState,
type DisableState,
} from '@/utils/monitor';
import type { OpenAIProviderConfig } from '@/types';
// 不支持禁用的渠道类型(小写)
const UNSUPPORTED_PROVIDER_TYPES = ['claude', 'gemini', 'codex', 'vertex'];
/**
* 不支持禁用的提示状态
*/
export interface UnsupportedDisableState {
providerType: string;
model: string;
displayName: string;
}
export interface UseDisableModelOptions {
providerMap: Record<string, string>;
providerTypeMap?: Record<string, string>;
providerModels?: Record<string, Set<string>>;
}
export interface UseDisableModelReturn {
/** 当前禁用状态 */
disableState: DisableState | null;
/** 不支持禁用的提示状态 */
unsupportedState: UnsupportedDisableState | null;
/** 是否正在禁用中 */
disabling: boolean;
/** 开始禁用流程 */
handleDisableClick: (source: string, model: string) => void;
/** 确认禁用需要点击3次 */
handleConfirmDisable: () => Promise<void>;
/** 取消禁用 */
handleCancelDisable: () => void;
/** 关闭不支持提示 */
handleCloseUnsupported: () => void;
/** 检查模型是否已禁用 */
isModelDisabled: (source: string, model: string) => boolean;
}
/**
* 禁用模型 Hook
* @param options 配置选项
* @returns 禁用模型相关的状态和方法
*/
export function useDisableModel(options: UseDisableModelOptions): UseDisableModelReturn {
const { providerMap, providerTypeMap, providerModels } = options;
const { t } = useTranslation();
// 使用全局 store 管理禁用状态
const { addDisabledModel, isDisabled } = useDisabledModelsStore();
const [disableState, setDisableState] = useState<DisableState | null>(null);
const [unsupportedState, setUnsupportedState] = useState<UnsupportedDisableState | null>(null);
const [disabling, setDisabling] = useState(false);
// 开始禁用流程
const handleDisableClick = useCallback((source: string, model: string) => {
// 首先检查提供商类型是否支持禁用
const providerType = providerTypeMap?.[source] || '';
const lowerType = providerType.toLowerCase();
// 如果是不支持的类型,立即显示提示
if (lowerType && UNSUPPORTED_PROVIDER_TYPES.includes(lowerType)) {
const providerName = resolveProvider(source, providerMap);
const displayName = providerName
? `${providerName} / ${model}`
: `${source.slice(0, 8)}*** / ${model}`;
setUnsupportedState({
providerType,
model,
displayName,
});
return;
}
// 支持的类型,进入正常禁用流程
setDisableState(createDisableState(source, model, providerMap));
}, [providerMap, providerTypeMap]);
// 确认禁用需要点击3次
const handleConfirmDisable = useCallback(async () => {
if (!disableState) return;
// 前两次点击只增加步骤
if (disableState.step < 3) {
setDisableState({
...disableState,
step: disableState.step + 1,
});
return;
}
// 第3次点击执行禁用
setDisabling(true);
try {
const providerName = resolveProvider(disableState.source, providerMap);
if (!providerName) {
throw new Error(t('monitor.logs.disable_error_no_provider'));
}
// 获取当前配置
const providers = await providersApi.getOpenAIProviders();
const targetProvider = providers.find(
(p) => p.name && p.name.toLowerCase() === providerName.toLowerCase()
);
if (!targetProvider) {
throw new Error(t('monitor.logs.disable_error_provider_not_found', { provider: providerName }));
}
const originalModels = targetProvider.models || [];
const modelAlias = disableState.model;
// 过滤掉匹配的模型
const filteredModels = originalModels.filter(
(m) => m.alias !== modelAlias && m.name !== modelAlias
);
// 只有当模型确实被过滤掉时才调用 API
if (filteredModels.length < originalModels.length) {
await providersApi.patchOpenAIProviderByName(targetProvider.name, {
models: filteredModels,
} as Partial<OpenAIProviderConfig>);
}
// 标记为已禁用(全局状态)
addDisabledModel(disableState.source, disableState.model);
setDisableState(null);
} catch (err) {
console.error('禁用模型失败:', err);
alert(err instanceof Error ? err.message : t('monitor.logs.disable_error'));
} finally {
setDisabling(false);
}
}, [disableState, providerMap, t, addDisabledModel]);
// 取消禁用
const handleCancelDisable = useCallback(() => {
setDisableState(null);
}, []);
// 关闭不支持提示
const handleCloseUnsupported = useCallback(() => {
setUnsupportedState(null);
}, []);
// 检查模型是否已禁用
const isModelDisabled = useCallback((source: string, model: string): boolean => {
// 首先检查全局状态中是否已禁用
if (isDisabled(source, model)) {
return true;
}
// 如果提供了 providerModels检查配置中是否已移除
if (providerModels) {
if (!source || !model) return false;
// 首先尝试完全匹配
if (providerModels[source]) {
return !providerModels[source].has(model);
}
// 然后尝试前缀匹配
const entries = Object.entries(providerModels);
for (const [key, modelSet] of entries) {
if (source.startsWith(key) || key.startsWith(source)) {
return !modelSet.has(model);
}
}
}
return false;
}, [isDisabled, providerModels]);
return {
disableState,
unsupportedState,
disabling,
handleDisableClick,
handleConfirmDisable,
handleCancelDisable,
handleCloseUnsupported,
isModelDisabled,
};
}

View File

@@ -104,7 +104,8 @@
"usage_stats": "Usage Statistics", "usage_stats": "Usage Statistics",
"config_management": "Config Panel", "config_management": "Config Panel",
"logs": "Logs Viewer", "logs": "Logs Viewer",
"system_info": "Management Center Info" "system_info": "Management Center Info",
"monitor": "Monitor Center"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -333,7 +334,10 @@
"openai_test_success": "Test succeeded. The model responded.", "openai_test_success": "Test succeeded. The model responded.",
"openai_test_failed": "Test failed", "openai_test_failed": "Test failed",
"openai_test_select_placeholder": "Choose from current models", "openai_test_select_placeholder": "Choose from current models",
"openai_test_select_empty": "No models configured. Add models first" "openai_test_select_empty": "No models configured. Add models first",
"search_placeholder": "Search configs (keys, URLs, models...)",
"search_empty_title": "No matching configs",
"search_empty_desc": "Try a different keyword or clear the search box"
}, },
"auth_files": { "auth_files": {
"title": "Auth Files Management", "title": "Auth Files Management",
@@ -380,6 +384,7 @@
"filter_claude": "Claude", "filter_claude": "Claude",
"filter_codex": "Codex", "filter_codex": "Codex",
"filter_antigravity": "Antigravity", "filter_antigravity": "Antigravity",
"filter_kiro": "Kiro",
"filter_iflow": "iFlow", "filter_iflow": "iFlow",
"filter_vertex": "Vertex", "filter_vertex": "Vertex",
"filter_empty": "Empty", "filter_empty": "Empty",
@@ -391,6 +396,7 @@
"type_claude": "Claude", "type_claude": "Claude",
"type_codex": "Codex", "type_codex": "Codex",
"type_antigravity": "Antigravity", "type_antigravity": "Antigravity",
"type_kiro": "Kiro",
"type_iflow": "iFlow", "type_iflow": "iFlow",
"type_vertex": "Vertex", "type_vertex": "Vertex",
"type_empty": "Empty", "type_empty": "Empty",
@@ -471,6 +477,23 @@
"fetch_all": "Fetch All", "fetch_all": "Fetch All",
"remaining_amount": "Remaining {{count}}" "remaining_amount": "Remaining {{count}}"
}, },
"kiro_quota": {
"title": "Kiro Quota",
"empty_title": "No Kiro Auth Files",
"empty_desc": "Upload a Kiro credential to view remaining quota.",
"idle": "Not loaded. Click Refresh Button.",
"loading": "Loading quota...",
"load_failed": "Failed to load quota: {{message}}",
"missing_auth_index": "Auth file missing auth_index",
"empty_data": "No quota data available",
"refresh_button": "Refresh Quota",
"fetch_all": "Fetch All",
"subscription_label": "Subscription",
"base_credits_label": "Base Credits",
"bonus_credits_label": "Bonus Credits",
"total_credits_label": "Total Credits",
"remaining_credits": "Remaining {{count}}"
},
"vertex_import": { "vertex_import": {
"title": "Vertex JSON Login", "title": "Vertex JSON Login",
"description": "Upload a Google service account JSON to store it as auth-dir/vertex-<project>.json using the same rules as the CLI vertex-import helper.", "description": "Upload a Google service account JSON to store it as auth-dir/vertex-<project>.json using the same rules as the CLI vertex-import helper.",
@@ -690,7 +713,26 @@
"iflow_cookie_result_expired": "Expires At", "iflow_cookie_result_expired": "Expires At",
"iflow_cookie_result_path": "Saved Path", "iflow_cookie_result_path": "Saved Path",
"iflow_cookie_result_type": "Type", "iflow_cookie_result_type": "Type",
"remote_access_disabled": "This login method is not available for remote access. Please access from localhost." "remote_access_disabled": "This login method is not available for remote access. Please access from localhost.",
"kiro_oauth_title": "Kiro OAuth",
"kiro_oauth_hint": "Login to Kiro service via AWS SSO, supporting Builder ID and Identity Center (IDC), or import refreshToken from Kiro IDE directly.",
"kiro_builder_id_label": "AWS Builder ID Login",
"kiro_builder_id_hint": "Login with AWS Builder ID account, suitable for individual developers.",
"kiro_builder_id_button": "Login with Builder ID",
"kiro_idc_label": "AWS Identity Center (IDC) Login",
"kiro_idc_hint": "Login with enterprise AWS Identity Center, requires Start URL and Region.",
"kiro_idc_start_url_label": "Start URL",
"kiro_idc_start_url_placeholder": "https://your-org.awsapps.com/start",
"kiro_idc_region_label": "Region (Optional)",
"kiro_idc_region_placeholder": "us-east-1",
"kiro_idc_button": "Login with IDC",
"kiro_token_import_label": "Token Import",
"kiro_token_import_hint": "Import refreshToken from Kiro IDE, can be found in Kiro IDE's auth file.",
"kiro_token_placeholder": "Paste refreshToken",
"kiro_token_import_button": "Import Token",
"kiro_token_required": "Please enter refreshToken first",
"kiro_token_import_success": "Kiro Token imported successfully",
"kiro_token_import_error": "Kiro Token import failed:"
}, },
"usage_stats": { "usage_stats": {
"title": "Usage Statistics", "title": "Usage Statistics",
@@ -1109,5 +1151,170 @@
"build_date": "Build Time", "build_date": "Build Time",
"version": "Management UI Version", "version": "Management UI Version",
"author": "Author" "author": "Author"
},
"monitor": {
"title": "Monitor Center",
"time_range": "Time Range",
"today": "Today",
"last_n_days": "Last {{n}} Days",
"api_filter": "API Query",
"api_filter_placeholder": "Query API data",
"apply": "Apply",
"no_data": "No data available",
"requests": "Requests",
"kpi": {
"requests": "Requests",
"success": "Success",
"failed": "Failed",
"rate": "Success Rate",
"tokens": "Tokens",
"input": "Input",
"output": "Output",
"reasoning": "Reasoning",
"cached": "Cached",
"avg_tpm": "Avg TPM",
"avg_rpm": "Avg RPM",
"avg_rpd": "Avg RPD",
"tokens_per_minute": "Tokens per minute",
"requests_per_minute": "Requests per minute",
"requests_per_day": "Requests per day"
},
"distribution": {
"title": "Model Usage Distribution",
"by_requests": "By Requests",
"by_tokens": "By Tokens",
"requests": "Requests",
"tokens": "Tokens",
"request_share": "Request Share",
"token_share": "Token Share"
},
"trend": {
"title": "Daily Usage Trend",
"subtitle": "Requests and Token usage trend",
"requests": "Requests",
"input_tokens": "Input Tokens",
"output_tokens": "Output Tokens",
"reasoning_tokens": "Reasoning Tokens",
"cached_tokens": "Cached Tokens"
},
"hourly": {
"last_6h": "Last 6 Hours",
"last_12h": "Last 12 Hours",
"last_24h": "Last 24 Hours",
"all": "All",
"requests": "Requests",
"success_rate": "Success Rate"
},
"hourly_model": {
"title": "Hourly Model Request Distribution",
"models": "Models"
},
"hourly_token": {
"title": "Hourly Token Usage",
"subtitle": "By Hour",
"total": "Total Tokens",
"input": "Input",
"output": "Output",
"reasoning": "Reasoning",
"cached": "Cached"
},
"channel": {
"title": "Channel Statistics",
"subtitle": "Grouped by source channel",
"click_hint": "Click row to expand model details",
"all_channels": "All Channels",
"all_models": "All Models",
"all_status": "All Status",
"only_success": "Success Only",
"only_failed": "Failed Only",
"header_name": "Channel",
"header_count": "Requests",
"header_rate": "Success Rate",
"header_recent": "Recent Status",
"header_time": "Last Request",
"model_details": "Model Details",
"model": "Model",
"success": "Success",
"failed": "Failed"
},
"time": {
"today": "Today",
"last_n_days": "{{n}} Days",
"custom": "Custom",
"to": "to",
"apply": "Apply"
},
"failure": {
"title": "Failure Analysis",
"subtitle": "Locate issues by source channel",
"click_hint": "Click row to expand details",
"no_failures": "No failure data",
"header_name": "Channel",
"header_count": "Failures",
"header_time": "Last Failure",
"header_models": "Top Failed Models",
"all_failed_models": "All Failed Models"
},
"logs": {
"title": "Request Logs",
"total_count": "{{count}} records",
"sort_hint": "Auto sorted by time desc",
"scroll_hint": "Scroll to browse all data",
"virtual_scroll_info": "Showing {{visible}} rows, {{total}} records total",
"all_apis": "All APIs",
"all_models": "All Models",
"all_sources": "All Sources",
"all_status": "All Status",
"all_provider_types": "All Providers",
"success": "Success",
"failed": "Failed",
"last_update": "Last Update",
"manual_refresh": "Manual Refresh",
"refresh_5s": "5s Refresh",
"refresh_10s": "10s Refresh",
"refresh_15s": "15s Refresh",
"refresh_30s": "30s Refresh",
"refresh_60s": "60s Refresh",
"refresh_in_seconds": "Refresh in {{seconds}}s",
"refreshing": "Refreshing...",
"header_auth": "Auth Index",
"header_api": "API",
"header_request_type": "Type",
"header_model": "Model",
"header_source": "Source",
"header_status": "Status",
"header_recent": "Recent Status",
"header_rate": "Success Rate",
"header_count": "Requests",
"header_input": "Input",
"header_output": "Output",
"header_total": "Total Tokens",
"header_time": "Time",
"header_actions": "Actions",
"showing": "Showing {{start}}-{{end}} of {{total}}",
"page_info": "Page {{current}}/{{total}}",
"first_page": "First",
"prev_page": "Prev",
"next_page": "Next",
"last_page": "Last",
"disable": "Disable",
"disable_model": "Disable this model",
"disabled": "Disabled",
"removed": "Removed",
"disabling": "Disabling...",
"disable_confirm_title": "Confirm Disable Model",
"disable_error": "Disable failed",
"disable_error_no_provider": "Cannot identify provider",
"disable_error_provider_not_found": "Provider config not found: {{provider}}",
"disable_not_supported": "{{provider}} provider does not support disable operation",
"disable_unsupported_title": "Auto-disable Not Supported",
"disable_unsupported_desc": "{{providerType}} type providers do not support auto-disable feature.",
"disable_unsupported_guide_title": "Manual Operation Guide",
"disable_unsupported_guide_step1": "1. Go to the \"AI Providers\" page",
"disable_unsupported_guide_step2": "2. Find the corresponding {{providerType}} configuration",
"disable_unsupported_guide_step3": "3. Edit the config and remove model \"{{model}}\"",
"disable_unsupported_guide_step4": "4. Save the configuration to apply changes",
"disable_unsupported_close": "Got it"
}
} }
} }

View File

@@ -104,7 +104,8 @@
"usage_stats": "使用统计", "usage_stats": "使用统计",
"config_management": "配置面板", "config_management": "配置面板",
"logs": "日志查看", "logs": "日志查看",
"system_info": "中心信息" "system_info": "中心信息",
"monitor": "监控中心"
}, },
"dashboard": { "dashboard": {
"title": "仪表盘", "title": "仪表盘",
@@ -333,7 +334,10 @@
"openai_test_success": "测试成功,模型可用。", "openai_test_success": "测试成功,模型可用。",
"openai_test_failed": "测试失败", "openai_test_failed": "测试失败",
"openai_test_select_placeholder": "从当前模型列表选择", "openai_test_select_placeholder": "从当前模型列表选择",
"openai_test_select_empty": "当前未配置模型,请先添加模型" "openai_test_select_empty": "当前未配置模型,请先添加模型",
"search_placeholder": "搜索配置(密钥、地址、模型等)",
"search_empty_title": "没有匹配的配置",
"search_empty_desc": "请尝试更换关键字或清空搜索框"
}, },
"auth_files": { "auth_files": {
"title": "认证文件管理", "title": "认证文件管理",
@@ -380,6 +384,7 @@
"filter_claude": "Claude", "filter_claude": "Claude",
"filter_codex": "Codex", "filter_codex": "Codex",
"filter_antigravity": "Antigravity", "filter_antigravity": "Antigravity",
"filter_kiro": "Kiro",
"filter_iflow": "iFlow", "filter_iflow": "iFlow",
"filter_vertex": "Vertex", "filter_vertex": "Vertex",
"filter_empty": "空文件", "filter_empty": "空文件",
@@ -391,6 +396,7 @@
"type_claude": "Claude", "type_claude": "Claude",
"type_codex": "Codex", "type_codex": "Codex",
"type_antigravity": "Antigravity", "type_antigravity": "Antigravity",
"type_kiro": "Kiro",
"type_iflow": "iFlow", "type_iflow": "iFlow",
"type_vertex": "Vertex", "type_vertex": "Vertex",
"type_empty": "空文件", "type_empty": "空文件",
@@ -471,6 +477,23 @@
"fetch_all": "获取全部", "fetch_all": "获取全部",
"remaining_amount": "剩余 {{count}}" "remaining_amount": "剩余 {{count}}"
}, },
"kiro_quota": {
"title": "Kiro 额度",
"empty_title": "暂无 Kiro 认证",
"empty_desc": "上传 Kiro 认证文件后即可查看额度。",
"idle": "尚未加载额度,请点击刷新按钮。",
"loading": "正在加载额度...",
"load_failed": "额度获取失败:{{message}}",
"missing_auth_index": "认证文件缺少 auth_index",
"empty_data": "暂无额度数据",
"refresh_button": "刷新额度",
"fetch_all": "获取全部",
"subscription_label": "订阅类型",
"base_credits_label": "基础额度",
"bonus_credits_label": "赠送额度",
"total_credits_label": "合计额度",
"remaining_credits": "剩余 {{count}}"
},
"vertex_import": { "vertex_import": {
"title": "Vertex JSON 登录", "title": "Vertex JSON 登录",
"description": "上传 Google 服务账号 JSON使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。", "description": "上传 Google 服务账号 JSON使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。",
@@ -690,7 +713,26 @@
"iflow_cookie_result_expired": "过期时间", "iflow_cookie_result_expired": "过期时间",
"iflow_cookie_result_path": "保存路径", "iflow_cookie_result_path": "保存路径",
"iflow_cookie_result_type": "类型", "iflow_cookie_result_type": "类型",
"remote_access_disabled": "远程访问不支持此登录方式,请从本地 (localhost) 访问" "remote_access_disabled": "远程访问不支持此登录方式,请从本地 (localhost) 访问",
"kiro_oauth_title": "Kiro OAuth",
"kiro_oauth_hint": "通过 AWS SSO 登录 Kiro 服务,支持 Builder ID 和 Identity Center (IDC) 两种方式,或直接导入 Kiro IDE 的 refreshToken。",
"kiro_builder_id_label": "AWS Builder ID 登录",
"kiro_builder_id_hint": "使用 AWS Builder ID 账号登录,适用于个人开发者。",
"kiro_builder_id_button": "使用 Builder ID 登录",
"kiro_idc_label": "AWS Identity Center (IDC) 登录",
"kiro_idc_hint": "使用企业 AWS Identity Center 登录,需要提供 Start URL 和 Region。",
"kiro_idc_start_url_label": "Start URL",
"kiro_idc_start_url_placeholder": "https://your-org.awsapps.com/start",
"kiro_idc_region_label": "Region (可选)",
"kiro_idc_region_placeholder": "us-east-1",
"kiro_idc_button": "使用 IDC 登录",
"kiro_token_import_label": "Token 导入",
"kiro_token_import_hint": "从 Kiro IDE 导入 refreshToken可在 Kiro IDE 的认证文件中找到。",
"kiro_token_placeholder": "粘贴 refreshToken",
"kiro_token_import_button": "导入 Token",
"kiro_token_required": "请先填写 refreshToken",
"kiro_token_import_success": "Kiro Token 导入成功",
"kiro_token_import_error": "Kiro Token 导入失败:"
}, },
"usage_stats": { "usage_stats": {
"title": "使用统计", "title": "使用统计",
@@ -1109,5 +1151,170 @@
"build_date": "构建时间", "build_date": "构建时间",
"version": "管理中心版本", "version": "管理中心版本",
"author": "作者" "author": "作者"
},
"monitor": {
"title": "监控中心",
"time_range": "时间范围",
"today": "今天",
"last_n_days": "最近 {{n}} 天",
"api_filter": "API 查询",
"api_filter_placeholder": "查询对应 API 数据",
"apply": "查看",
"no_data": "暂无数据",
"requests": "请求",
"kpi": {
"requests": "请求数",
"success": "成功",
"failed": "失败",
"rate": "成功率",
"tokens": "Tokens",
"input": "输入",
"output": "输出",
"reasoning": "思考",
"cached": "缓存",
"avg_tpm": "平均 TPM",
"avg_rpm": "平均 RPM",
"avg_rpd": "日均 RPD",
"tokens_per_minute": "每分钟 Token",
"requests_per_minute": "每分钟请求",
"requests_per_day": "每日请求数"
},
"distribution": {
"title": "模型用量分布",
"by_requests": "按请求数",
"by_tokens": "按 Token 数",
"requests": "请求",
"tokens": "Token",
"request_share": "请求占比",
"token_share": "Token 占比"
},
"trend": {
"title": "每日用量趋势",
"subtitle": "请求数与 Token 用量趋势",
"requests": "请求数",
"input_tokens": "输入 Token",
"output_tokens": "输出 Token",
"reasoning_tokens": "思考 Token",
"cached_tokens": "缓存 Token"
},
"hourly": {
"last_6h": "最近 6 小时",
"last_12h": "最近 12 小时",
"last_24h": "最近 24 小时",
"all": "全部",
"requests": "请求数",
"success_rate": "成功率"
},
"hourly_model": {
"title": "每小时模型请求分布",
"models": "模型"
},
"hourly_token": {
"title": "每小时 Token 用量",
"subtitle": "按小时显示",
"total": "总 Token",
"input": "输入",
"output": "输出",
"reasoning": "思考",
"cached": "缓存"
},
"channel": {
"title": "渠道统计",
"subtitle": "按来源渠道分类",
"click_hint": "单击行展开模型详情",
"all_channels": "全部渠道",
"all_models": "全部模型",
"all_status": "全部状态",
"only_success": "仅成功",
"only_failed": "仅失败",
"header_name": "渠道",
"header_count": "请求数",
"header_rate": "成功率",
"header_recent": "最近请求状态",
"header_time": "最近请求时间",
"model_details": "模型详情",
"model": "模型",
"success": "成功",
"failed": "失败"
},
"time": {
"today": "今天",
"last_n_days": "{{n}} 天",
"custom": "自定义",
"to": "至",
"apply": "应用"
},
"failure": {
"title": "失败来源分析",
"subtitle": "从来源渠道定位异常",
"click_hint": "单击行展开详情",
"no_failures": "暂无失败数据",
"header_name": "渠道",
"header_count": "失败数",
"header_time": "最近失败",
"header_models": "主要失败模型",
"all_failed_models": "所有失败模型"
},
"logs": {
"title": "请求日志",
"total_count": "共 {{count}} 条",
"sort_hint": "自动按时间倒序",
"scroll_hint": "滚动浏览全部数据",
"virtual_scroll_info": "当前显示 {{visible}} 行,共 {{total}} 条记录",
"all_apis": "全部请求 API",
"all_models": "全部请求模型",
"all_sources": "全部请求渠道",
"all_status": "全部请求状态",
"all_provider_types": "全部请求类型",
"success": "成功",
"failed": "失败",
"last_update": "最后更新",
"manual_refresh": "手动刷新",
"refresh_5s": "5秒刷新",
"refresh_10s": "10秒刷新",
"refresh_15s": "15秒刷新",
"refresh_30s": "30秒刷新",
"refresh_60s": "60秒刷新",
"refresh_in_seconds": "{{seconds}}秒后刷新",
"refreshing": "刷新中...",
"header_auth": "认证索引",
"header_api": "请求 API",
"header_request_type": "请求类型",
"header_model": "请求模型",
"header_source": "请求渠道",
"header_status": "请求状态",
"header_recent": "最近请求状态",
"header_rate": "成功率",
"header_count": "请求数",
"header_input": "输入",
"header_output": "输出",
"header_total": "总 Token",
"header_time": "时间",
"header_actions": "操作",
"showing": "显示 {{start}}-{{end}} 条,共 {{total}} 条",
"page_info": "第 {{current}}/{{total}} 页",
"first_page": "首页",
"prev_page": "上一页",
"next_page": "下一页",
"last_page": "末页",
"disable": "禁用",
"disable_model": "禁用此模型",
"disabled": "已禁用",
"removed": "已移除",
"disabling": "禁用中...",
"disable_confirm_title": "确认禁用模型",
"disable_error": "禁用失败",
"disable_error_no_provider": "无法识别渠道",
"disable_error_provider_not_found": "未找到渠道配置:{{provider}}",
"disable_not_supported": "{{provider}} 渠道不支持禁用操作",
"disable_unsupported_title": "不支持自动禁用",
"disable_unsupported_desc": "{{providerType}} 类型的渠道暂不支持自动禁用功能。",
"disable_unsupported_guide_title": "手动操作指南",
"disable_unsupported_guide_step1": "1. 前往「AI 提供商」页面",
"disable_unsupported_guide_step2": "2. 找到对应的 {{providerType}} 配置",
"disable_unsupported_guide_step3": "3. 编辑配置,移除模型「{{model}}」",
"disable_unsupported_guide_step4": "4. 保存配置即可生效",
"disable_unsupported_close": "我知道了"
}
} }
} }

View File

@@ -20,7 +20,53 @@
font-size: 28px; font-size: 28px;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
margin: 0 0 $spacing-xl 0; margin: 0;
}
.pageHeader {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
margin-bottom: $spacing-xl;
}
.searchBox {
flex: 0 1 320px;
min-width: 200px;
@include mobile {
flex: 1 1 100%;
}
:global(.form-group) {
margin-bottom: 0;
}
}
.searchEmpty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $spacing-xl * 2;
text-align: center;
background: var(--bg-secondary);
border: 1px dashed var(--border-primary);
border-radius: 12px;
}
.searchEmptyTitle {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: $spacing-sm;
}
.searchEmptyDesc {
font-size: 14px;
color: var(--text-tertiary);
} }
.content { .content {

File diff suppressed because it is too large Load Diff

379
src/pages/MonitorPage.tsx Normal file
View File

@@ -0,0 +1,379 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
BarController,
LineController,
ArcElement,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useThemeStore } from '@/stores';
import { usageApi, providersApi } from '@/services/api';
import { KpiCards } from '@/components/monitor/KpiCards';
import { ModelDistributionChart } from '@/components/monitor/ModelDistributionChart';
import { DailyTrendChart } from '@/components/monitor/DailyTrendChart';
import { HourlyModelChart } from '@/components/monitor/HourlyModelChart';
import { HourlyTokenChart } from '@/components/monitor/HourlyTokenChart';
import { ChannelStats } from '@/components/monitor/ChannelStats';
import { FailureAnalysis } from '@/components/monitor/FailureAnalysis';
import { RequestLogs } from '@/components/monitor/RequestLogs';
import styles from './MonitorPage.module.scss';
// 注册 Chart.js 组件
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
BarController,
LineController,
ArcElement,
Title,
Tooltip,
Legend,
Filler
);
// 时间范围选项
type TimeRange = 1 | 7 | 14 | 30;
export interface UsageDetail {
timestamp: string;
failed: boolean;
source: string;
auth_index: string;
tokens: {
input_tokens: number;
output_tokens: number;
reasoning_tokens: number;
cached_tokens: number;
total_tokens: number;
};
}
export interface UsageData {
apis: Record<string, {
models: Record<string, {
details: UsageDetail[];
}>;
}>;
}
export function MonitorPage() {
const { t } = useTranslation();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const isDark = resolvedTheme === 'dark';
// 状态
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [usageData, setUsageData] = useState<UsageData | null>(null);
const [timeRange, setTimeRange] = useState<TimeRange>(7);
const [apiFilter, setApiFilter] = useState('');
const [providerMap, setProviderMap] = useState<Record<string, string>>({});
const [providerModels, setProviderModels] = useState<Record<string, Set<string>>>({});
const [providerTypeMap, setProviderTypeMap] = useState<Record<string, string>>({});
// 加载渠道名称映射(支持所有提供商类型)
const loadProviderMap = useCallback(async () => {
try {
const map: Record<string, string> = {};
const modelsMap: Record<string, Set<string>> = {};
const typeMap: Record<string, string> = {};
// 并行加载所有提供商配置
const [openaiProviders, geminiKeys, claudeConfigs, codexConfigs, vertexConfigs] = await Promise.all([
providersApi.getOpenAIProviders().catch(() => []),
providersApi.getGeminiKeys().catch(() => []),
providersApi.getClaudeConfigs().catch(() => []),
providersApi.getCodexConfigs().catch(() => []),
providersApi.getVertexConfigs().catch(() => []),
]);
// 处理 OpenAI 兼容提供商
openaiProviders.forEach((provider) => {
const providerName = provider.headers?.['X-Provider'] || provider.name || 'unknown';
const modelSet = new Set<string>();
(provider.models || []).forEach((m) => {
if (m.alias) modelSet.add(m.alias);
if (m.name) modelSet.add(m.name);
});
const apiKeyEntries = provider.apiKeyEntries || [];
apiKeyEntries.forEach((entry) => {
const apiKey = entry.apiKey;
if (apiKey) {
map[apiKey] = providerName;
modelsMap[apiKey] = modelSet;
typeMap[apiKey] = 'OpenAI';
}
});
if (provider.name) {
map[provider.name] = providerName;
modelsMap[provider.name] = modelSet;
typeMap[provider.name] = 'OpenAI';
}
});
// 处理 Gemini 提供商
geminiKeys.forEach((config) => {
const apiKey = config.apiKey;
if (apiKey) {
const providerName = config.prefix?.trim() || 'Gemini';
map[apiKey] = providerName;
typeMap[apiKey] = 'Gemini';
}
});
// 处理 Claude 提供商
claudeConfigs.forEach((config) => {
const apiKey = config.apiKey;
if (apiKey) {
const providerName = config.prefix?.trim() || 'Claude';
map[apiKey] = providerName;
typeMap[apiKey] = 'Claude';
// 存储模型集合
if (config.models && config.models.length > 0) {
const modelSet = new Set<string>();
config.models.forEach((m) => {
if (m.alias) modelSet.add(m.alias);
if (m.name) modelSet.add(m.name);
});
modelsMap[apiKey] = modelSet;
}
}
});
// 处理 Codex 提供商
codexConfigs.forEach((config) => {
const apiKey = config.apiKey;
if (apiKey) {
const providerName = config.prefix?.trim() || 'Codex';
map[apiKey] = providerName;
typeMap[apiKey] = 'Codex';
if (config.models && config.models.length > 0) {
const modelSet = new Set<string>();
config.models.forEach((m) => {
if (m.alias) modelSet.add(m.alias);
if (m.name) modelSet.add(m.name);
});
modelsMap[apiKey] = modelSet;
}
}
});
// 处理 Vertex 提供商
vertexConfigs.forEach((config) => {
const apiKey = config.apiKey;
if (apiKey) {
const providerName = config.prefix?.trim() || 'Vertex';
map[apiKey] = providerName;
typeMap[apiKey] = 'Vertex';
if (config.models && config.models.length > 0) {
const modelSet = new Set<string>();
config.models.forEach((m) => {
if (m.alias) modelSet.add(m.alias);
if (m.name) modelSet.add(m.name);
});
modelsMap[apiKey] = modelSet;
}
}
});
setProviderMap(map);
setProviderModels(modelsMap);
setProviderTypeMap(typeMap);
} catch (err) {
console.warn('Monitor: Failed to load provider map:', err);
}
}, []);
// 加载数据
const loadData = useCallback(async () => {
setLoading(true);
setError(null);
try {
// 并行加载使用数据和渠道映射
const [response] = await Promise.all([
usageApi.getUsage(),
loadProviderMap()
]);
// API 返回的数据可能在 response.usage 或直接在 response 中
const data = response?.usage ?? response;
setUsageData(data as UsageData);
} catch (err) {
const message = err instanceof Error ? err.message : t('common.unknown_error');
console.error('Monitor: Error loading data:', err);
setError(message);
} finally {
setLoading(false);
}
}, [t, loadProviderMap]);
// 初始加载
useEffect(() => {
loadData();
}, [loadData]);
// 响应头部刷新
useHeaderRefresh(loadData);
// 根据时间范围过滤数据
const filteredData = useMemo(() => {
if (!usageData?.apis) {
return null;
}
const now = new Date();
const cutoffTime = new Date(now.getTime() - timeRange * 24 * 60 * 60 * 1000);
const filtered: UsageData = { apis: {} };
Object.entries(usageData.apis).forEach(([apiKey, apiData]) => {
// 如果有 API 过滤器,检查是否匹配
if (apiFilter && !apiKey.toLowerCase().includes(apiFilter.toLowerCase())) {
return;
}
// 检查 apiData 是否有 models 属性
if (!apiData?.models) {
return;
}
const filteredModels: Record<string, { details: UsageDetail[] }> = {};
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
// 检查 modelData 是否有 details 属性
if (!modelData?.details || !Array.isArray(modelData.details)) {
return;
}
const filteredDetails = modelData.details.filter((detail) => {
const timestamp = new Date(detail.timestamp);
return timestamp >= cutoffTime;
});
if (filteredDetails.length > 0) {
filteredModels[modelName] = { details: filteredDetails };
}
});
if (Object.keys(filteredModels).length > 0) {
filtered.apis[apiKey] = { models: filteredModels };
}
});
return filtered;
}, [usageData, timeRange, apiFilter]);
// 处理时间范围变化
const handleTimeRangeChange = (range: TimeRange) => {
setTimeRange(range);
};
// 处理 API 过滤应用(触发数据刷新)
const handleApiFilterApply = () => {
loadData();
};
return (
<div className={styles.container}>
{loading && !usageData && (
<div className={styles.loadingOverlay} aria-busy="true">
<div className={styles.loadingOverlayContent}>
<LoadingSpinner size={28} className={styles.loadingOverlaySpinner} />
<span className={styles.loadingOverlayText}>{t('common.loading')}</span>
</div>
</div>
)}
{/* 页面标题 */}
<div className={styles.header}>
<h1 className={styles.pageTitle}>{t('monitor.title')}</h1>
<div className={styles.headerActions}>
<Button
variant="secondary"
size="sm"
onClick={loadData}
disabled={loading}
>
{loading ? t('common.loading') : t('common.refresh')}
</Button>
</div>
</div>
{/* 错误提示 */}
{error && <div className={styles.errorBox}>{error}</div>}
{/* 时间范围和 API 过滤 */}
<div className={styles.filters}>
<div className={styles.filterGroup}>
<span className={styles.filterLabel}>{t('monitor.time_range')}</span>
<div className={styles.timeButtons}>
{([1, 7, 14, 30] as TimeRange[]).map((range) => (
<button
key={range}
className={`${styles.timeButton} ${timeRange === range ? styles.active : ''}`}
onClick={() => handleTimeRangeChange(range)}
>
{range === 1 ? t('monitor.today') : t('monitor.last_n_days', { n: range })}
</button>
))}
</div>
</div>
<div className={styles.filterGroup}>
<span className={styles.filterLabel}>{t('monitor.api_filter')}</span>
<input
type="text"
className={styles.filterInput}
placeholder={t('monitor.api_filter_placeholder')}
value={apiFilter}
onChange={(e) => setApiFilter(e.target.value)}
/>
<Button variant="secondary" size="sm" onClick={handleApiFilterApply}>
{t('monitor.apply')}
</Button>
</div>
</div>
{/* KPI 卡片 */}
<KpiCards data={filteredData} loading={loading} timeRange={timeRange} />
{/* 图表区域 */}
<div className={styles.chartsGrid}>
<ModelDistributionChart data={filteredData} loading={loading} isDark={isDark} timeRange={timeRange} />
<DailyTrendChart data={filteredData} loading={loading} isDark={isDark} timeRange={timeRange} />
</div>
{/* 小时级图表 */}
<HourlyModelChart data={filteredData} loading={loading} isDark={isDark} />
<HourlyTokenChart data={filteredData} loading={loading} isDark={isDark} />
{/* 统计表格 */}
<div className={styles.statsGrid}>
<ChannelStats data={filteredData} loading={loading} providerMap={providerMap} providerModels={providerModels} />
<FailureAnalysis data={filteredData} loading={loading} providerMap={providerMap} providerModels={providerModels} />
</div>
{/* 请求日志 */}
<RequestLogs
data={filteredData}
loading={loading}
providerMap={providerMap}
providerTypeMap={providerTypeMap}
apiFilter={apiFilter}
/>
</div>
);
}

View File

@@ -15,6 +15,7 @@ import iconGemini from '@/assets/icons/gemini.svg';
import iconQwen from '@/assets/icons/qwen.svg'; import iconQwen from '@/assets/icons/qwen.svg';
import iconIflow from '@/assets/icons/iflow.svg'; import iconIflow from '@/assets/icons/iflow.svg';
import iconVertex from '@/assets/icons/vertex.svg'; import iconVertex from '@/assets/icons/vertex.svg';
import iconKiro from '@/assets/icons/kiro.svg';
interface ProviderState { interface ProviderState {
url?: string; url?: string;
@@ -54,6 +55,19 @@ interface VertexImportState {
result?: VertexImportResult; result?: VertexImportResult;
} }
interface KiroOAuthState {
method: 'builder-id' | 'idc' | null;
startUrl: string;
region: string;
}
interface KiroTokenImportState {
token: string;
loading: boolean;
error?: string;
success?: boolean;
}
const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string; icon: string | { light: string; dark: string } }[] = [ const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string; icon: string | { light: string; dark: string } }[] = [
{ id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: { light: iconCodexLight, dark: iconCodexDark } }, { id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: { light: iconCodexLight, dark: iconCodexDark } },
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude }, { id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude },
@@ -82,6 +96,15 @@ export function OAuthPage() {
location: '', location: '',
loading: false loading: false
}); });
const [kiroOAuth, setKiroOAuth] = useState<KiroOAuthState>({
method: null,
startUrl: '',
region: ''
});
const [kiroTokenImport, setKiroTokenImport] = useState<KiroTokenImportState>({
token: '',
loading: false
});
const timers = useRef<Record<string, number>>({}); const timers = useRef<Record<string, number>>({});
const vertexFileInputRef = useRef<HTMLInputElement | null>(null); const vertexFileInputRef = useRef<HTMLInputElement | null>(null);
@@ -303,6 +326,48 @@ export function OAuthPage() {
} }
}; };
const openKiroOAuth = (method: 'builder-id' | 'idc') => {
const baseUrl = window.location.origin;
let url = `${baseUrl}/v0/oauth/kiro/start?method=${method}`;
if (method === 'idc') {
const startUrl = kiroOAuth.startUrl.trim();
const region = kiroOAuth.region.trim();
if (startUrl) {
url += `&start_url=${encodeURIComponent(startUrl)}`;
}
if (region) {
url += `&region=${encodeURIComponent(region)}`;
}
}
window.open(url, '_blank', 'noopener,noreferrer');
};
const handleKiroTokenImport = async () => {
const token = kiroTokenImport.token.trim();
if (!token) {
showNotification(t('auth_login.kiro_token_required'), 'warning');
return;
}
setKiroTokenImport((prev) => ({ ...prev, loading: true, error: undefined, success: undefined }));
try {
const baseUrl = window.location.origin;
const response = await fetch(`${baseUrl}/v0/oauth/kiro/import`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `HTTP ${response.status}`);
}
setKiroTokenImport((prev) => ({ ...prev, loading: false, success: true }));
showNotification(t('auth_login.kiro_token_import_success'), 'success');
} catch (err: any) {
setKiroTokenImport((prev) => ({ ...prev, loading: false, error: err?.message }));
showNotification(`${t('auth_login.kiro_token_import_error')} ${err?.message || ''}`, 'error');
}
};
return ( return (
<div className={styles.container}> <div className={styles.container}>
<h1 className={styles.pageTitle}>{t('nav.oauth', { defaultValue: 'OAuth' })}</h1> <h1 className={styles.pageTitle}>{t('nav.oauth', { defaultValue: 'OAuth' })}</h1>
@@ -574,6 +639,95 @@ export function OAuthPage() {
</div> </div>
)} )}
</Card> </Card>
{/* Kiro OAuth 登录 */}
<Card
title={
<span className={styles.cardTitle}>
<img src={iconKiro} alt="" className={styles.cardTitleIcon} />
{t('auth_login.kiro_oauth_title')}
</span>
}
>
<div className="hint">{t('auth_login.kiro_oauth_hint')}</div>
{/* AWS Builder ID 登录 */}
<div className="form-group" style={{ marginTop: 16 }}>
<label className="label">{t('auth_login.kiro_builder_id_label')}</label>
<div className="hint">{t('auth_login.kiro_builder_id_hint')}</div>
<Button
variant="secondary"
size="sm"
onClick={() => openKiroOAuth('builder-id')}
style={{ marginTop: 8 }}
>
{t('auth_login.kiro_builder_id_button')}
</Button>
</div>
{/* AWS Identity Center (IDC) 登录 */}
<div className="form-group" style={{ marginTop: 16 }}>
<label className="label">{t('auth_login.kiro_idc_label')}</label>
<div className="hint">{t('auth_login.kiro_idc_hint')}</div>
<Input
label={t('auth_login.kiro_idc_start_url_label')}
value={kiroOAuth.startUrl}
onChange={(e) => setKiroOAuth((prev) => ({ ...prev, startUrl: e.target.value }))}
placeholder={t('auth_login.kiro_idc_start_url_placeholder')}
/>
<Input
label={t('auth_login.kiro_idc_region_label')}
value={kiroOAuth.region}
onChange={(e) => setKiroOAuth((prev) => ({ ...prev, region: e.target.value }))}
placeholder={t('auth_login.kiro_idc_region_placeholder')}
/>
<Button
variant="secondary"
size="sm"
onClick={() => openKiroOAuth('idc')}
style={{ marginTop: 8 }}
>
{t('auth_login.kiro_idc_button')}
</Button>
</div>
{/* Token 导入 */}
<div className="form-group" style={{ marginTop: 16 }}>
<label className="label">{t('auth_login.kiro_token_import_label')}</label>
<div className="hint">{t('auth_login.kiro_token_import_hint')}</div>
<Input
value={kiroTokenImport.token}
onChange={(e) =>
setKiroTokenImport((prev) => ({
...prev,
token: e.target.value,
error: undefined,
success: undefined
}))
}
placeholder={t('auth_login.kiro_token_placeholder')}
/>
<Button
variant="secondary"
size="sm"
onClick={handleKiroTokenImport}
loading={kiroTokenImport.loading}
style={{ marginTop: 8 }}
>
{t('auth_login.kiro_token_import_button')}
</Button>
{kiroTokenImport.success && (
<div className="status-badge success" style={{ marginTop: 8 }}>
{t('auth_login.kiro_token_import_success')}
</div>
)}
{kiroTokenImport.error && (
<div className="status-badge error" style={{ marginTop: 8 }}>
{t('auth_login.kiro_token_import_error')} {kiroTokenImport.error}
</div>
)}
</div>
</Card>
</div> </div>
</div> </div>
); );

View File

@@ -104,7 +104,8 @@
.antigravityGrid, .antigravityGrid,
.codexGrid, .codexGrid,
.geminiCliGrid { .geminiCliGrid,
.kiroGrid {
display: grid; display: grid;
gap: $spacing-md; gap: $spacing-md;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
@@ -116,7 +117,8 @@
.antigravityControls, .antigravityControls,
.codexControls, .codexControls,
.geminiCliControls { .geminiCliControls,
.kiroControls {
display: flex; display: flex;
gap: $spacing-md; gap: $spacing-md;
flex-wrap: wrap; flex-wrap: wrap;
@@ -126,7 +128,8 @@
.antigravityControl, .antigravityControl,
.codexControl, .codexControl,
.geminiCliControl { .geminiCliControl,
.kiroControl {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
@@ -163,6 +166,12 @@
rgba(231, 239, 255, 0)); rgba(231, 239, 255, 0));
} }
.kiroCard {
background-image: linear-gradient(180deg,
rgba(255, 248, 225, 0.18),
rgba(255, 248, 225, 0));
}
.quotaSection { .quotaSection {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -1,5 +1,5 @@
/** /**
* Quota management page - coordinates the three quota sections. * Quota management page - coordinates the four quota sections.
*/ */
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
@@ -11,7 +11,8 @@ import {
QuotaSection, QuotaSection,
ANTIGRAVITY_CONFIG, ANTIGRAVITY_CONFIG,
CODEX_CONFIG, CODEX_CONFIG,
GEMINI_CLI_CONFIG GEMINI_CLI_CONFIG,
KIRO_CONFIG
} from '@/components/quota'; } from '@/components/quota';
import type { AuthFileItem } from '@/types'; import type { AuthFileItem } from '@/types';
import styles from './QuotaPage.module.scss'; import styles from './QuotaPage.module.scss';
@@ -75,6 +76,12 @@ export function QuotaPage() {
loading={loading} loading={loading}
disabled={disableControls} disabled={disableControls}
/> />
<QuotaSection
config={KIRO_CONFIG}
files={files}
loading={loading}
disabled={disableControls}
/>
<QuotaSection <QuotaSection
config={CODEX_CONFIG} config={CODEX_CONFIG}
files={files} files={files}

View File

@@ -219,7 +219,7 @@ export function SystemPage() {
</a> </a>
<a <a
href="https://github.com/router-for-me/Cli-Proxy-API-Management-Center" href="https://github.com/kongkongyo/Cli-Proxy-API-Management-Center"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={styles.linkCard} className={styles.linkCard}

View File

@@ -18,6 +18,7 @@ import { UsagePage } from '@/pages/UsagePage';
import { ConfigPage } from '@/pages/ConfigPage'; import { ConfigPage } from '@/pages/ConfigPage';
import { LogsPage } from '@/pages/LogsPage'; import { LogsPage } from '@/pages/LogsPage';
import { SystemPage } from '@/pages/SystemPage'; import { SystemPage } from '@/pages/SystemPage';
import { MonitorPage } from '@/pages/MonitorPage';
const mainRoutes = [ const mainRoutes = [
{ path: '/', element: <DashboardPage /> }, { path: '/', element: <DashboardPage /> },
@@ -60,6 +61,7 @@ const mainRoutes = [
{ path: '/config', element: <ConfigPage /> }, { path: '/config', element: <ConfigPage /> },
{ path: '/logs', element: <LogsPage /> }, { path: '/logs', element: <LogsPage /> },
{ path: '/system', element: <SystemPage /> }, { path: '/system', element: <SystemPage /> },
{ path: '/monitor', element: <MonitorPage /> },
{ path: '*', element: <Navigate to="/" replace /> }, { path: '*', element: <Navigate to="/" replace /> },
]; ];

View File

@@ -194,5 +194,14 @@ export const providersApi = {
apiClient.patch('/openai-compatibility', { index, value: serializeOpenAIProvider(value) }), apiClient.patch('/openai-compatibility', { index, value: serializeOpenAIProvider(value) }),
deleteOpenAIProvider: (name: string) => deleteOpenAIProvider: (name: string) =>
apiClient.delete(`/openai-compatibility?name=${encodeURIComponent(name)}`) apiClient.delete(`/openai-compatibility?name=${encodeURIComponent(name)}`),
// 通过 name 更新 OpenAI 兼容提供商(用于禁用模型)
patchOpenAIProviderByName: (name: string, value: Partial<OpenAIProviderConfig>) => {
const payload: Record<string, any> = {};
if (value.models !== undefined) {
payload.models = serializeModelAliases(value.models);
}
return apiClient.patch('/openai-compatibility', { name, value: payload });
}
}; };

View File

@@ -9,4 +9,5 @@ export { useAuthStore } from './useAuthStore';
export { useConfigStore } from './useConfigStore'; export { useConfigStore } from './useConfigStore';
export { useModelsStore } from './useModelsStore'; export { useModelsStore } from './useModelsStore';
export { useQuotaStore } from './useQuotaStore'; export { useQuotaStore } from './useQuotaStore';
export { useDisabledModelsStore } from './useDisabledModelsStore';
export { useOpenAIEditDraftStore } from './useOpenAIEditDraftStore'; export { useOpenAIEditDraftStore } from './useOpenAIEditDraftStore';

View File

@@ -0,0 +1,50 @@
/**
* 禁用模型状态管理
* 全局管理已禁用的模型,确保所有组件状态同步
*/
import { create } from 'zustand';
interface DisabledModelsState {
/** 已禁用的模型集合,格式:`${source}|||${model}` */
disabledModels: Set<string>;
/** 添加禁用模型 */
addDisabledModel: (source: string, model: string) => void;
/** 移除禁用模型(恢复) */
removeDisabledModel: (source: string, model: string) => void;
/** 检查模型是否已禁用 */
isDisabled: (source: string, model: string) => boolean;
/** 清空所有禁用状态 */
clearAll: () => void;
}
export const useDisabledModelsStore = create<DisabledModelsState>()((set, get) => ({
disabledModels: new Set<string>(),
addDisabledModel: (source, model) => {
const key = `${source}|||${model}`;
set((state) => {
const newSet = new Set(state.disabledModels);
newSet.add(key);
return { disabledModels: newSet };
});
},
removeDisabledModel: (source, model) => {
const key = `${source}|||${model}`;
set((state) => {
const newSet = new Set(state.disabledModels);
newSet.delete(key);
return { disabledModels: newSet };
});
},
isDisabled: (source, model) => {
const key = `${source}|||${model}`;
return get().disabledModels.has(key);
},
clearAll: () => {
set({ disabledModels: new Set() });
},
}));

View File

@@ -3,7 +3,7 @@
*/ */
import { create } from 'zustand'; import { create } from 'zustand';
import type { AntigravityQuotaState, CodexQuotaState, GeminiCliQuotaState } from '@/types'; import type { AntigravityQuotaState, CodexQuotaState, GeminiCliQuotaState, KiroQuotaState } from '@/types';
type QuotaUpdater<T> = T | ((prev: T) => T); type QuotaUpdater<T> = T | ((prev: T) => T);
@@ -11,9 +11,11 @@ interface QuotaStoreState {
antigravityQuota: Record<string, AntigravityQuotaState>; antigravityQuota: Record<string, AntigravityQuotaState>;
codexQuota: Record<string, CodexQuotaState>; codexQuota: Record<string, CodexQuotaState>;
geminiCliQuota: Record<string, GeminiCliQuotaState>; geminiCliQuota: Record<string, GeminiCliQuotaState>;
kiroQuota: Record<string, KiroQuotaState>;
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void; setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void; setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void; setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
setKiroQuota: (updater: QuotaUpdater<Record<string, KiroQuotaState>>) => void;
clearQuotaCache: () => void; clearQuotaCache: () => void;
} }
@@ -28,6 +30,7 @@ export const useQuotaStore = create<QuotaStoreState>((set) => ({
antigravityQuota: {}, antigravityQuota: {},
codexQuota: {}, codexQuota: {},
geminiCliQuota: {}, geminiCliQuota: {},
kiroQuota: {},
setAntigravityQuota: (updater) => setAntigravityQuota: (updater) =>
set((state) => ({ set((state) => ({
antigravityQuota: resolveUpdater(updater, state.antigravityQuota) antigravityQuota: resolveUpdater(updater, state.antigravityQuota)
@@ -40,10 +43,15 @@ export const useQuotaStore = create<QuotaStoreState>((set) => ({
set((state) => ({ set((state) => ({
geminiCliQuota: resolveUpdater(updater, state.geminiCliQuota) geminiCliQuota: resolveUpdater(updater, state.geminiCliQuota)
})), })),
setKiroQuota: (updater) =>
set((state) => ({
kiroQuota: resolveUpdater(updater, state.kiroQuota)
})),
clearQuotaCache: () => clearQuotaCache: () =>
set({ set({
antigravityQuota: {}, antigravityQuota: {},
codexQuota: {}, codexQuota: {},
geminiCliQuota: {} geminiCliQuota: {},
kiroQuota: {}
}) })
})); }));

View File

@@ -116,14 +116,42 @@ textarea {
.card-header { .card-header {
display: flex; display: flex;
align-items: center; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
margin-bottom: $spacing-md; margin-bottom: $spacing-md;
gap: 12px;
flex-wrap: wrap;
@media (max-width: 768px) {
flex-direction: column;
gap: 10px;
}
.card-title-group {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
flex: 1;
}
.title { .title {
font-size: 18px; font-size: 18px;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
@media (max-width: 768px) {
font-size: 16px;
}
}
.subtitle {
font-size: 13px;
color: var(--text-secondary);
@media (max-width: 768px) {
font-size: 12px;
}
} }
} }

View File

@@ -145,3 +145,58 @@ export interface CodexQuotaState {
error?: string; error?: string;
errorStatus?: number; errorStatus?: number;
} }
// Kiro (AWS CodeWhisperer) quota types
export interface KiroFreeTrialInfo {
freeTrialStatus?: string;
usageLimit?: number;
currentUsage?: number;
usageLimitWithPrecision?: number;
currentUsageWithPrecision?: number;
}
export interface KiroUsageBreakdown {
usageLimit?: number;
currentUsage?: number;
usageLimitWithPrecision?: number;
currentUsageWithPrecision?: number;
nextDateReset?: number;
displayName?: string;
resourceType?: string;
freeTrialInfo?: KiroFreeTrialInfo;
}
export interface KiroQuotaPayload {
daysUntilReset?: number;
nextDateReset?: number;
userInfo?: {
email?: string;
userId?: string;
};
subscriptionInfo?: {
subscriptionTitle?: string;
type?: string;
};
usageBreakdownList?: KiroUsageBreakdown[];
}
export interface KiroQuotaState {
status: 'idle' | 'loading' | 'success' | 'error';
// Base quota (原本额度)
baseUsage: number | null;
baseLimit: number | null;
baseRemaining: number | null;
// Free trial/bonus quota (赠送额度)
bonusUsage: number | null;
bonusLimit: number | null;
bonusRemaining: number | null;
bonusStatus?: string;
// Total (合计)
currentUsage: number | null;
usageLimit: number | null;
remainingCredits: number | null;
nextReset?: string;
subscriptionType?: string;
error?: string;
errorStatus?: number;
}

264
src/utils/monitor.ts Normal file
View File

@@ -0,0 +1,264 @@
/**
* 监控中心公共工具函数
*/
import type { UsageData } from '@/pages/MonitorPage';
/**
* 日期范围接口
*/
export interface DateRange {
start: Date;
end: Date;
}
/**
* 禁用模型状态接口
*/
export interface DisableState {
source: string;
model: string;
displayName: string;
step: number;
}
/**
* 脱敏 API Key
* @param key API Key 字符串
* @returns 脱敏后的字符串
*/
export function maskSecret(key: string): string {
if (!key || key === '-' || key === 'unknown') return key || '-';
if (key.length <= 8) {
return `${key.slice(0, 4)}***`;
}
return `${key.slice(0, 4)}***${key.slice(-4)}`;
}
/**
* 解析渠道名称(返回 provider 名称)
* @param source 来源标识
* @param providerMap 渠道映射表
* @returns provider 名称或 null
*/
export function resolveProvider(
source: string,
providerMap: Record<string, string>
): string | null {
if (!source || source === '-' || source === 'unknown') return null;
// 首先尝试完全匹配
if (providerMap[source]) {
return providerMap[source];
}
// 然后尝试前缀匹配(双向)
const entries = Object.entries(providerMap);
for (const [key, provider] of entries) {
if (source.startsWith(key) || key.startsWith(source)) {
return provider;
}
}
return null;
}
/**
* 格式化渠道显示名称:渠道名 (脱敏后的api-key)
* @param source 来源标识
* @param providerMap 渠道映射表
* @returns 格式化后的显示名称
*/
export function formatProviderDisplay(
source: string,
providerMap: Record<string, string>
): string {
if (!source || source === '-' || source === 'unknown') {
return source || '-';
}
const provider = resolveProvider(source, providerMap);
const masked = maskSecret(source);
if (!provider) return masked;
return `${provider} (${masked})`;
}
/**
* 获取渠道显示信息(分离渠道名和秘钥)
* @param source 来源标识
* @param providerMap 渠道映射表
* @returns 包含渠道名和秘钥的对象
*/
export function getProviderDisplayParts(
source: string,
providerMap: Record<string, string>
): { provider: string | null; masked: string } {
if (!source || source === '-' || source === 'unknown') {
return { provider: null, masked: source || '-' };
}
const provider = resolveProvider(source, providerMap);
const masked = maskSecret(source);
return { provider, masked };
}
/**
* 格式化时间戳为日期时间字符串
* @param timestamp 时间戳(毫秒数或 ISO 字符串)
* @returns 格式化后的日期时间字符串
*/
export function formatTimestamp(timestamp: number | string): string {
if (!timestamp) return '-';
const date = typeof timestamp === 'string' ? new Date(timestamp) : new Date(timestamp);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
/**
* 获取成功率对应的样式类名
* @param rate 成功率0-100
* @param styles 样式模块对象
* @returns 样式类名
*/
export function getRateClassName(
rate: number,
styles: Record<string, string>
): string {
if (rate >= 90) return styles.rateHigh || '';
if (rate >= 70) return styles.rateMedium || '';
return styles.rateLow || '';
}
/**
* 检查模型是否在配置中可用(未被移除)
* @param source 来源标识
* @param modelAlias 模型别名
* @param providerModels 渠道模型映射表
* @returns 是否可用
*/
export function isModelEnabled(
source: string,
modelAlias: string,
providerModels: Record<string, Set<string>>
): boolean {
if (!source || !modelAlias) return true; // 无法判断时默认显示
// 首先尝试完全匹配
if (providerModels[source]) {
return providerModels[source].has(modelAlias);
}
// 然后尝试前缀匹配
const entries = Object.entries(providerModels);
for (const [key, modelSet] of entries) {
if (source.startsWith(key) || key.startsWith(source)) {
return modelSet.has(modelAlias);
}
}
return true; // 找不到渠道配置时默认显示
}
/**
* 检查模型是否已禁用(会话中禁用或配置中已移除)
* @param source 来源标识
* @param model 模型名称
* @param disabledModels 已禁用模型集合
* @param providerModels 渠道模型映射表
* @returns 是否已禁用
*/
export function isModelDisabled(
source: string,
model: string,
disabledModels: Set<string>,
providerModels: Record<string, Set<string>>
): boolean {
// 首先检查会话中是否已禁用
if (disabledModels.has(`${source}|||${model}`)) {
return true;
}
// 然后检查配置中是否已移除
return !isModelEnabled(source, model, providerModels);
}
/**
* 创建禁用状态对象
* @param source 来源标识
* @param model 模型名称
* @param providerMap 渠道映射表
* @returns 禁用状态对象
*/
export function createDisableState(
source: string,
model: string,
providerMap: Record<string, string>
): DisableState {
const providerName = resolveProvider(source, providerMap);
const displayName = providerName
? `${providerName} / ${model}`
: `${maskSecret(source)} / ${model}`;
return { source, model, displayName, step: 1 };
}
/**
* 时间范围类型
*/
export type TimeRangeValue = number | 'custom';
/**
* 根据时间范围过滤数据
* @param data 原始数据
* @param timeRange 时间范围(天数或 'custom'
* @param customRange 自定义日期范围
* @returns 过滤后的数据
*/
export function filterDataByTimeRange(
data: UsageData | null,
timeRange: TimeRangeValue,
customRange?: DateRange
): UsageData | null {
if (!data?.apis) return null;
const now = new Date();
let cutoffStart: Date;
let cutoffEnd: Date = new Date(now.getTime());
cutoffEnd.setHours(23, 59, 59, 999);
if (timeRange === 'custom' && customRange) {
cutoffStart = customRange.start;
cutoffEnd = customRange.end;
} else if (typeof timeRange === 'number') {
cutoffStart = new Date(now.getTime() - timeRange * 24 * 60 * 60 * 1000);
cutoffStart.setHours(0, 0, 0, 0);
} else {
cutoffStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
cutoffStart.setHours(0, 0, 0, 0);
}
const filtered: UsageData = { apis: {} };
Object.entries(data.apis).forEach(([apiKey, apiData]) => {
if (!apiData?.models) return;
const filteredModels: Record<string, { details: UsageData['apis'][string]['models'][string]['details'] }> = {};
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
if (!modelData?.details || !Array.isArray(modelData.details)) return;
const filteredDetails = modelData.details.filter((detail) => {
const timestamp = new Date(detail.timestamp);
return timestamp >= cutoffStart && timestamp <= cutoffEnd;
});
if (filteredDetails.length > 0) {
filteredModels[modelName] = { details: filteredDetails };
}
});
if (Object.keys(filteredModels).length > 0) {
filtered.apis[apiKey] = { models: filteredModels };
}
});
return filtered;
}

View File

@@ -38,6 +38,10 @@ export const TYPE_COLORS: Record<string, TypeColorSet> = {
light: { bg: '#e0f7fa', text: '#006064' }, light: { bg: '#e0f7fa', text: '#006064' },
dark: { bg: '#004d40', text: '#80deea' }, dark: { bg: '#004d40', text: '#80deea' },
}, },
kiro: {
light: { bg: '#fff8e1', text: '#ff8f00' },
dark: { bg: '#ff6f00', text: '#ffe082' },
},
iflow: { iflow: {
light: { bg: '#f3e5f5', text: '#7b1fa2' }, light: { bg: '#f3e5f5', text: '#7b1fa2' },
dark: { bg: '#4a148c', text: '#ce93d8' }, dark: { bg: '#4a148c', text: '#ce93d8' },
@@ -149,3 +153,15 @@ export const CODEX_REQUEST_HEADERS = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'User-Agent': 'codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal', 'User-Agent': 'codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal',
}; };
// Kiro (AWS CodeWhisperer) API configuration
export const KIRO_QUOTA_URL =
'https://codewhisperer.us-east-1.amazonaws.com/getUsageLimits?isEmailRequired=true&origin=AI_EDITOR&resourceType=AGENTIC_REQUEST';
export const KIRO_REQUEST_HEADERS = {
Authorization: 'Bearer $TOKEN$',
'x-amz-user-agent': 'aws-sdk-js/1.0.0 KiroIDE-0.6.18-cpamc',
'User-Agent': 'aws-sdk-js/1.0.0 ua/2.1 os/windows lang/js md/nodejs#20.16.0 api/codewhispererruntime#1.0.0 m/E KiroIDE-0.6.18-cpamc',
'amz-sdk-request': 'attempt=1; max=1',
Connection: 'close',
};

View File

@@ -2,7 +2,7 @@
* Normalization and parsing functions for quota data. * Normalization and parsing functions for quota data.
*/ */
import type { CodexUsagePayload, GeminiCliQuotaPayload } from '@/types'; import type { CodexUsagePayload, GeminiCliQuotaPayload, KiroQuotaPayload } from '@/types';
export function normalizeAuthIndexValue(value: unknown): string | null { export function normalizeAuthIndexValue(value: unknown): string | null {
if (typeof value === 'number' && Number.isFinite(value)) { if (typeof value === 'number' && Number.isFinite(value)) {
@@ -151,3 +151,20 @@ export function parseGeminiCliQuotaPayload(payload: unknown): GeminiCliQuotaPayl
} }
return null; return null;
} }
export function parseKiroQuotaPayload(payload: unknown): KiroQuotaPayload | null {
if (payload === undefined || payload === null) return null;
if (typeof payload === 'string') {
const trimmed = payload.trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed) as KiroQuotaPayload;
} catch {
return null;
}
}
if (typeof payload === 'object') {
return payload as KiroQuotaPayload;
}
return null;
}

View File

@@ -22,6 +22,10 @@ export function isGeminiCliFile(file: AuthFileItem): boolean {
return resolveAuthProvider(file) === 'gemini-cli'; return resolveAuthProvider(file) === 'gemini-cli';
} }
export function isKiroFile(file: AuthFileItem): boolean {
return resolveAuthProvider(file) === 'kiro';
}
export function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean { export function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean {
const raw = file['runtime_only'] ?? file.runtimeOnly; const raw = file['runtime_only'] ?? file.runtimeOnly;
if (typeof raw === 'boolean') return raw; if (typeof raw === 'boolean') return raw;