mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 19:30:51 +08:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
684502c8b6 | ||
|
|
0aee78c072 | ||
|
|
8780ea7ec5 | ||
|
|
40fe33aeae | ||
|
|
2a94be08fa | ||
|
|
0758cfe08a | ||
|
|
02a01e5afc | ||
|
|
561e06503c | ||
|
|
94962158ef | ||
|
|
68974ffc68 | ||
|
|
f8ed787f92 | ||
|
|
dea106cf47 | ||
|
|
76ef1b68af | ||
|
|
39a003bdd4 | ||
|
|
b1426ccefc | ||
|
|
a9df58cba7 | ||
|
|
f6563490a6 | ||
|
|
18c1ba6c3c | ||
|
|
c2627cac3e | ||
|
|
df472119e7 |
35
.github/workflows/release.yml
vendored
35
.github/workflows/release.yml
vendored
@@ -15,6 +15,9 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
fetch-tags: true
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -36,27 +39,25 @@ jobs:
|
|||||||
mv index.html management.html
|
mv index.html management.html
|
||||||
ls -lh management.html
|
ls -lh management.html
|
||||||
|
|
||||||
|
- name: Generate release notes
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
current_tag="${GITHUB_REF_NAME}"
|
||||||
|
previous_tag="$(git tag --list 'v*' --sort=-v:refname | grep -v "^${current_tag}$" | head -n 1 || true)"
|
||||||
|
if [ -n "${previous_tag}" ]; then
|
||||||
|
range="${previous_tag}..${current_tag}"
|
||||||
|
else
|
||||||
|
range="${current_tag}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
: > release-notes.md
|
||||||
|
git log --pretty=format:"- %h %s" "${range}" >> release-notes.md
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
with:
|
with:
|
||||||
files: dist/management.html
|
files: dist/management.html
|
||||||
body: |
|
body_path: release-notes.md
|
||||||
## CLI Proxy API Management Center - ${{ github.ref_name }}
|
|
||||||
|
|
||||||
### Download and Usage
|
|
||||||
1. Download the `management.html` file
|
|
||||||
2. Open it directly in your browser
|
|
||||||
3. All assets (CSS, JavaScript, images) are bundled into this single file
|
|
||||||
|
|
||||||
### Features
|
|
||||||
- Single file, no external dependencies required
|
|
||||||
- Complete management interface for CLI Proxy API
|
|
||||||
- Support for local and remote connections
|
|
||||||
- Multi-language support (Chinese/English)
|
|
||||||
- Dark/Light theme support
|
|
||||||
|
|
||||||
---
|
|
||||||
🤖 Generated with GitHub Actions
|
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
env:
|
env:
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,6 +17,7 @@ dist-ssr
|
|||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
|
settings.local.json
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
.idea
|
.idea
|
||||||
|
|||||||
212
README.md
212
README.md
@@ -1,6 +1,6 @@
|
|||||||
# CLI Proxy API Management Center
|
# CLI Proxy API Management Center
|
||||||
|
|
||||||
A modern React-based WebUI for managing the CLI Proxy API, completely refactored with a modern tech stack for enhanced maintainability, type safety, and user experience.
|
A single-file WebUI (React + TypeScript) for operating and troubleshooting the **CLI Proxy API** via its **Management API** (config, credentials, logs, and usage).
|
||||||
|
|
||||||
[中文文档](README_CN.md)
|
[中文文档](README_CN.md)
|
||||||
|
|
||||||
@@ -10,181 +10,121 @@ A modern React-based WebUI for managing the CLI Proxy API, completely refactored
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Features
|
## What this is (and isn’t)
|
||||||
|
|
||||||
### Core Capabilities
|
- 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.
|
||||||
|
|
||||||
- **Login & Authentication**: Auto-detects current address (manual override supported), encrypted auto-login with secure localStorage, session persistence
|
## Quick start
|
||||||
- **Basic Settings**: Debug mode, proxy URL, request retries with custom config, quota fallback (auto-switch project/preview models), usage statistics toggle, request logging & file logging, WebSocket `/ws/*` authentication
|
|
||||||
- **API Keys Management**: Manage proxy auth keys with add/edit/delete operations
|
|
||||||
- **AI Providers**: Configure Gemini/Codex/Claude settings, OpenAI-compatible providers with custom base URLs/headers/proxy/model aliases, Vertex AI credential import from service-account JSON
|
|
||||||
- **Auth Files & OAuth**: Upload/download/search/paginate JSON credentials; type filters (Qwen/Gemini/GeminiCLI/AIStudio/Claude/Codex/Antigravity/iFlow/Vertex/Empty); bulk delete; OAuth/Device flows for multiple providers
|
|
||||||
- **Logs Viewer**: Real-time log viewer with auto-refresh, download and clear capabilities (appears when logging-to-file is enabled)
|
|
||||||
- **Usage Analytics**: Overview cards, hourly/daily toggles, interactive charts with multiple model lines, per-API statistics table
|
|
||||||
- **Config Management**: In-browser YAML editor for `/config.yaml` with syntax highlighting, reload/save functionality
|
|
||||||
- **System Information**: Connection status, config cache, server version/build date, UI version in footer
|
|
||||||
|
|
||||||
### User Experience
|
### Option A: Use the WebUI bundled in CLIProxyAPI (recommended)
|
||||||
|
|
||||||
- **Responsive Design**: Full mobile support with collapsible sidebar
|
1. Start your CLI Proxy API service.
|
||||||
- **Theme System**: Light/dark mode with persistent preference
|
2. Open: `http://<host>:<api_port>/management.html`
|
||||||
- **Internationalization**: English and Simplified Chinese (zh-CN) with seamless switching
|
3. Enter your **management key** and connect.
|
||||||
- **Real-time Feedback**: Toast notifications for all operations
|
|
||||||
- **Security**: Masked secrets, encrypted local storage
|
|
||||||
|
|
||||||
## Tech Stack
|
The address is auto-detected from the current page URL; manual override is supported.
|
||||||
|
|
||||||
- **Frontend Framework**: React 19 with TypeScript
|
### Option B: Run the dev server
|
||||||
- **Build Tool**: Vite 7 with single-file output ([vite-plugin-singlefile](https://github.com/nicknisi/vite-plugin-singlefile))
|
|
||||||
- **State Management**: [Zustand](https://github.com/pmndrs/zustand) for global stores
|
|
||||||
- **Routing**: React Router 7 with HashRouter
|
|
||||||
- **HTTP Client**: Axios with interceptors for auth & error handling
|
|
||||||
- **Internationalization**: i18next + react-i18next
|
|
||||||
- **Styling**: SCSS with CSS Modules, CSS Variables for theming
|
|
||||||
- **Charts**: Chart.js + react-chartjs-2
|
|
||||||
- **Code Editor**: @uiw/react-codemirror with YAML support
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Node.js 18+ (LTS recommended)
|
|
||||||
- npm 9+
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repository
|
|
||||||
git clone https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
|
|
||||||
cd Cli-Proxy-API-Management-Center
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
npm install
|
npm install
|
||||||
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Development
|
Open `http://localhost:5173`, then connect to your CLI Proxy API instance.
|
||||||
|
|
||||||
|
### Option C: Build a single HTML file
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev # Start Vite dev server (default: http://localhost:5173)
|
npm install
|
||||||
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build
|
- 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`
|
||||||
|
|
||||||
```bash
|
Tip: opening `dist/index.html` via `file://` may be blocked by browser CORS; serving it (preview/static server) is more reliable.
|
||||||
npm run build # TypeScript check + Vite production build
|
|
||||||
```
|
|
||||||
|
|
||||||
The build outputs a single `dist/index.html` file with all assets inlined.
|
## Connecting to the server
|
||||||
|
|
||||||
### Other Commands
|
### API address
|
||||||
|
|
||||||
```bash
|
You can enter any of the following; the UI will normalize it:
|
||||||
npm run preview # Preview production build locally
|
|
||||||
npm run lint # ESLint with strict mode (--max-warnings 0)
|
|
||||||
npm run format # Prettier formatting for src/**/*.{ts,tsx,css,scss}
|
|
||||||
npm run type-check # TypeScript type checking only (tsc --noEmit)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
- `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)
|
||||||
|
|
||||||
### Access Methods
|
### Management key (not the same as API keys)
|
||||||
|
|
||||||
1. **Integrated with CLI Proxy API (Recommended)**
|
The management key is sent with every request as:
|
||||||
After starting the CLI Proxy API service, visit `http://your-server:8317/management.html`
|
|
||||||
|
|
||||||
2. **Standalone (Built file)**
|
- `Authorization: Bearer <MANAGEMENT_KEY>` (default)
|
||||||
Open the built `dist/index.html` directly in a browser, or host it on any static file server
|
|
||||||
|
|
||||||
3. **Development Server**
|
This is different from the proxy `api-keys` you manage inside the UI (those are for client requests to the proxy endpoints).
|
||||||
Run `npm run dev` and open `http://localhost:5173`
|
|
||||||
|
|
||||||
### Initial Configuration
|
### Remote management
|
||||||
|
|
||||||
1. The login page auto-detects the current address; you can modify it if needed
|
If you connect from a non-localhost browser, the server must allow remote management (e.g. `allow-remote-management: true`).
|
||||||
2. Enter your management key
|
See `api.md` for the full authentication rules, server-side limits, and edge cases.
|
||||||
3. Click Connect to authenticate
|
|
||||||
4. Credentials are encrypted and saved locally for auto-login
|
|
||||||
|
|
||||||
> **Tip**: The Logs navigation item appears only after enabling "Logging to file" in Basic Settings.
|
## What you can manage (mapped to the UI pages)
|
||||||
|
|
||||||
## Project Structure
|
- **Dashboard**: connection status, server version/build date, quick counts, model availability snapshot.
|
||||||
|
- **Basic Settings**: debug, proxy URL, request retry, quota fallback (switch project/preview models), usage statistics, request logging, file logging, WebSocket auth.
|
||||||
|
- **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
|
||||||
├── src/
|
|
||||||
│ ├── components/
|
|
||||||
│ │ ├── common/ # Shared components (NotificationContainer)
|
|
||||||
│ │ ├── layout/ # App shell (MainLayout with sidebar)
|
|
||||||
│ │ └── ui/ # Reusable UI primitives (Button, Input, Modal, etc.)
|
|
||||||
│ ├── hooks/ # Custom hooks (useApi, useDebounce, usePagination, etc.)
|
|
||||||
│ ├── i18n/
|
|
||||||
│ │ ├── locales/ # Translation files (zh-CN.json, en.json)
|
|
||||||
│ │ └── index.ts # i18next configuration
|
|
||||||
│ ├── pages/ # Route page components with co-located .module.scss
|
|
||||||
│ ├── router/ # ProtectedRoute wrapper
|
|
||||||
│ ├── services/
|
|
||||||
│ │ ├── api/ # API layer (client.ts singleton, feature modules)
|
|
||||||
│ │ └── storage/ # Secure storage utilities
|
|
||||||
│ ├── stores/ # Zustand stores (auth, config, theme, language, notification)
|
|
||||||
│ ├── styles/ # Global SCSS (variables, mixins, themes, components)
|
|
||||||
│ ├── types/ # TypeScript type definitions
|
|
||||||
│ ├── utils/ # Utility functions (constants, format, validation, etc.)
|
|
||||||
│ ├── App.tsx # Root component with routing
|
|
||||||
│ └── main.tsx # Entry point
|
|
||||||
├── dist/ # Build output (single-file bundle)
|
|
||||||
├── vite.config.ts # Vite configuration
|
|
||||||
├── tsconfig.json # TypeScript configuration
|
|
||||||
└── package.json
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Architecture Patterns
|
- 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).
|
||||||
|
|
||||||
- **Path Alias**: Use `@/` to import from `src/` (configured in vite.config.ts and tsconfig.json)
|
## Security notes
|
||||||
- **API Client**: Singleton `apiClient` in `src/services/api/client.ts` with auth interceptors
|
|
||||||
- **State Management**: Zustand stores with localStorage persistence for auth/theme/language
|
- The management key is stored in browser `localStorage` using a lightweight obfuscation format (`enc::v1::...`) to avoid plaintext storage; treat it as sensitive.
|
||||||
- **Styling**: SCSS variables auto-injected; CSS Modules for component-scoped styles
|
- Use a dedicated browser profile/device for management. Be cautious when enabling remote management and evaluate its exposure surface.
|
||||||
- **Build Output**: Single-file bundle for easy distribution (all assets inlined)
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Connection Issues
|
- **Can’t 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.
|
||||||
|
|
||||||
1. Confirm the CLI Proxy API service is running
|
## Development
|
||||||
2. Check if the API address is correct
|
|
||||||
3. Verify that the management key is valid
|
|
||||||
4. Ensure your firewall allows the connection
|
|
||||||
|
|
||||||
### Data Not Updating
|
```bash
|
||||||
|
npm run dev # Vite dev server
|
||||||
1. Click the "Refresh All" button in the header
|
npm run build # tsc + Vite build
|
||||||
2. Check your network connection
|
npm run preview # serve dist locally
|
||||||
3. Open browser DevTools console for error details
|
npm run lint # ESLint (fails on warnings)
|
||||||
|
npm run format # Prettier
|
||||||
### Logs & Config Editor
|
npm run type-check # tsc --noEmit
|
||||||
|
```
|
||||||
- **Logs**: Requires server-side logging-to-file enabled; 404 indicates old server version or logging disabled
|
|
||||||
- **Config Editor**: Requires `/config.yaml` endpoint; ensure valid YAML syntax before saving
|
|
||||||
|
|
||||||
### Usage Statistics
|
|
||||||
|
|
||||||
- If charts are empty, enable "Usage statistics" in settings; data resets on server restart
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
We welcome Issues and Pull Requests! Please follow these guidelines:
|
Issues and PRs are welcome. Please include:
|
||||||
|
|
||||||
1. Fork the repository
|
- Reproduction steps (server version + UI version)
|
||||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
- Screenshots for UI changes
|
||||||
3. Commit your changes with clear messages
|
- Verification notes (`npm run lint`, `npm run type-check`)
|
||||||
4. Push to your branch
|
|
||||||
5. Open a Pull Request
|
|
||||||
|
|
||||||
### Development Guidelines
|
|
||||||
|
|
||||||
- Run `npm run lint` and `npm run type-check` before committing
|
|
||||||
- Follow existing code patterns and naming conventions
|
|
||||||
- Use TypeScript strict mode
|
|
||||||
- Write meaningful commit messages
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the MIT License.
|
MIT
|
||||||
|
|||||||
250
README_CN.md
250
README_CN.md
@@ -1,6 +1,6 @@
|
|||||||
# CLI Proxy API 管理中心
|
# CLI Proxy API 管理中心
|
||||||
|
|
||||||
用于管理 CLI Proxy API 的现代化 React Web 界面,采用全新技术栈重构,提供更好的可维护性、类型安全性和用户体验。
|
用于管理与排障 **CLI Proxy API** 的单文件 WebUI(React + TypeScript),通过 **Management API** 完成配置、凭据、日志与统计等运维工作。
|
||||||
|
|
||||||
[English](README.md)
|
[English](README.md)
|
||||||
|
|
||||||
@@ -8,183 +8,123 @@
|
|||||||
**示例地址**: https://remote.router-for.me/
|
**示例地址**: https://remote.router-for.me/
|
||||||
**最低版本要求**: ≥ 6.3.0(推荐 ≥ 6.5.0)
|
**最低版本要求**: ≥ 6.3.0(推荐 ≥ 6.5.0)
|
||||||
|
|
||||||
自 6.0.19 版本起,WebUI 已集成到主程序中,启动服务后可通过 `/management.html` 访问。
|
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`)读取/修改配置、上传凭据、查看日志与使用统计。
|
||||||
|
- 它 **不是** 代理本体,不参与流量转发。
|
||||||
- **登录与认证**: 自动检测当前地址(支持手动修改),加密自动登录,会话持久化
|
|
||||||
- **基础设置**: 调试模式、代理 URL、请求重试配置、配额溢出自动切换项目/预览模型、使用统计开关、请求日志与文件日志、WebSocket `/ws/*` 鉴权
|
|
||||||
- **API 密钥管理**: 管理代理认证密钥,支持添加/编辑/删除操作
|
|
||||||
- **AI 提供商**: 配置 Gemini/Codex/Claude,OpenAI 兼容提供商(自定义 Base URL/Headers/代理/模型别名),Vertex AI 服务账号 JSON 导入
|
|
||||||
- **认证文件与 OAuth**: 上传/下载/搜索/分页 JSON 凭据;类型筛选(Qwen/Gemini/GeminiCLI/AIStudio/Claude/Codex/Antigravity/iFlow/Vertex/Empty);批量删除;多提供商 OAuth/设备码流程
|
|
||||||
- **日志查看**: 实时日志查看,支持自动刷新、下载和清空(启用"写入日志文件"后显示)
|
|
||||||
- **使用统计**: 概览卡片、小时/天切换、多模型交互式图表、按 API 统计表格
|
|
||||||
- **配置管理**: 内置 YAML 编辑器,支持 `/config.yaml` 语法高亮、重载/保存
|
|
||||||
- **系统信息**: 连接状态、配置缓存、服务器版本/构建时间、底栏显示 UI 版本
|
|
||||||
|
|
||||||
### 用户体验
|
|
||||||
|
|
||||||
- **响应式设计**: 完整移动端支持,可折叠侧边栏
|
|
||||||
- **主题系统**: 明/暗模式切换,偏好持久化
|
|
||||||
- **国际化**: 简体中文和英文,无缝切换
|
|
||||||
- **实时反馈**: 所有操作的消息通知
|
|
||||||
- **安全性**: 密钥遮蔽、加密本地存储
|
|
||||||
|
|
||||||
## 技术栈
|
|
||||||
|
|
||||||
- **前端框架**: React 19 + TypeScript
|
|
||||||
- **构建工具**: Vite 7,单文件输出([vite-plugin-singlefile](https://github.com/nicknisi/vite-plugin-singlefile))
|
|
||||||
- **状态管理**: [Zustand](https://github.com/pmndrs/zustand)
|
|
||||||
- **路由**: React Router 7 (HashRouter)
|
|
||||||
- **HTTP 客户端**: Axios,带认证和错误处理拦截器
|
|
||||||
- **国际化**: i18next + react-i18next
|
|
||||||
- **样式**: SCSS + CSS Modules,CSS 变量主题
|
|
||||||
- **图表**: Chart.js + react-chartjs-2
|
|
||||||
- **代码编辑器**: @uiw/react-codemirror(YAML 支持)
|
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 环境要求
|
### 方式 A:使用 CLIProxyAPI 自带的 WebUI(推荐)
|
||||||
|
|
||||||
- Node.js 18+(推荐 LTS 版本)
|
1. 启动 CLI Proxy API 服务。
|
||||||
- npm 9+
|
2. 打开:`http://<host>:<api_port>/management.html`
|
||||||
|
3. 输入 **管理密钥** 并连接。
|
||||||
|
|
||||||
### 安装
|
页面会根据当前地址自动推断 API 地址,也支持手动修改。
|
||||||
|
|
||||||
|
### 方式 B:开发调试
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 克隆仓库
|
|
||||||
git clone https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
|
|
||||||
cd Cli-Proxy-API-Management-Center
|
|
||||||
|
|
||||||
# 安装依赖
|
|
||||||
npm install
|
npm install
|
||||||
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### 开发
|
打开 `http://localhost:5173`,然后连接到你的 CLI Proxy API 实例。
|
||||||
|
|
||||||
|
### 方式 C:构建单文件 HTML
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev # 启动 Vite 开发服务器(默认: http://localhost:5173)
|
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
|
```bash
|
||||||
npm run build # TypeScript 检查 + Vite 生产构建
|
npm run dev # 启动开发服务器
|
||||||
|
npm run build # tsc + Vite 构建
|
||||||
|
npm run preview # 本地预览 dist
|
||||||
|
npm run lint # ESLint(warnings 视为失败)
|
||||||
|
npm run format # Prettier
|
||||||
|
npm run type-check # tsc --noEmit
|
||||||
```
|
```
|
||||||
|
|
||||||
构建输出单个 `dist/index.html` 文件,所有资源已内联。
|
|
||||||
|
|
||||||
### 其他命令
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run preview # 本地预览生产构建
|
|
||||||
npm run lint # ESLint 严格模式(--max-warnings 0)
|
|
||||||
npm run format # Prettier 格式化 src/**/*.{ts,tsx,css,scss}
|
|
||||||
npm run type-check # 仅 TypeScript 类型检查(tsc --noEmit)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 使用方法
|
|
||||||
|
|
||||||
### 访问方式
|
|
||||||
|
|
||||||
1. **与 CLI Proxy API 集成使用(推荐)**
|
|
||||||
启动 CLI Proxy API 服务后,访问 `http://您的服务器:8317/management.html`
|
|
||||||
|
|
||||||
2. **独立使用(构建后文件)**
|
|
||||||
直接在浏览器打开构建的 `dist/index.html`,或部署到任意静态文件服务器
|
|
||||||
|
|
||||||
3. **开发服务器**
|
|
||||||
运行 `npm run dev` 后打开 `http://localhost:5173`
|
|
||||||
|
|
||||||
### 初始配置
|
|
||||||
|
|
||||||
1. 登录页会自动检测当前地址,可根据需要修改
|
|
||||||
2. 输入管理密钥
|
|
||||||
3. 点击连接进行认证
|
|
||||||
4. 凭据会加密保存到本地,下次自动登录
|
|
||||||
|
|
||||||
> **提示**: 只有在"基础设置"中启用"写入日志文件"后,才会显示"日志查看"导航项。
|
|
||||||
|
|
||||||
## 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
├── src/
|
|
||||||
│ ├── components/
|
|
||||||
│ │ ├── common/ # 公共组件(NotificationContainer)
|
|
||||||
│ │ ├── layout/ # 应用外壳(MainLayout 侧边栏布局)
|
|
||||||
│ │ └── ui/ # 可复用 UI 组件(Button、Input、Modal 等)
|
|
||||||
│ ├── hooks/ # 自定义 Hooks(useApi、useDebounce、usePagination 等)
|
|
||||||
│ ├── i18n/
|
|
||||||
│ │ ├── locales/ # 翻译文件(zh-CN.json、en.json)
|
|
||||||
│ │ └── index.ts # i18next 配置
|
|
||||||
│ ├── pages/ # 路由页面组件,配套 .module.scss 样式
|
|
||||||
│ ├── router/ # ProtectedRoute 路由守卫
|
|
||||||
│ ├── services/
|
|
||||||
│ │ ├── api/ # API 层(client.ts 单例,功能模块)
|
|
||||||
│ │ └── storage/ # 安全存储工具
|
|
||||||
│ ├── stores/ # Zustand 状态管理(auth、config、theme、language、notification)
|
|
||||||
│ ├── styles/ # 全局 SCSS(variables、mixins、themes、components)
|
|
||||||
│ ├── types/ # TypeScript 类型定义
|
|
||||||
│ ├── utils/ # 工具函数(constants、format、validation 等)
|
|
||||||
│ ├── App.tsx # 根组件与路由
|
|
||||||
│ └── main.tsx # 入口文件
|
|
||||||
├── dist/ # 构建输出(单文件打包)
|
|
||||||
├── vite.config.ts # Vite 配置
|
|
||||||
├── tsconfig.json # TypeScript 配置
|
|
||||||
└── package.json
|
|
||||||
```
|
|
||||||
|
|
||||||
### 核心架构模式
|
|
||||||
|
|
||||||
- **路径别名**: 使用 `@/` 导入 `src/` 目录(在 vite.config.ts 和 tsconfig.json 中配置)
|
|
||||||
- **API 客户端**: `src/services/api/client.ts` 单例,带认证拦截器
|
|
||||||
- **状态管理**: Zustand stores,auth/theme/language 持久化到 localStorage
|
|
||||||
- **样式**: SCSS 变量自动注入;CSS Modules 实现组件作用域样式
|
|
||||||
- **构建输出**: 单文件打包,便于分发(所有资源内联)
|
|
||||||
|
|
||||||
## 故障排除
|
|
||||||
|
|
||||||
### 连接问题
|
|
||||||
|
|
||||||
1. 确认 CLI Proxy API 服务正在运行
|
|
||||||
2. 检查 API 地址是否正确
|
|
||||||
3. 验证管理密钥是否有效
|
|
||||||
4. 确认防火墙设置允许连接
|
|
||||||
|
|
||||||
### 数据不更新
|
|
||||||
|
|
||||||
1. 点击顶栏的"刷新全部"按钮
|
|
||||||
2. 检查网络连接
|
|
||||||
3. 打开浏览器开发者工具控制台查看错误信息
|
|
||||||
|
|
||||||
### 日志与配置编辑
|
|
||||||
|
|
||||||
- **日志**: 需要服务端启用写文件日志;返回 404 说明服务器版本过旧或未启用日志
|
|
||||||
- **配置编辑**: 依赖 `/config.yaml` 接口;保存前请确保 YAML 语法正确
|
|
||||||
|
|
||||||
### 使用统计
|
|
||||||
|
|
||||||
- 若图表为空,请在设置中启用"使用统计";数据在服务重启后会清空
|
|
||||||
|
|
||||||
## 贡献
|
## 贡献
|
||||||
|
|
||||||
欢迎提交 Issue 和 Pull Request!请遵循以下指南:
|
欢迎提 Issue 与 PR。建议附上:
|
||||||
|
|
||||||
1. Fork 本仓库
|
- 复现步骤(服务端版本 + UI 版本)
|
||||||
2. 创建功能分支(`git checkout -b feature/amazing-feature`)
|
- UI 改动截图
|
||||||
3. 提交更改,使用清晰的提交信息
|
- 验证记录(`npm run lint`、`npm run type-check`)
|
||||||
4. 推送到分支
|
|
||||||
5. 开启 Pull Request
|
|
||||||
|
|
||||||
### 开发规范
|
|
||||||
|
|
||||||
- 提交前运行 `npm run lint` 和 `npm run type-check`
|
|
||||||
- 遵循现有代码模式和命名规范
|
|
||||||
- 使用 TypeScript 严格模式
|
|
||||||
- 编写有意义的提交信息
|
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
本项目采用 MIT 许可证。
|
MIT
|
||||||
|
|||||||
41
src/App.tsx
41
src/App.tsx
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { HashRouter, Navigate, Route, Routes } from 'react-router-dom';
|
import { HashRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||||
import { LoginPage } from '@/pages/LoginPage';
|
import { LoginPage } from '@/pages/LoginPage';
|
||||||
|
import { DashboardPage } from '@/pages/DashboardPage';
|
||||||
import { SettingsPage } from '@/pages/SettingsPage';
|
import { SettingsPage } from '@/pages/SettingsPage';
|
||||||
import { ApiKeysPage } from '@/pages/ApiKeysPage';
|
import { ApiKeysPage } from '@/pages/ApiKeysPage';
|
||||||
import { AiProvidersPage } from '@/pages/AiProvidersPage';
|
import { AiProvidersPage } from '@/pages/AiProvidersPage';
|
||||||
@@ -11,19 +12,29 @@ 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 { NotificationContainer } from '@/components/common/NotificationContainer';
|
import { NotificationContainer } from '@/components/common/NotificationContainer';
|
||||||
|
import { SplashScreen } from '@/components/common/SplashScreen';
|
||||||
import { MainLayout } from '@/components/layout/MainLayout';
|
import { MainLayout } from '@/components/layout/MainLayout';
|
||||||
import { ProtectedRoute } from '@/router/ProtectedRoute';
|
import { ProtectedRoute } from '@/router/ProtectedRoute';
|
||||||
import { useAuthStore, useLanguageStore, useThemeStore } from '@/stores';
|
import { useAuthStore, useLanguageStore, useThemeStore } from '@/stores';
|
||||||
|
|
||||||
|
const SPLASH_DURATION = 1500;
|
||||||
|
const SPLASH_FADE_DURATION = 400;
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const initializeTheme = useThemeStore((state) => state.initializeTheme);
|
const initializeTheme = useThemeStore((state) => state.initializeTheme);
|
||||||
const language = useLanguageStore((state) => state.language);
|
const language = useLanguageStore((state) => state.language);
|
||||||
const setLanguage = useLanguageStore((state) => state.setLanguage);
|
const setLanguage = useLanguageStore((state) => state.setLanguage);
|
||||||
const restoreSession = useAuthStore((state) => state.restoreSession);
|
const restoreSession = useAuthStore((state) => state.restoreSession);
|
||||||
|
|
||||||
|
const [splashReadyToFade, setSplashReadyToFade] = useState(false);
|
||||||
|
const [showSplash, setShowSplash] = useState(true);
|
||||||
|
const [authReady, setAuthReady] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initializeTheme();
|
initializeTheme();
|
||||||
restoreSession();
|
void restoreSession().finally(() => {
|
||||||
|
setAuthReady(true);
|
||||||
|
});
|
||||||
}, [initializeTheme, restoreSession]);
|
}, [initializeTheme, restoreSession]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -31,6 +42,27 @@ function App() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []); // 仅用于首屏同步 i18n 语言
|
}, []); // 仅用于首屏同步 i18n 语言
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setSplashReadyToFade(true);
|
||||||
|
}, SPLASH_DURATION - SPLASH_FADE_DURATION);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSplashFinish = useCallback(() => {
|
||||||
|
setShowSplash(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (showSplash) {
|
||||||
|
return (
|
||||||
|
<SplashScreen
|
||||||
|
fadeOut={splashReadyToFade && authReady}
|
||||||
|
onFinish={handleSplashFinish}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<NotificationContainer />
|
<NotificationContainer />
|
||||||
@@ -44,7 +76,8 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<Navigate to="/settings" replace />} />
|
<Route index element={<DashboardPage />} />
|
||||||
|
<Route path="dashboard" element={<DashboardPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="api-keys" element={<ApiKeysPage />} />
|
<Route path="api-keys" element={<ApiKeysPage />} />
|
||||||
<Route path="ai-providers" element={<AiProvidersPage />} />
|
<Route path="ai-providers" element={<AiProvidersPage />} />
|
||||||
@@ -54,7 +87,7 @@ function App() {
|
|||||||
<Route path="config" element={<ConfigPage />} />
|
<Route path="config" element={<ConfigPage />} />
|
||||||
<Route path="logs" element={<LogsPage />} />
|
<Route path="logs" element={<LogsPage />} />
|
||||||
<Route path="system" element={<SystemPage />} />
|
<Route path="system" element={<SystemPage />} />
|
||||||
<Route path="*" element={<Navigate to="/settings" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
|
|||||||
106
src/components/common/SplashScreen.scss
Normal file
106
src/components/common/SplashScreen.scss
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
@use 'sass:color';
|
||||||
|
@use '../../styles/variables.scss' as *;
|
||||||
|
|
||||||
|
.splash-screen {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.4s ease-out;
|
||||||
|
|
||||||
|
&.fade-out {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.splash-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-md;
|
||||||
|
animation: splash-enter 0.6s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes splash-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9) translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.splash-logo {
|
||||||
|
height: 80px;
|
||||||
|
width: auto;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
box-shadow: $shadow-lg;
|
||||||
|
animation: splash-logo-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes splash-logo-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.splash-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splash-subtitle {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
margin-top: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splash-loader {
|
||||||
|
width: 120px;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: $radius-full;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splash-loader-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--primary-color);
|
||||||
|
border-radius: $radius-full;
|
||||||
|
animation: splash-loading 1.2s ease-in-out infinite;
|
||||||
|
transform-origin: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes splash-loading {
|
||||||
|
0% {
|
||||||
|
transform: scaleX(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scaleX(1);
|
||||||
|
transform-origin: left;
|
||||||
|
}
|
||||||
|
50.01% {
|
||||||
|
transform-origin: right;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/components/common/SplashScreen.tsx
Normal file
36
src/components/common/SplashScreen.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
||||||
|
import './SplashScreen.scss';
|
||||||
|
|
||||||
|
interface SplashScreenProps {
|
||||||
|
onFinish: () => void;
|
||||||
|
fadeOut?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FADE_OUT_DURATION = 400;
|
||||||
|
|
||||||
|
export function SplashScreen({ onFinish, fadeOut = false }: SplashScreenProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fadeOut) return;
|
||||||
|
const finishTimer = setTimeout(() => {
|
||||||
|
onFinish();
|
||||||
|
}, FADE_OUT_DURATION);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(finishTimer);
|
||||||
|
};
|
||||||
|
}, [fadeOut, onFinish]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`splash-screen ${fadeOut ? 'fade-out' : ''}`}>
|
||||||
|
<div className="splash-content">
|
||||||
|
<img src={INLINE_LOGO_JPEG} alt="CPAMC" className="splash-logo" />
|
||||||
|
<h1 className="splash-title">CLI Proxy API</h1>
|
||||||
|
<p className="splash-subtitle">Management Center</p>
|
||||||
|
<div className="splash-loader">
|
||||||
|
<div className="splash-loader-bar" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,12 +2,15 @@ import { ReactNode, SVGProps, useCallback, useEffect, useLayoutEffect, useRef, u
|
|||||||
import { NavLink, Outlet } from 'react-router-dom';
|
import { NavLink, Outlet } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
import {
|
import {
|
||||||
IconBot,
|
IconBot,
|
||||||
IconChartLine,
|
IconChartLine,
|
||||||
IconFileText,
|
IconFileText,
|
||||||
IconInfo,
|
IconInfo,
|
||||||
IconKey,
|
IconKey,
|
||||||
|
IconLayoutDashboard,
|
||||||
IconScrollText,
|
IconScrollText,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconShield,
|
IconShield,
|
||||||
@@ -15,9 +18,10 @@ import {
|
|||||||
} from '@/components/ui/icons';
|
} from '@/components/ui/icons';
|
||||||
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
||||||
import { useAuthStore, useConfigStore, useLanguageStore, useNotificationStore, useThemeStore } from '@/stores';
|
import { useAuthStore, useConfigStore, useLanguageStore, useNotificationStore, useThemeStore } from '@/stores';
|
||||||
import { versionApi } from '@/services/api';
|
import { configApi, versionApi } from '@/services/api';
|
||||||
|
|
||||||
const sidebarIcons: Record<string, ReactNode> = {
|
const sidebarIcons: Record<string, ReactNode> = {
|
||||||
|
dashboard: <IconLayoutDashboard size={18} />,
|
||||||
settings: <IconSlidersHorizontal size={18} />,
|
settings: <IconSlidersHorizontal size={18} />,
|
||||||
apiKeys: <IconKey size={18} />,
|
apiKeys: <IconKey size={18} />,
|
||||||
aiProviders: <IconBot size={18} />,
|
aiProviders: <IconBot size={18} />,
|
||||||
@@ -146,6 +150,7 @@ export function MainLayout() {
|
|||||||
const config = useConfigStore((state) => state.config);
|
const config = useConfigStore((state) => state.config);
|
||||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||||
const clearCache = useConfigStore((state) => state.clearCache);
|
const clearCache = useConfigStore((state) => state.clearCache);
|
||||||
|
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||||
|
|
||||||
const theme = useThemeStore((state) => state.theme);
|
const theme = useThemeStore((state) => state.theme);
|
||||||
const toggleTheme = useThemeStore((state) => state.toggleTheme);
|
const toggleTheme = useThemeStore((state) => state.toggleTheme);
|
||||||
@@ -155,11 +160,20 @@ export function MainLayout() {
|
|||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
const [checkingVersion, setCheckingVersion] = useState(false);
|
const [checkingVersion, setCheckingVersion] = useState(false);
|
||||||
const [brandExpanded, setBrandExpanded] = useState(true);
|
const [brandExpanded, setBrandExpanded] = useState(true);
|
||||||
|
const [requestLogModalOpen, setRequestLogModalOpen] = useState(false);
|
||||||
|
const [requestLogDraft, setRequestLogDraft] = useState(false);
|
||||||
|
const [requestLogTouched, setRequestLogTouched] = useState(false);
|
||||||
|
const [requestLogSaving, setRequestLogSaving] = useState(false);
|
||||||
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const headerRef = useRef<HTMLElement | null>(null);
|
const headerRef = useRef<HTMLElement | null>(null);
|
||||||
|
const versionTapCount = useRef(0);
|
||||||
|
const versionTapTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const fullBrandName = 'CLI Proxy API Management Center';
|
const fullBrandName = 'CLI Proxy API Management Center';
|
||||||
const abbrBrandName = t('title.abbr');
|
const abbrBrandName = t('title.abbr');
|
||||||
|
const requestLogEnabled = config?.requestLog ?? false;
|
||||||
|
const requestLogDirty = requestLogDraft !== requestLogEnabled;
|
||||||
|
const canEditRequestLog = connectionStatus === 'connected' && Boolean(config);
|
||||||
|
|
||||||
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
|
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -201,6 +215,20 @@ export function MainLayout() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (requestLogModalOpen && !requestLogTouched) {
|
||||||
|
setRequestLogDraft(requestLogEnabled);
|
||||||
|
}
|
||||||
|
}, [requestLogModalOpen, requestLogTouched, requestLogEnabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (versionTapTimer.current) {
|
||||||
|
clearTimeout(versionTapTimer.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleBrandClick = useCallback(() => {
|
const handleBrandClick = useCallback(() => {
|
||||||
if (!brandExpanded) {
|
if (!brandExpanded) {
|
||||||
setBrandExpanded(true);
|
setBrandExpanded(true);
|
||||||
@@ -214,6 +242,60 @@ export function MainLayout() {
|
|||||||
}
|
}
|
||||||
}, [brandExpanded]);
|
}, [brandExpanded]);
|
||||||
|
|
||||||
|
const openRequestLogModal = useCallback(() => {
|
||||||
|
setRequestLogTouched(false);
|
||||||
|
setRequestLogDraft(requestLogEnabled);
|
||||||
|
setRequestLogModalOpen(true);
|
||||||
|
}, [requestLogEnabled]);
|
||||||
|
|
||||||
|
const handleRequestLogClose = useCallback(() => {
|
||||||
|
setRequestLogModalOpen(false);
|
||||||
|
setRequestLogTouched(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleVersionTap = useCallback(() => {
|
||||||
|
versionTapCount.current += 1;
|
||||||
|
if (versionTapTimer.current) {
|
||||||
|
clearTimeout(versionTapTimer.current);
|
||||||
|
}
|
||||||
|
versionTapTimer.current = setTimeout(() => {
|
||||||
|
versionTapCount.current = 0;
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
if (versionTapCount.current >= 7) {
|
||||||
|
versionTapCount.current = 0;
|
||||||
|
if (versionTapTimer.current) {
|
||||||
|
clearTimeout(versionTapTimer.current);
|
||||||
|
versionTapTimer.current = null;
|
||||||
|
}
|
||||||
|
openRequestLogModal();
|
||||||
|
}
|
||||||
|
}, [openRequestLogModal]);
|
||||||
|
|
||||||
|
const handleRequestLogSave = async () => {
|
||||||
|
if (!canEditRequestLog) return;
|
||||||
|
if (!requestLogDirty) {
|
||||||
|
setRequestLogModalOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = requestLogEnabled;
|
||||||
|
setRequestLogSaving(true);
|
||||||
|
updateConfigValue('request-log', requestLogDraft);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await configApi.updateRequestLog(requestLogDraft);
|
||||||
|
clearCache('request-log');
|
||||||
|
showNotification(t('notification.request_log_updated'), 'success');
|
||||||
|
setRequestLogModalOpen(false);
|
||||||
|
} catch (error: any) {
|
||||||
|
updateConfigValue('request-log', previous);
|
||||||
|
showNotification(`${t('notification.update_failed')}: ${error?.message || ''}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setRequestLogSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConfig().catch(() => {
|
fetchConfig().catch(() => {
|
||||||
// ignore initial failure; login flow会提示
|
// ignore initial failure; login flow会提示
|
||||||
@@ -230,6 +312,7 @@ export function MainLayout() {
|
|||||||
: 'muted';
|
: 'muted';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
|
{ path: '/', label: t('nav.dashboard'), icon: sidebarIcons.dashboard },
|
||||||
{ path: '/settings', label: t('nav.basic_settings'), icon: sidebarIcons.settings },
|
{ path: '/settings', label: t('nav.basic_settings'), icon: sidebarIcons.settings },
|
||||||
{ path: '/api-keys', label: t('nav.api_keys'), icon: sidebarIcons.apiKeys },
|
{ path: '/api-keys', label: t('nav.api_keys'), icon: sidebarIcons.apiKeys },
|
||||||
{ path: '/ai-providers', label: t('nav.ai_providers'), icon: sidebarIcons.aiProviders },
|
{ path: '/ai-providers', label: t('nav.ai_providers'), icon: sidebarIcons.aiProviders },
|
||||||
@@ -366,7 +449,7 @@ export function MainLayout() {
|
|||||||
<span>
|
<span>
|
||||||
{t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')}
|
{t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span onClick={handleVersionTap}>
|
||||||
{t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')}
|
{t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
@@ -376,6 +459,40 @@ export function MainLayout() {
|
|||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={requestLogModalOpen}
|
||||||
|
onClose={handleRequestLogClose}
|
||||||
|
title={t('basic_settings.request_log_title')}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={handleRequestLogClose} disabled={requestLogSaving}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleRequestLogSave}
|
||||||
|
loading={requestLogSaving}
|
||||||
|
disabled={!canEditRequestLog || !requestLogDirty}
|
||||||
|
>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="request-log-modal">
|
||||||
|
<div className="status-badge warning">{t('basic_settings.request_log_warning')}</div>
|
||||||
|
<ToggleSwitch
|
||||||
|
label={t('basic_settings.request_log_enable')}
|
||||||
|
labelPosition="left"
|
||||||
|
checked={requestLogDraft}
|
||||||
|
disabled={!canEditRequestLog || requestLogSaving}
|
||||||
|
onChange={(value) => {
|
||||||
|
setRequestLogDraft(value);
|
||||||
|
setRequestLogTouched(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import type { PropsWithChildren, ReactNode } from 'react';
|
|||||||
interface CardProps {
|
interface CardProps {
|
||||||
title?: ReactNode;
|
title?: ReactNode;
|
||||||
extra?: ReactNode;
|
extra?: ReactNode;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Card({ title, extra, children }: PropsWithChildren<CardProps>) {
|
export function Card({ title, extra, children, className }: PropsWithChildren<CardProps>) {
|
||||||
return (
|
return (
|
||||||
<div 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="title">{title}</div>
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
export function LoadingSpinner({ size = 20 }: { size?: number }) {
|
export function LoadingSpinner({
|
||||||
|
size = 20,
|
||||||
|
className = ''
|
||||||
|
}: {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="loading-spinner"
|
className={`loading-spinner${className ? ` ${className}` : ''}`}
|
||||||
style={{ width: size, height: size, borderWidth: size / 7 }}
|
style={{ width: size, height: size, borderWidth: size / 7 }}
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
|
|||||||
@@ -5,15 +5,26 @@ interface ToggleSwitchProps {
|
|||||||
onChange: (value: boolean) => void;
|
onChange: (value: boolean) => void;
|
||||||
label?: ReactNode;
|
label?: ReactNode;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
labelPosition?: 'left' | 'right';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ToggleSwitch({ checked, onChange, label, disabled = false }: ToggleSwitchProps) {
|
export function ToggleSwitch({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
disabled = false,
|
||||||
|
labelPosition = 'right'
|
||||||
|
}: ToggleSwitchProps) {
|
||||||
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
onChange(event.target.checked);
|
onChange(event.target.checked);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const className = ['switch', labelPosition === 'left' ? 'switch-label-left' : '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label className="switch">
|
<label className={className}>
|
||||||
<input type="checkbox" checked={checked} onChange={handleChange} disabled={disabled} />
|
<input type="checkbox" checked={checked} onChange={handleChange} disabled={disabled} />
|
||||||
<span className="track">
|
<span className="track">
|
||||||
<span className="thumb" />
|
<span className="thumb" />
|
||||||
|
|||||||
@@ -303,3 +303,14 @@ export function IconCode({ size = 20, ...props }: IconProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IconLayoutDashboard({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<rect width="7" height="9" x="3" y="3" rx="1" />
|
||||||
|
<rect width="7" height="5" x="14" y="3" rx="1" />
|
||||||
|
<rect width="7" height="9" x="14" y="12" rx="1" />
|
||||||
|
<rect width="7" height="5" x="3" y="16" rx="1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"required": "Required",
|
"required": "Required",
|
||||||
"api_key": "Key",
|
"api_key": "Key",
|
||||||
"base_url": "Address",
|
"base_url": "Address",
|
||||||
|
"prefix": "Prefix",
|
||||||
"proxy_url": "Proxy",
|
"proxy_url": "Proxy",
|
||||||
"alias": "Alias",
|
"alias": "Alias",
|
||||||
"failure": "Failure",
|
"failure": "Failure",
|
||||||
@@ -81,6 +82,7 @@
|
|||||||
"status": "Connection Status:"
|
"status": "Connection Status:"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
|
"dashboard": "Dashboard",
|
||||||
"basic_settings": "Basic Settings",
|
"basic_settings": "Basic Settings",
|
||||||
"api_keys": "API Keys",
|
"api_keys": "API Keys",
|
||||||
"ai_providers": "AI Providers",
|
"ai_providers": "AI Providers",
|
||||||
@@ -91,6 +93,27 @@
|
|||||||
"logs": "Logs Viewer",
|
"logs": "Logs Viewer",
|
||||||
"system_info": "Management Center Info"
|
"system_info": "Management Center Info"
|
||||||
},
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Dashboard",
|
||||||
|
"subtitle": "Welcome to CLI Proxy API Management Center",
|
||||||
|
"openai_providers": "OpenAI Providers",
|
||||||
|
"quick_actions": "Quick Actions",
|
||||||
|
"current_config": "Current Configuration",
|
||||||
|
"management_keys": "Management Keys",
|
||||||
|
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} O:{{openai}}",
|
||||||
|
"oauth_credentials": "OAuth Credentials",
|
||||||
|
"usage_overview": "Usage Overview",
|
||||||
|
"total_requests": "Total Requests",
|
||||||
|
"total_tokens": "Total Tokens",
|
||||||
|
"rpm_30min": "RPM (30min)",
|
||||||
|
"tpm_30min": "TPM (30min)",
|
||||||
|
"models_used": "Models Used",
|
||||||
|
"no_usage_data": "No usage data available",
|
||||||
|
"view_detailed_usage": "View Detailed Stats",
|
||||||
|
"edit_settings": "Edit Settings",
|
||||||
|
"available_models": "Available Models",
|
||||||
|
"available_models_desc": "Total models from all providers"
|
||||||
|
},
|
||||||
"basic_settings": {
|
"basic_settings": {
|
||||||
"title": "Basic Settings",
|
"title": "Basic Settings",
|
||||||
"debug_title": "Debug Mode",
|
"debug_title": "Debug Mode",
|
||||||
@@ -110,7 +133,9 @@
|
|||||||
"usage_statistics_enable": "Enable usage statistics",
|
"usage_statistics_enable": "Enable usage statistics",
|
||||||
"logging_title": "Logging",
|
"logging_title": "Logging",
|
||||||
"logging_to_file_enable": "Enable logging to file",
|
"logging_to_file_enable": "Enable logging to file",
|
||||||
|
"request_log_title": "Request Logging",
|
||||||
"request_log_enable": "Enable request logging",
|
"request_log_enable": "Enable request logging",
|
||||||
|
"request_log_warning": "Keep this off unless you need detailed troubleshooting.",
|
||||||
"ws_auth_title": "WebSocket Authentication",
|
"ws_auth_title": "WebSocket Authentication",
|
||||||
"ws_auth_enable": "Require auth for /ws/*"
|
"ws_auth_enable": "Require auth for /ws/*"
|
||||||
},
|
},
|
||||||
@@ -149,6 +174,9 @@
|
|||||||
"excluded_models_placeholder": "Comma or newline separated, e.g. gemini-1.5-pro, gemini-1.5-flash",
|
"excluded_models_placeholder": "Comma or newline separated, e.g. gemini-1.5-pro, gemini-1.5-flash",
|
||||||
"excluded_models_hint": "Leave empty to allow all models; values are trimmed and deduplicated automatically.",
|
"excluded_models_hint": "Leave empty to allow all models; values are trimmed and deduplicated automatically.",
|
||||||
"excluded_models_count": "Excluding {{count}} models",
|
"excluded_models_count": "Excluding {{count}} models",
|
||||||
|
"prefix_label": "Prefix (Optional):",
|
||||||
|
"prefix_placeholder": "e.g.: team-a",
|
||||||
|
"prefix_hint": "When set, call models as prefix/<model> to target this entry.",
|
||||||
"config_toggle_label": "Enabled",
|
"config_toggle_label": "Enabled",
|
||||||
"config_disabled_badge": "Disabled",
|
"config_disabled_badge": "Disabled",
|
||||||
"codex_title": "Codex API Configuration",
|
"codex_title": "Codex API Configuration",
|
||||||
@@ -200,8 +228,6 @@
|
|||||||
"ampcode_upstream_api_key_current": "Current Amp official key: {{key}}",
|
"ampcode_upstream_api_key_current": "Current Amp official key: {{key}}",
|
||||||
"ampcode_clear_upstream_api_key": "Clear official key",
|
"ampcode_clear_upstream_api_key": "Clear official key",
|
||||||
"ampcode_clear_upstream_api_key_confirm": "Are you sure you want to clear the Ampcode upstream API key (Amp official)?",
|
"ampcode_clear_upstream_api_key_confirm": "Are you sure you want to clear the Ampcode upstream API key (Amp official)?",
|
||||||
"ampcode_restrict_management_label": "Restrict Amp management routes to localhost",
|
|
||||||
"ampcode_restrict_management_hint": "When enabled, Amp management routes (/api/auth, /api/user, /api/threads, etc.) only accept 127.0.0.1/::1 (recommended).",
|
|
||||||
"ampcode_force_model_mappings_label": "Force model mappings",
|
"ampcode_force_model_mappings_label": "Force model mappings",
|
||||||
"ampcode_force_model_mappings_hint": "When enabled, mappings override local API-key availability checks.",
|
"ampcode_force_model_mappings_hint": "When enabled, mappings override local API-key availability checks.",
|
||||||
"ampcode_model_mappings_label": "Model mappings (from → to)",
|
"ampcode_model_mappings_label": "Model mappings (from → to)",
|
||||||
@@ -582,6 +608,8 @@
|
|||||||
"auto_refresh_disabled": "Auto refresh disabled",
|
"auto_refresh_disabled": "Auto refresh disabled",
|
||||||
"load_more_hint": "Scroll up to load more",
|
"load_more_hint": "Scroll up to load more",
|
||||||
"hidden_lines": "Hidden: {{count}} lines",
|
"hidden_lines": "Hidden: {{count}} lines",
|
||||||
|
"loaded_lines": "Loaded: {{count}} lines",
|
||||||
|
"filtered_lines": "Filtered: {{count}} lines",
|
||||||
"hide_management_logs": "Hide {{prefix}} logs",
|
"hide_management_logs": "Hide {{prefix}} logs",
|
||||||
"search_placeholder": "Search logs by content or keyword",
|
"search_placeholder": "Search logs by content or keyword",
|
||||||
"search_empty_title": "No matching logs found",
|
"search_empty_title": "No matching logs found",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"required": "必填",
|
"required": "必填",
|
||||||
"api_key": "密钥",
|
"api_key": "密钥",
|
||||||
"base_url": "地址",
|
"base_url": "地址",
|
||||||
|
"prefix": "前缀",
|
||||||
"proxy_url": "代理",
|
"proxy_url": "代理",
|
||||||
"alias": "别名",
|
"alias": "别名",
|
||||||
"failure": "失败",
|
"failure": "失败",
|
||||||
@@ -81,6 +82,7 @@
|
|||||||
"status": "连接状态:"
|
"status": "连接状态:"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
|
"dashboard": "仪表盘",
|
||||||
"basic_settings": "基础设置",
|
"basic_settings": "基础设置",
|
||||||
"api_keys": "API 密钥",
|
"api_keys": "API 密钥",
|
||||||
"ai_providers": "AI 提供商",
|
"ai_providers": "AI 提供商",
|
||||||
@@ -91,6 +93,27 @@
|
|||||||
"logs": "日志查看",
|
"logs": "日志查看",
|
||||||
"system_info": "中心信息"
|
"system_info": "中心信息"
|
||||||
},
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "仪表盘",
|
||||||
|
"subtitle": "欢迎使用 CLI Proxy API 管理中心",
|
||||||
|
"openai_providers": "OpenAI 提供商",
|
||||||
|
"quick_actions": "快捷操作",
|
||||||
|
"current_config": "当前配置",
|
||||||
|
"management_keys": "管理密钥",
|
||||||
|
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} O:{{openai}}",
|
||||||
|
"oauth_credentials": "OAuth 凭证",
|
||||||
|
"usage_overview": "使用概览",
|
||||||
|
"total_requests": "总请求数",
|
||||||
|
"total_tokens": "总 Token 数",
|
||||||
|
"rpm_30min": "RPM (30分钟)",
|
||||||
|
"tpm_30min": "TPM (30分钟)",
|
||||||
|
"models_used": "使用模型数",
|
||||||
|
"no_usage_data": "暂无使用数据",
|
||||||
|
"view_detailed_usage": "查看详细统计",
|
||||||
|
"edit_settings": "编辑设置",
|
||||||
|
"available_models": "可用模型",
|
||||||
|
"available_models_desc": "所有提供商的模型总数"
|
||||||
|
},
|
||||||
"basic_settings": {
|
"basic_settings": {
|
||||||
"title": "基础设置",
|
"title": "基础设置",
|
||||||
"debug_title": "调试模式",
|
"debug_title": "调试模式",
|
||||||
@@ -110,7 +133,9 @@
|
|||||||
"usage_statistics_enable": "启用使用统计",
|
"usage_statistics_enable": "启用使用统计",
|
||||||
"logging_title": "日志记录",
|
"logging_title": "日志记录",
|
||||||
"logging_to_file_enable": "启用日志记录到文件",
|
"logging_to_file_enable": "启用日志记录到文件",
|
||||||
|
"request_log_title": "请求日志",
|
||||||
"request_log_enable": "启用请求日志",
|
"request_log_enable": "启用请求日志",
|
||||||
|
"request_log_warning": "仅在需要排查问题时开启,日常请保持关闭。",
|
||||||
"ws_auth_title": "WebSocket 鉴权",
|
"ws_auth_title": "WebSocket 鉴权",
|
||||||
"ws_auth_enable": "启用 /ws/* 鉴权"
|
"ws_auth_enable": "启用 /ws/* 鉴权"
|
||||||
},
|
},
|
||||||
@@ -149,6 +174,9 @@
|
|||||||
"excluded_models_placeholder": "用逗号或换行分隔,例如: gemini-1.5-pro, gemini-1.5-flash",
|
"excluded_models_placeholder": "用逗号或换行分隔,例如: gemini-1.5-pro, gemini-1.5-flash",
|
||||||
"excluded_models_hint": "留空表示不过滤;保存时会自动去重并忽略空白。",
|
"excluded_models_hint": "留空表示不过滤;保存时会自动去重并忽略空白。",
|
||||||
"excluded_models_count": "排除 {{count}} 个模型",
|
"excluded_models_count": "排除 {{count}} 个模型",
|
||||||
|
"prefix_label": "前缀 (可选):",
|
||||||
|
"prefix_placeholder": "例如: team-a",
|
||||||
|
"prefix_hint": "设置后可用 prefix/<model> 选择该条目。",
|
||||||
"config_toggle_label": "启用",
|
"config_toggle_label": "启用",
|
||||||
"config_disabled_badge": "已停用",
|
"config_disabled_badge": "已停用",
|
||||||
"codex_title": "Codex API 配置",
|
"codex_title": "Codex API 配置",
|
||||||
@@ -200,8 +228,6 @@
|
|||||||
"ampcode_upstream_api_key_current": "当前Amp官方密钥: {{key}}",
|
"ampcode_upstream_api_key_current": "当前Amp官方密钥: {{key}}",
|
||||||
"ampcode_clear_upstream_api_key": "清除官方密钥",
|
"ampcode_clear_upstream_api_key": "清除官方密钥",
|
||||||
"ampcode_clear_upstream_api_key_confirm": "确定要清除 Ampcode 的 upstream API key(Amp官方)吗?",
|
"ampcode_clear_upstream_api_key_confirm": "确定要清除 Ampcode 的 upstream API key(Amp官方)吗?",
|
||||||
"ampcode_restrict_management_label": "仅允许本机访问 Amp 管理路由",
|
|
||||||
"ampcode_restrict_management_hint": "开启后,/api/auth、/api/user、/api/threads 等 Amp 管理路由仅允许 127.0.0.1/::1 访问(推荐)。",
|
|
||||||
"ampcode_force_model_mappings_label": "强制应用模型映射",
|
"ampcode_force_model_mappings_label": "强制应用模型映射",
|
||||||
"ampcode_force_model_mappings_hint": "开启后,模型映射将覆盖本地 API Key 可用性判断。",
|
"ampcode_force_model_mappings_hint": "开启后,模型映射将覆盖本地 API Key 可用性判断。",
|
||||||
"ampcode_model_mappings_label": "模型映射 (from → to)",
|
"ampcode_model_mappings_label": "模型映射 (from → to)",
|
||||||
@@ -582,6 +608,8 @@
|
|||||||
"auto_refresh_disabled": "自动刷新已关闭",
|
"auto_refresh_disabled": "自动刷新已关闭",
|
||||||
"load_more_hint": "向上滚动加载更多",
|
"load_more_hint": "向上滚动加载更多",
|
||||||
"hidden_lines": "已隐藏 {{count}} 行",
|
"hidden_lines": "已隐藏 {{count}} 行",
|
||||||
|
"loaded_lines": "已载入 {{count}} 行",
|
||||||
|
"filtered_lines": "已过滤 {{count}} 行",
|
||||||
"hide_management_logs": "屏蔽 {{prefix}} 日志",
|
"hide_management_logs": "屏蔽 {{prefix}} 日志",
|
||||||
"search_placeholder": "搜索日志内容或关键字",
|
"search_placeholder": "搜索日志内容或关键字",
|
||||||
"search_empty_title": "未找到匹配的日志",
|
"search_empty_title": "未找到匹配的日志",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ interface ModelEntry {
|
|||||||
|
|
||||||
interface OpenAIFormState {
|
interface OpenAIFormState {
|
||||||
name: string;
|
name: string;
|
||||||
|
prefix: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
headers: HeaderEntry[];
|
headers: HeaderEntry[];
|
||||||
testModel?: string;
|
testModel?: string;
|
||||||
@@ -49,7 +50,6 @@ interface OpenAIFormState {
|
|||||||
interface AmpcodeFormState {
|
interface AmpcodeFormState {
|
||||||
upstreamUrl: string;
|
upstreamUrl: string;
|
||||||
upstreamApiKey: string;
|
upstreamApiKey: string;
|
||||||
restrictManagementToLocalhost: boolean;
|
|
||||||
forceModelMappings: boolean;
|
forceModelMappings: boolean;
|
||||||
mappingEntries: ModelEntry[];
|
mappingEntries: ModelEntry[];
|
||||||
}
|
}
|
||||||
@@ -174,7 +174,6 @@ const entriesToAmpcodeMappings = (entries: ModelEntry[]): AmpcodeModelMapping[]
|
|||||||
const buildAmpcodeFormState = (ampcode?: AmpcodeConfig | null): AmpcodeFormState => ({
|
const buildAmpcodeFormState = (ampcode?: AmpcodeConfig | null): AmpcodeFormState => ({
|
||||||
upstreamUrl: ampcode?.upstreamUrl ?? '',
|
upstreamUrl: ampcode?.upstreamUrl ?? '',
|
||||||
upstreamApiKey: '',
|
upstreamApiKey: '',
|
||||||
restrictManagementToLocalhost: ampcode?.restrictManagementToLocalhost ?? true,
|
|
||||||
forceModelMappings: ampcode?.forceModelMappings ?? false,
|
forceModelMappings: ampcode?.forceModelMappings ?? false,
|
||||||
mappingEntries: ampcodeMappingsToEntries(ampcode?.modelMappings),
|
mappingEntries: ampcodeMappingsToEntries(ampcode?.modelMappings),
|
||||||
});
|
});
|
||||||
@@ -202,6 +201,7 @@ export function AiProvidersPage() {
|
|||||||
|
|
||||||
const [geminiForm, setGeminiForm] = useState<GeminiKeyConfig & { excludedText: string }>({
|
const [geminiForm, setGeminiForm] = useState<GeminiKeyConfig & { excludedText: string }>({
|
||||||
apiKey: '',
|
apiKey: '',
|
||||||
|
prefix: '',
|
||||||
baseUrl: '',
|
baseUrl: '',
|
||||||
headers: {},
|
headers: {},
|
||||||
excludedModels: [],
|
excludedModels: [],
|
||||||
@@ -211,6 +211,7 @@ export function AiProvidersPage() {
|
|||||||
ProviderKeyConfig & { modelEntries: ModelEntry[]; excludedText: string }
|
ProviderKeyConfig & { modelEntries: ModelEntry[]; excludedText: string }
|
||||||
>({
|
>({
|
||||||
apiKey: '',
|
apiKey: '',
|
||||||
|
prefix: '',
|
||||||
baseUrl: '',
|
baseUrl: '',
|
||||||
proxyUrl: '',
|
proxyUrl: '',
|
||||||
headers: {},
|
headers: {},
|
||||||
@@ -221,6 +222,7 @@ export function AiProvidersPage() {
|
|||||||
});
|
});
|
||||||
const [openaiForm, setOpenaiForm] = useState<OpenAIFormState>({
|
const [openaiForm, setOpenaiForm] = useState<OpenAIFormState>({
|
||||||
name: '',
|
name: '',
|
||||||
|
prefix: '',
|
||||||
baseUrl: '',
|
baseUrl: '',
|
||||||
headers: [],
|
headers: [],
|
||||||
apiKeyEntries: [buildApiKeyEntry()],
|
apiKeyEntries: [buildApiKeyEntry()],
|
||||||
@@ -319,6 +321,7 @@ export function AiProvidersPage() {
|
|||||||
setModal(null);
|
setModal(null);
|
||||||
setGeminiForm({
|
setGeminiForm({
|
||||||
apiKey: '',
|
apiKey: '',
|
||||||
|
prefix: '',
|
||||||
baseUrl: '',
|
baseUrl: '',
|
||||||
headers: {},
|
headers: {},
|
||||||
excludedModels: [],
|
excludedModels: [],
|
||||||
@@ -326,6 +329,7 @@ export function AiProvidersPage() {
|
|||||||
});
|
});
|
||||||
setProviderForm({
|
setProviderForm({
|
||||||
apiKey: '',
|
apiKey: '',
|
||||||
|
prefix: '',
|
||||||
baseUrl: '',
|
baseUrl: '',
|
||||||
proxyUrl: '',
|
proxyUrl: '',
|
||||||
headers: {},
|
headers: {},
|
||||||
@@ -336,6 +340,7 @@ export function AiProvidersPage() {
|
|||||||
});
|
});
|
||||||
setOpenaiForm({
|
setOpenaiForm({
|
||||||
name: '',
|
name: '',
|
||||||
|
prefix: '',
|
||||||
baseUrl: '',
|
baseUrl: '',
|
||||||
headers: [],
|
headers: [],
|
||||||
apiKeyEntries: [buildApiKeyEntry()],
|
apiKeyEntries: [buildApiKeyEntry()],
|
||||||
@@ -412,6 +417,7 @@ export function AiProvidersPage() {
|
|||||||
const modelEntries = modelsToEntries(entry.models);
|
const modelEntries = modelsToEntries(entry.models);
|
||||||
setOpenaiForm({
|
setOpenaiForm({
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
|
prefix: entry.prefix ?? '',
|
||||||
baseUrl: entry.baseUrl,
|
baseUrl: entry.baseUrl,
|
||||||
headers: headersToEntries(entry.headers),
|
headers: headersToEntries(entry.headers),
|
||||||
testModel: entry.testModel,
|
testModel: entry.testModel,
|
||||||
@@ -701,9 +707,6 @@ export function AiProvidersPage() {
|
|||||||
await ampcodeApi.clearUpstreamUrl();
|
await ampcodeApi.clearUpstreamUrl();
|
||||||
}
|
}
|
||||||
|
|
||||||
await ampcodeApi.updateRestrictManagementToLocalhost(
|
|
||||||
ampcodeForm.restrictManagementToLocalhost
|
|
||||||
);
|
|
||||||
await ampcodeApi.updateForceModelMappings(ampcodeForm.forceModelMappings);
|
await ampcodeApi.updateForceModelMappings(ampcodeForm.forceModelMappings);
|
||||||
|
|
||||||
if (ampcodeLoaded || ampcodeMappingsDirty) {
|
if (ampcodeLoaded || ampcodeMappingsDirty) {
|
||||||
@@ -720,12 +723,18 @@ export function AiProvidersPage() {
|
|||||||
|
|
||||||
const previous = config?.ampcode ?? {};
|
const previous = config?.ampcode ?? {};
|
||||||
const next: AmpcodeConfig = {
|
const next: AmpcodeConfig = {
|
||||||
...previous,
|
|
||||||
upstreamUrl: upstreamUrl || undefined,
|
upstreamUrl: upstreamUrl || undefined,
|
||||||
restrictManagementToLocalhost: ampcodeForm.restrictManagementToLocalhost,
|
|
||||||
forceModelMappings: ampcodeForm.forceModelMappings,
|
forceModelMappings: ampcodeForm.forceModelMappings,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (previous.upstreamApiKey) {
|
||||||
|
next.upstreamApiKey = previous.upstreamApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(previous.modelMappings)) {
|
||||||
|
next.modelMappings = previous.modelMappings;
|
||||||
|
}
|
||||||
|
|
||||||
if (overrideKey) {
|
if (overrideKey) {
|
||||||
next.upstreamApiKey = overrideKey;
|
next.upstreamApiKey = overrideKey;
|
||||||
}
|
}
|
||||||
@@ -756,6 +765,7 @@ export function AiProvidersPage() {
|
|||||||
try {
|
try {
|
||||||
const payload: GeminiKeyConfig = {
|
const payload: GeminiKeyConfig = {
|
||||||
apiKey: geminiForm.apiKey.trim(),
|
apiKey: geminiForm.apiKey.trim(),
|
||||||
|
prefix: geminiForm.prefix?.trim() || undefined,
|
||||||
baseUrl: geminiForm.baseUrl?.trim() || undefined,
|
baseUrl: geminiForm.baseUrl?.trim() || undefined,
|
||||||
headers: buildHeaderObject(headersToEntries(geminiForm.headers as any)),
|
headers: buildHeaderObject(headersToEntries(geminiForm.headers as any)),
|
||||||
excludedModels: parseExcludedModels(geminiForm.excludedText),
|
excludedModels: parseExcludedModels(geminiForm.excludedText),
|
||||||
@@ -899,6 +909,7 @@ export function AiProvidersPage() {
|
|||||||
|
|
||||||
const payload: ProviderKeyConfig = {
|
const payload: ProviderKeyConfig = {
|
||||||
apiKey: providerForm.apiKey.trim(),
|
apiKey: providerForm.apiKey.trim(),
|
||||||
|
prefix: providerForm.prefix?.trim() || undefined,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
proxyUrl: providerForm.proxyUrl?.trim() || undefined,
|
proxyUrl: providerForm.proxyUrl?.trim() || undefined,
|
||||||
headers: buildHeaderObject(headersToEntries(providerForm.headers as any)),
|
headers: buildHeaderObject(headersToEntries(providerForm.headers as any)),
|
||||||
@@ -969,6 +980,7 @@ export function AiProvidersPage() {
|
|||||||
try {
|
try {
|
||||||
const payload: OpenAIProviderConfig = {
|
const payload: OpenAIProviderConfig = {
|
||||||
name: openaiForm.name.trim(),
|
name: openaiForm.name.trim(),
|
||||||
|
prefix: openaiForm.prefix?.trim() || undefined,
|
||||||
baseUrl: openaiForm.baseUrl.trim(),
|
baseUrl: openaiForm.baseUrl.trim(),
|
||||||
headers: buildHeaderObject(openaiForm.headers),
|
headers: buildHeaderObject(openaiForm.headers),
|
||||||
apiKeyEntries: openaiForm.apiKeyEntries.map((entry) => ({
|
apiKeyEntries: openaiForm.apiKeyEntries.map((entry) => ({
|
||||||
@@ -1175,6 +1187,12 @@ export function AiProvidersPage() {
|
|||||||
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
|
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
|
||||||
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
|
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{item.prefix && (
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.prefix}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Base URL 行 */}
|
{/* Base URL 行 */}
|
||||||
{item.baseUrl && (
|
{item.baseUrl && (
|
||||||
<div className={styles.fieldRow}>
|
<div className={styles.fieldRow}>
|
||||||
@@ -1273,6 +1291,12 @@ export function AiProvidersPage() {
|
|||||||
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
|
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
|
||||||
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
|
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{item.prefix && (
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.prefix}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Base URL 行 */}
|
{/* Base URL 行 */}
|
||||||
{item.baseUrl && (
|
{item.baseUrl && (
|
||||||
<div className={styles.fieldRow}>
|
<div className={styles.fieldRow}>
|
||||||
@@ -1378,6 +1402,12 @@ export function AiProvidersPage() {
|
|||||||
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
|
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
|
||||||
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
|
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{item.prefix && (
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.prefix}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Base URL 行 */}
|
{/* Base URL 行 */}
|
||||||
{item.baseUrl && (
|
{item.baseUrl && (
|
||||||
<div className={styles.fieldRow}>
|
<div className={styles.fieldRow}>
|
||||||
@@ -1505,16 +1535,6 @@ export function AiProvidersPage() {
|
|||||||
: t('common.not_set')}
|
: t('common.not_set')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.fieldRow}>
|
|
||||||
<span className={styles.fieldLabel}>
|
|
||||||
{t('ai_providers.ampcode_restrict_management_label')}:
|
|
||||||
</span>
|
|
||||||
<span className={styles.fieldValue}>
|
|
||||||
{(config?.ampcode?.restrictManagementToLocalhost ?? true)
|
|
||||||
? t('common.yes')
|
|
||||||
: t('common.no')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.fieldRow}>
|
<div className={styles.fieldRow}>
|
||||||
<span className={styles.fieldLabel}>
|
<span className={styles.fieldLabel}>
|
||||||
{t('ai_providers.ampcode_force_model_mappings_label')}:
|
{t('ai_providers.ampcode_force_model_mappings_label')}:
|
||||||
@@ -1576,6 +1596,12 @@ export function AiProvidersPage() {
|
|||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<div className="item-title">{item.name}</div>
|
<div className="item-title">{item.name}</div>
|
||||||
|
{item.prefix && (
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.prefix}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Base URL 行 */}
|
{/* Base URL 行 */}
|
||||||
<div className={styles.fieldRow}>
|
<div className={styles.fieldRow}>
|
||||||
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
|
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
|
||||||
@@ -1739,18 +1765,6 @@ export function AiProvidersPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<ToggleSwitch
|
|
||||||
label={t('ai_providers.ampcode_restrict_management_label')}
|
|
||||||
checked={ampcodeForm.restrictManagementToLocalhost}
|
|
||||||
onChange={(value) =>
|
|
||||||
setAmpcodeForm((prev) => ({ ...prev, restrictManagementToLocalhost: value }))
|
|
||||||
}
|
|
||||||
disabled={ampcodeModalLoading || ampcodeSaving}
|
|
||||||
/>
|
|
||||||
<div className="hint">{t('ai_providers.ampcode_restrict_management_hint')}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
label={t('ai_providers.ampcode_force_model_mappings_label')}
|
label={t('ai_providers.ampcode_force_model_mappings_label')}
|
||||||
@@ -1806,6 +1820,13 @@ export function AiProvidersPage() {
|
|||||||
value={geminiForm.apiKey}
|
value={geminiForm.apiKey}
|
||||||
onChange={(e) => setGeminiForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
onChange={(e) => setGeminiForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.prefix_label')}
|
||||||
|
placeholder={t('ai_providers.prefix_placeholder')}
|
||||||
|
value={geminiForm.prefix ?? ''}
|
||||||
|
onChange={(e) => setGeminiForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
||||||
|
hint={t('ai_providers.prefix_hint')}
|
||||||
|
/>
|
||||||
<Input
|
<Input
|
||||||
label={t('ai_providers.gemini_base_url_label')}
|
label={t('ai_providers.gemini_base_url_label')}
|
||||||
placeholder={t('ai_providers.gemini_base_url_placeholder')}
|
placeholder={t('ai_providers.gemini_base_url_placeholder')}
|
||||||
@@ -1870,6 +1891,13 @@ export function AiProvidersPage() {
|
|||||||
value={providerForm.apiKey}
|
value={providerForm.apiKey}
|
||||||
onChange={(e) => setProviderForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
onChange={(e) => setProviderForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.prefix_label')}
|
||||||
|
placeholder={t('ai_providers.prefix_placeholder')}
|
||||||
|
value={providerForm.prefix ?? ''}
|
||||||
|
onChange={(e) => setProviderForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
||||||
|
hint={t('ai_providers.prefix_hint')}
|
||||||
|
/>
|
||||||
<Input
|
<Input
|
||||||
label={
|
label={
|
||||||
modal?.type === 'codex'
|
modal?.type === 'codex'
|
||||||
@@ -1952,6 +1980,13 @@ export function AiProvidersPage() {
|
|||||||
value={openaiForm.name}
|
value={openaiForm.name}
|
||||||
onChange={(e) => setOpenaiForm((prev) => ({ ...prev, name: e.target.value }))}
|
onChange={(e) => setOpenaiForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.prefix_label')}
|
||||||
|
placeholder={t('ai_providers.prefix_placeholder')}
|
||||||
|
value={openaiForm.prefix ?? ''}
|
||||||
|
onChange={(e) => setOpenaiForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
||||||
|
hint={t('ai_providers.prefix_hint')}
|
||||||
|
/>
|
||||||
<Input
|
<Input
|
||||||
label={t('ai_providers.openai_add_modal_url_label')}
|
label={t('ai_providers.openai_add_modal_url_label')}
|
||||||
value={openaiForm.baseUrl}
|
value={openaiForm.baseUrl}
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
.container {
|
.container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pageTitle {
|
.pageTitle {
|
||||||
@@ -21,6 +25,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $spacing-lg;
|
gap: $spacing-lg;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchInputWrapper {
|
.searchInputWrapper {
|
||||||
@@ -127,7 +133,8 @@
|
|||||||
|
|
||||||
.editorWrapper {
|
.editorWrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 500px;
|
flex: 1;
|
||||||
|
min-height: 200px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: $radius-lg;
|
border-radius: $radius-lg;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -206,6 +213,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.configCard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: $spacing-sm;
|
gap: $spacing-sm;
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ export function ConfigPage() {
|
|||||||
<h1 className={styles.pageTitle}>{t('config_management.title')}</h1>
|
<h1 className={styles.pageTitle}>{t('config_management.title')}</h1>
|
||||||
<p className={styles.description}>{t('config_management.description')}</p>
|
<p className={styles.description}>{t('config_management.description')}</p>
|
||||||
|
|
||||||
<Card>
|
<Card className={styles.configCard}>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
{/* Editor */}
|
{/* Editor */}
|
||||||
{error && <div className="error-box">{error}</div>}
|
{error && <div className="error-box">{error}</div>}
|
||||||
|
|||||||
320
src/pages/DashboardPage.module.scss
Normal file
320
src/pages/DashboardPage.module.scss
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
@use 'sass:color';
|
||||||
|
@use '../styles/variables.scss' as *;
|
||||||
|
|
||||||
|
.dashboard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-lg;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: $spacing-xs 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connectionCard {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-md;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
padding: $spacing-md $spacing-lg;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connectionStatus {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusDot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $gray-400;
|
||||||
|
|
||||||
|
&.connected {
|
||||||
|
background: $success-color;
|
||||||
|
box-shadow: 0 0 8px rgba($success-color, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.connecting {
|
||||||
|
background: $warning-color;
|
||||||
|
animation: pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disconnected {
|
||||||
|
background: $error-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusText {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connectionInfo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-md;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.serverUrl {
|
||||||
|
font-family: $font-mono;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.serverVersion {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary-color);
|
||||||
|
background: rgba($primary-color, 0.1);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: $radius-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buildDate {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: $spacing-md;
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.statCard {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-md;
|
||||||
|
padding: $spacing-lg;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all $transition-fast;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.statIcon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statValue {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statLabel {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statSublabel {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsGrid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButton {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
|
||||||
|
// Button 内部的 span 需要 flex 对齐图标和文字
|
||||||
|
> span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.configGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
padding: $spacing-sm $spacing-md;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: $radius-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configLabel {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.configValue {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
&.enabled {
|
||||||
|
color: $success-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.configValueMono {
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: $font-mono;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configItemFull {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage stats section
|
||||||
|
.usageGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usageCard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: $spacing-md;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: $radius-md;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usageValue {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usageLabel {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usageLoading,
|
||||||
|
.usageEmpty {
|
||||||
|
padding: $spacing-lg;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: $radius-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewMoreLink {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
margin-top: $spacing-xs;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
315
src/pages/DashboardPage.tsx
Normal file
315
src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
IconKey,
|
||||||
|
IconBot,
|
||||||
|
IconFileText,
|
||||||
|
IconSatellite
|
||||||
|
} from '@/components/ui/icons';
|
||||||
|
import { useAuthStore, useConfigStore, useModelsStore } from '@/stores';
|
||||||
|
import { apiKeysApi, providersApi, authFilesApi } from '@/services/api';
|
||||||
|
import styles from './DashboardPage.module.scss';
|
||||||
|
|
||||||
|
interface QuickStat {
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
path: string;
|
||||||
|
loading?: boolean;
|
||||||
|
sublabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderStats {
|
||||||
|
gemini: number | null;
|
||||||
|
codex: number | null;
|
||||||
|
claude: number | null;
|
||||||
|
openai: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||||
|
const serverVersion = useAuthStore((state) => state.serverVersion);
|
||||||
|
const serverBuildDate = useAuthStore((state) => state.serverBuildDate);
|
||||||
|
const apiBase = useAuthStore((state) => state.apiBase);
|
||||||
|
const config = useConfigStore((state) => state.config);
|
||||||
|
|
||||||
|
const models = useModelsStore((state) => state.models);
|
||||||
|
const modelsLoading = useModelsStore((state) => state.loading);
|
||||||
|
const fetchModelsFromStore = useModelsStore((state) => state.fetchModels);
|
||||||
|
|
||||||
|
const [stats, setStats] = useState<{
|
||||||
|
apiKeys: number | null;
|
||||||
|
authFiles: number | null;
|
||||||
|
}>({
|
||||||
|
apiKeys: null,
|
||||||
|
authFiles: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const [providerStats, setProviderStats] = useState<ProviderStats>({
|
||||||
|
gemini: null,
|
||||||
|
codex: null,
|
||||||
|
claude: null,
|
||||||
|
openai: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const apiKeysCache = useRef<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiKeysCache.current = [];
|
||||||
|
}, [apiBase, config?.apiKeys]);
|
||||||
|
|
||||||
|
const normalizeApiKeyList = (input: any): string[] => {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const keys: string[] = [];
|
||||||
|
|
||||||
|
input.forEach((item) => {
|
||||||
|
const value = typeof item === 'string' ? item : item?.['api-key'] ?? item?.apiKey ?? '';
|
||||||
|
const trimmed = String(value || '').trim();
|
||||||
|
if (!trimmed || seen.has(trimmed)) return;
|
||||||
|
seen.add(trimmed);
|
||||||
|
keys.push(trimmed);
|
||||||
|
});
|
||||||
|
|
||||||
|
return keys;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveApiKeysForModels = useCallback(async () => {
|
||||||
|
if (apiKeysCache.current.length) {
|
||||||
|
return apiKeysCache.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configKeys = normalizeApiKeyList(config?.apiKeys);
|
||||||
|
if (configKeys.length) {
|
||||||
|
apiKeysCache.current = configKeys;
|
||||||
|
return configKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const list = await apiKeysApi.list();
|
||||||
|
const normalized = normalizeApiKeyList(list);
|
||||||
|
if (normalized.length) {
|
||||||
|
apiKeysCache.current = normalized;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [config?.apiKeys]);
|
||||||
|
|
||||||
|
const fetchModels = useCallback(async () => {
|
||||||
|
if (connectionStatus !== 'connected' || !apiBase) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiKeys = await resolveApiKeysForModels();
|
||||||
|
const primaryKey = apiKeys[0];
|
||||||
|
await fetchModelsFromStore(apiBase, primaryKey);
|
||||||
|
} catch {
|
||||||
|
// Ignore model fetch errors on dashboard
|
||||||
|
}
|
||||||
|
}, [connectionStatus, apiBase, resolveApiKeysForModels, fetchModelsFromStore]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchStats = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [keysRes, filesRes, geminiRes, codexRes, claudeRes, openaiRes] = await Promise.allSettled([
|
||||||
|
apiKeysApi.list(),
|
||||||
|
authFilesApi.list(),
|
||||||
|
providersApi.getGeminiKeys(),
|
||||||
|
providersApi.getCodexConfigs(),
|
||||||
|
providersApi.getClaudeConfigs(),
|
||||||
|
providersApi.getOpenAIProviders()
|
||||||
|
]);
|
||||||
|
|
||||||
|
setStats({
|
||||||
|
apiKeys: keysRes.status === 'fulfilled' ? keysRes.value.length : null,
|
||||||
|
authFiles: filesRes.status === 'fulfilled' ? filesRes.value.files.length : null
|
||||||
|
});
|
||||||
|
|
||||||
|
setProviderStats({
|
||||||
|
gemini: geminiRes.status === 'fulfilled' ? geminiRes.value.length : null,
|
||||||
|
codex: codexRes.status === 'fulfilled' ? codexRes.value.length : null,
|
||||||
|
claude: claudeRes.status === 'fulfilled' ? claudeRes.value.length : null,
|
||||||
|
openai: openaiRes.status === 'fulfilled' ? openaiRes.value.length : null
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (connectionStatus === 'connected') {
|
||||||
|
fetchStats();
|
||||||
|
fetchModels();
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [connectionStatus, fetchModels]);
|
||||||
|
|
||||||
|
// Calculate total provider keys only when all provider stats are available.
|
||||||
|
const providerStatsReady =
|
||||||
|
providerStats.gemini !== null &&
|
||||||
|
providerStats.codex !== null &&
|
||||||
|
providerStats.claude !== null &&
|
||||||
|
providerStats.openai !== null;
|
||||||
|
const hasProviderStats =
|
||||||
|
providerStats.gemini !== null ||
|
||||||
|
providerStats.codex !== null ||
|
||||||
|
providerStats.claude !== null ||
|
||||||
|
providerStats.openai !== null;
|
||||||
|
const totalProviderKeys = providerStatsReady
|
||||||
|
? (providerStats.gemini ?? 0) +
|
||||||
|
(providerStats.codex ?? 0) +
|
||||||
|
(providerStats.claude ?? 0) +
|
||||||
|
(providerStats.openai ?? 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const quickStats: QuickStat[] = [
|
||||||
|
{
|
||||||
|
label: t('nav.api_keys'),
|
||||||
|
value: stats.apiKeys ?? '-',
|
||||||
|
icon: <IconKey size={24} />,
|
||||||
|
path: '/api-keys',
|
||||||
|
loading: loading && stats.apiKeys === null,
|
||||||
|
sublabel: t('dashboard.management_keys')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('nav.ai_providers'),
|
||||||
|
value: loading ? '-' : providerStatsReady ? totalProviderKeys : '-',
|
||||||
|
icon: <IconBot size={24} />,
|
||||||
|
path: '/ai-providers',
|
||||||
|
loading: loading,
|
||||||
|
sublabel: hasProviderStats
|
||||||
|
? t('dashboard.provider_keys_detail', {
|
||||||
|
gemini: providerStats.gemini ?? '-',
|
||||||
|
codex: providerStats.codex ?? '-',
|
||||||
|
claude: providerStats.claude ?? '-',
|
||||||
|
openai: providerStats.openai ?? '-'
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('nav.auth_files'),
|
||||||
|
value: stats.authFiles ?? '-',
|
||||||
|
icon: <IconFileText size={24} />,
|
||||||
|
path: '/auth-files',
|
||||||
|
loading: loading && stats.authFiles === null,
|
||||||
|
sublabel: t('dashboard.oauth_credentials')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('dashboard.available_models'),
|
||||||
|
value: modelsLoading ? '-' : models.length,
|
||||||
|
icon: <IconSatellite size={24} />,
|
||||||
|
path: '/system',
|
||||||
|
loading: modelsLoading,
|
||||||
|
sublabel: t('dashboard.available_models_desc')
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.dashboard}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<h1 className={styles.title}>{t('dashboard.title')}</h1>
|
||||||
|
<p className={styles.subtitle}>{t('dashboard.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.connectionCard}>
|
||||||
|
<div className={styles.connectionStatus}>
|
||||||
|
<span
|
||||||
|
className={`${styles.statusDot} ${
|
||||||
|
connectionStatus === 'connected'
|
||||||
|
? styles.connected
|
||||||
|
: connectionStatus === 'connecting'
|
||||||
|
? styles.connecting
|
||||||
|
: styles.disconnected
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className={styles.statusText}>
|
||||||
|
{t(
|
||||||
|
connectionStatus === 'connected'
|
||||||
|
? 'common.connected'
|
||||||
|
: connectionStatus === 'connecting'
|
||||||
|
? 'common.connecting'
|
||||||
|
: 'common.disconnected'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.connectionInfo}>
|
||||||
|
<span className={styles.serverUrl}>{apiBase || '-'}</span>
|
||||||
|
{serverVersion && <span className={styles.serverVersion}>v{serverVersion}</span>}
|
||||||
|
{serverBuildDate && (
|
||||||
|
<span className={styles.buildDate}>
|
||||||
|
{new Date(serverBuildDate).toLocaleDateString(i18n.language)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.statsGrid}>
|
||||||
|
{quickStats.map((stat) => (
|
||||||
|
<Link key={stat.path} to={stat.path} className={styles.statCard}>
|
||||||
|
<div className={styles.statIcon}>{stat.icon}</div>
|
||||||
|
<div className={styles.statContent}>
|
||||||
|
<span className={styles.statValue}>{stat.loading ? '...' : stat.value}</span>
|
||||||
|
<span className={styles.statLabel}>{stat.label}</span>
|
||||||
|
{stat.sublabel && !stat.loading && (
|
||||||
|
<span className={styles.statSublabel}>{stat.sublabel}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config && (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>{t('dashboard.current_config')}</h2>
|
||||||
|
<div className={styles.configGrid}>
|
||||||
|
<div className={styles.configItem}>
|
||||||
|
<span className={styles.configLabel}>{t('basic_settings.debug_enable')}</span>
|
||||||
|
<span className={`${styles.configValue} ${config.debug ? styles.enabled : styles.disabled}`}>
|
||||||
|
{config.debug ? t('common.yes') : t('common.no')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.configItem}>
|
||||||
|
<span className={styles.configLabel}>{t('basic_settings.usage_statistics_enable')}</span>
|
||||||
|
<span className={`${styles.configValue} ${config.usageStatisticsEnabled ? styles.enabled : styles.disabled}`}>
|
||||||
|
{config.usageStatisticsEnabled ? t('common.yes') : t('common.no')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.configItem}>
|
||||||
|
<span className={styles.configLabel}>{t('basic_settings.logging_to_file_enable')}</span>
|
||||||
|
<span className={`${styles.configValue} ${config.loggingToFile ? styles.enabled : styles.disabled}`}>
|
||||||
|
{config.loggingToFile ? t('common.yes') : t('common.no')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.configItem}>
|
||||||
|
<span className={styles.configLabel}>{t('basic_settings.retry_count_label')}</span>
|
||||||
|
<span className={styles.configValue}>{config.requestRetry ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.configItem}>
|
||||||
|
<span className={styles.configLabel}>{t('basic_settings.ws_auth_enable')}</span>
|
||||||
|
<span className={`${styles.configValue} ${config.wsAuth ? styles.enabled : styles.disabled}`}>
|
||||||
|
{config.wsAuth ? t('common.yes') : t('common.no')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{config.proxyUrl && (
|
||||||
|
<div className={`${styles.configItem} ${styles.configItemFull}`}>
|
||||||
|
<span className={styles.configLabel}>{t('basic_settings.proxy_url_label')}</span>
|
||||||
|
<span className={styles.configValueMono}>{config.proxyUrl}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Link to="/settings" className={styles.viewMoreLink}>
|
||||||
|
{t('dashboard.edit_settings')} →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { Navigate, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
@@ -44,12 +44,10 @@ export function LoginPage() {
|
|||||||
init();
|
init();
|
||||||
}, [detectedBase, restoreSession, storedBase, storedKey]);
|
}, [detectedBase, restoreSession, storedBase, storedKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
if (isAuthenticated) {
|
||||||
if (isAuthenticated) {
|
const redirect = (location.state as any)?.from?.pathname || '/';
|
||||||
const redirect = (location.state as any)?.from?.pathname || '/';
|
return <Navigate to={redirect} replace />;
|
||||||
navigate(redirect, { replace: true });
|
}
|
||||||
}
|
|
||||||
}, [isAuthenticated, navigate, location.state]);
|
|
||||||
|
|
||||||
const handleUseCurrent = () => {
|
const handleUseCurrent = () => {
|
||||||
setApiBase(detectedBase);
|
setApiBase(detectedBase);
|
||||||
|
|||||||
@@ -2,19 +2,64 @@
|
|||||||
|
|
||||||
.container {
|
.container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pageTitle {
|
.pageTitle {
|
||||||
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 0 $spacing-lg 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabBar {
|
||||||
|
display: flex;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
margin-bottom: $spacing-lg;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabItem {
|
||||||
|
@include button-reset;
|
||||||
|
padding: 12px 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: transparent;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color 0.15s ease,
|
||||||
|
border-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabActive {
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-bottom-color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $spacing-lg;
|
gap: $spacing-lg;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logCard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -22,9 +67,12 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $spacing-sm;
|
gap: $spacing-sm;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
margin-left: auto;
|
||||||
|
|
||||||
@include mobile {
|
@include mobile {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +160,8 @@
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: $radius-md;
|
border-radius: $radius-md;
|
||||||
max-height: 620px;
|
flex: 1;
|
||||||
|
min-height: 200px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -137,6 +186,12 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loadMoreStats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
.logList {
|
.logList {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -187,15 +242,8 @@
|
|||||||
|
|
||||||
.rowMain {
|
.rowMain {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rowMeta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -236,6 +284,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.requestIdBadge {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
||||||
|
'Courier New', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #0891b2;
|
||||||
|
background: rgba(8, 145, 178, 0.1);
|
||||||
|
border-color: rgba(8, 145, 178, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
.statusBadge {
|
.statusBadge {
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
@@ -308,6 +365,5 @@
|
|||||||
|
|
||||||
.message {
|
.message {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,11 +44,12 @@ type HttpMethod = (typeof HTTP_METHODS)[number];
|
|||||||
const HTTP_METHOD_REGEX = new RegExp(`\\b(${HTTP_METHODS.join('|')})\\b`);
|
const HTTP_METHOD_REGEX = new RegExp(`\\b(${HTTP_METHODS.join('|')})\\b`);
|
||||||
|
|
||||||
const LOG_TIMESTAMP_REGEX = /^\[?(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\]?/;
|
const LOG_TIMESTAMP_REGEX = /^\[?(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\]?/;
|
||||||
const LOG_LEVEL_REGEX = /^\[?(trace|debug|info|warn|warning|error|fatal)\]?(?=\s|\[|$)\s*/i;
|
const LOG_LEVEL_REGEX = /^\[?(trace|debug|info|warn|warning|error|fatal)\s*\]?(?=\s|\[|$)\s*/i;
|
||||||
const LOG_SOURCE_REGEX = /^\[([^\]]+)\]/;
|
const LOG_SOURCE_REGEX = /^\[([^\]]+)\]/;
|
||||||
const LOG_LATENCY_REGEX = /\b(\d+(?:\.\d+)?)(?:\s*)(µs|us|ms|s)\b/i;
|
const LOG_LATENCY_REGEX = /\b(\d+(?:\.\d+)?)(?:\s*)(µs|us|ms|s)\b/i;
|
||||||
const LOG_IPV4_REGEX = /\b(?:\d{1,3}\.){3}\d{1,3}\b/;
|
const LOG_IPV4_REGEX = /\b(?:\d{1,3}\.){3}\d{1,3}\b/;
|
||||||
const LOG_IPV6_REGEX = /\b(?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}\b/i;
|
const LOG_IPV6_REGEX = /\b(?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}\b/i;
|
||||||
|
const LOG_REQUEST_ID_REGEX = /^([a-f0-9]{8}|--------)$/i;
|
||||||
const LOG_TIME_OF_DAY_REGEX = /^\d{1,2}:\d{2}:\d{2}(?:\.\d{1,3})?$/;
|
const LOG_TIME_OF_DAY_REGEX = /^\d{1,2}:\d{2}:\d{2}(?:\.\d{1,3})?$/;
|
||||||
const GIN_TIMESTAMP_SEGMENT_REGEX =
|
const GIN_TIMESTAMP_SEGMENT_REGEX =
|
||||||
/^\[GIN\]\s+(\d{4})\/(\d{2})\/(\d{2})\s*-\s*(\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s*$/;
|
/^\[GIN\]\s+(\d{4})\/(\d{2})\/(\d{2})\s*-\s*(\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s*$/;
|
||||||
@@ -102,6 +103,7 @@ type ParsedLogLine = {
|
|||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
level?: LogLevel;
|
level?: LogLevel;
|
||||||
source?: string;
|
source?: string;
|
||||||
|
requestId?: string;
|
||||||
statusCode?: number;
|
statusCode?: number;
|
||||||
latency?: string;
|
latency?: string;
|
||||||
ip?: string;
|
ip?: string;
|
||||||
@@ -154,6 +156,16 @@ const parseLogLine = (raw: string): ParsedLogLine => {
|
|||||||
remaining = remaining.slice(tsMatch[0].length).trim();
|
remaining = remaining.slice(tsMatch[0].length).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let requestId: string | undefined;
|
||||||
|
const requestIdMatch = remaining.match(/^\[([a-f0-9]{8}|--------)\]\s*/i);
|
||||||
|
if (requestIdMatch) {
|
||||||
|
const id = requestIdMatch[1];
|
||||||
|
if (!/^-+$/.test(id)) {
|
||||||
|
requestId = id;
|
||||||
|
}
|
||||||
|
remaining = remaining.slice(requestIdMatch[0].length).trim();
|
||||||
|
}
|
||||||
|
|
||||||
let level: LogLevel | undefined;
|
let level: LogLevel | undefined;
|
||||||
const lvlMatch = remaining.match(LOG_LEVEL_REGEX);
|
const lvlMatch = remaining.match(LOG_LEVEL_REGEX);
|
||||||
if (lvlMatch) {
|
if (lvlMatch) {
|
||||||
@@ -199,10 +211,23 @@ const parseLogLine = (raw: string): ParsedLogLine => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// request id (8-char hex or dashes)
|
||||||
|
const requestIdIndex = segments.findIndex((segment) => LOG_REQUEST_ID_REGEX.test(segment));
|
||||||
|
if (requestIdIndex >= 0) {
|
||||||
|
const match = segments[requestIdIndex].match(LOG_REQUEST_ID_REGEX);
|
||||||
|
if (match) {
|
||||||
|
const id = match[1];
|
||||||
|
if (!/^-+$/.test(id)) {
|
||||||
|
requestId = id;
|
||||||
|
}
|
||||||
|
consumed.add(requestIdIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// status code
|
// status code
|
||||||
const statusIndex = segments.findIndex((segment) => /^\d{3}\b/.test(segment));
|
const statusIndex = segments.findIndex((segment) => /^\d{3}$/.test(segment));
|
||||||
if (statusIndex >= 0) {
|
if (statusIndex >= 0) {
|
||||||
const match = segments[statusIndex].match(/^(\d{3})\b/);
|
const match = segments[statusIndex].match(/^(\d{3})$/);
|
||||||
if (match) {
|
if (match) {
|
||||||
const code = Number.parseInt(match[1], 10);
|
const code = Number.parseInt(match[1], 10);
|
||||||
if (code >= 100 && code <= 599) {
|
if (code >= 100 && code <= 599) {
|
||||||
@@ -244,6 +269,16 @@ const parseLogLine = (raw: string): ParsedLogLine => {
|
|||||||
consumed.add(methodIndex);
|
consumed.add(methodIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// source (e.g. [gin_logger.go:94])
|
||||||
|
const sourceIndex = segments.findIndex((segment) => LOG_SOURCE_REGEX.test(segment));
|
||||||
|
if (sourceIndex >= 0) {
|
||||||
|
const match = segments[sourceIndex].match(LOG_SOURCE_REGEX);
|
||||||
|
if (match) {
|
||||||
|
source = match[1];
|
||||||
|
consumed.add(sourceIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
message = segments.filter((_, index) => !consumed.has(index)).join(' | ');
|
message = segments.filter((_, index) => !consumed.has(index)).join(' | ');
|
||||||
} else {
|
} else {
|
||||||
statusCode = detectHttpStatusCode(remaining);
|
statusCode = detectHttpStatusCode(remaining);
|
||||||
@@ -276,6 +311,7 @@ const parseLogLine = (raw: string): ParsedLogLine => {
|
|||||||
timestamp,
|
timestamp,
|
||||||
level,
|
level,
|
||||||
source,
|
source,
|
||||||
|
requestId,
|
||||||
statusCode,
|
statusCode,
|
||||||
latency,
|
latency,
|
||||||
ip,
|
ip,
|
||||||
@@ -319,11 +355,14 @@ const copyToClipboard = async (text: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TabType = 'logs' | 'errors';
|
||||||
|
|
||||||
export function LogsPage() {
|
export function LogsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification } = useNotificationStore();
|
||||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>('logs');
|
||||||
const [logState, setLogState] = useState<LogState>({ buffer: [], visibleFrom: 0 });
|
const [logState, setLogState] = useState<LogState>({ buffer: [], visibleFrom: 0 });
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -599,143 +638,157 @@ export function LogsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<h1 className={styles.pageTitle}>{t('logs.title')}</h1>
|
<h1 className={styles.pageTitle}>{t('logs.title')}</h1>
|
||||||
|
|
||||||
|
<div className={styles.tabBar}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.tabItem} ${activeTab === 'logs' ? styles.tabActive : ''}`}
|
||||||
|
onClick={() => setActiveTab('logs')}
|
||||||
|
>
|
||||||
|
{t('logs.log_content')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.tabItem} ${activeTab === 'errors' ? styles.tabActive : ''}`}
|
||||||
|
onClick={() => setActiveTab('errors')}
|
||||||
|
>
|
||||||
|
{t('logs.error_logs_modal_title')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<Card
|
{activeTab === 'logs' && (
|
||||||
title={t('logs.log_content')}
|
<Card className={styles.logCard}>
|
||||||
extra={
|
{error && <div className="error-box">{error}</div>}
|
||||||
<div className={styles.toolbar}>
|
|
||||||
<Button
|
<div className={styles.filters}>
|
||||||
variant="secondary"
|
<div className={styles.searchWrapper}>
|
||||||
size="sm"
|
<Input
|
||||||
onClick={() => loadLogs(false)}
|
value={searchQuery}
|
||||||
disabled={disableControls || loading}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className={styles.actionButton}
|
placeholder={t('logs.search_placeholder')}
|
||||||
>
|
className={styles.searchInput}
|
||||||
<span className={styles.buttonContent}>
|
rightElement={
|
||||||
<IconRefreshCw size={16} />
|
searchQuery ? (
|
||||||
{t('logs.refresh_button')}
|
<button
|
||||||
</span>
|
type="button"
|
||||||
</Button>
|
className={styles.searchClear}
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
title="Clear"
|
||||||
|
aria-label="Clear"
|
||||||
|
>
|
||||||
|
<IconX size={16} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<IconSearch size={16} className={styles.searchIcon} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={autoRefresh}
|
checked={hideManagementLogs}
|
||||||
onChange={(value) => setAutoRefresh(value)}
|
onChange={setHideManagementLogs}
|
||||||
disabled={disableControls}
|
|
||||||
label={
|
label={
|
||||||
<span className={styles.switchLabel}>
|
<span className={styles.switchLabel}>
|
||||||
<IconTimer size={16} />
|
<IconEyeOff size={16} />
|
||||||
{t('logs.auto_refresh')}
|
{t('logs.hide_management_logs', { prefix: MANAGEMENT_API_PREFIX })}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={downloadLogs}
|
|
||||||
disabled={logState.buffer.length === 0}
|
|
||||||
className={styles.actionButton}
|
|
||||||
>
|
|
||||||
<span className={styles.buttonContent}>
|
|
||||||
<IconDownload size={16} />
|
|
||||||
{t('logs.download_button')}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={clearLogs}
|
|
||||||
disabled={disableControls}
|
|
||||||
className={styles.actionButton}
|
|
||||||
>
|
|
||||||
<span className={styles.buttonContent}>
|
|
||||||
<IconTrash2 size={16} />
|
|
||||||
{t('logs.clear_button')}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{error && <div className="error-box">{error}</div>}
|
|
||||||
|
|
||||||
<div className={styles.filters}>
|
<div className={styles.toolbar}>
|
||||||
<div className={styles.searchWrapper}>
|
<Button
|
||||||
<Input
|
variant="secondary"
|
||||||
value={searchQuery}
|
size="sm"
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onClick={() => loadLogs(false)}
|
||||||
placeholder={t('logs.search_placeholder')}
|
disabled={disableControls || loading}
|
||||||
className={styles.searchInput}
|
className={styles.actionButton}
|
||||||
rightElement={
|
>
|
||||||
searchQuery ? (
|
<span className={styles.buttonContent}>
|
||||||
<button
|
<IconRefreshCw size={16} />
|
||||||
type="button"
|
{t('logs.refresh_button')}
|
||||||
className={styles.searchClear}
|
|
||||||
onClick={() => setSearchQuery('')}
|
|
||||||
title="Clear"
|
|
||||||
aria-label="Clear"
|
|
||||||
>
|
|
||||||
<IconX size={16} />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<IconSearch size={16} className={styles.searchIcon} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ToggleSwitch
|
|
||||||
checked={hideManagementLogs}
|
|
||||||
onChange={setHideManagementLogs}
|
|
||||||
label={
|
|
||||||
<span className={styles.switchLabel}>
|
|
||||||
<IconEyeOff size={16} />
|
|
||||||
{t('logs.hide_management_logs', { prefix: MANAGEMENT_API_PREFIX })}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className={styles.filterStats}>
|
|
||||||
<span>
|
|
||||||
{parsedVisibleLines.length} {t('logs.lines')}
|
|
||||||
</span>
|
|
||||||
{removedCount > 0 && (
|
|
||||||
<span className={styles.removedCount}>
|
|
||||||
{t('logs.removed')} {removedCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="hint">{t('logs.loading')}</div>
|
|
||||||
) : logState.buffer.length > 0 && parsedVisibleLines.length > 0 ? (
|
|
||||||
<div ref={logViewerRef} className={styles.logPanel} onScroll={handleLogScroll}>
|
|
||||||
{canLoadMore && (
|
|
||||||
<div className={styles.loadMoreBanner}>
|
|
||||||
<span>{t('logs.load_more_hint')}</span>
|
|
||||||
<span className={styles.loadMoreCount}>
|
|
||||||
{t('logs.hidden_lines', { count: logState.visibleFrom })}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</Button>
|
||||||
)}
|
<ToggleSwitch
|
||||||
<div className={styles.logList}>
|
checked={autoRefresh}
|
||||||
{parsedVisibleLines.map((line, index) => {
|
onChange={(value) => setAutoRefresh(value)}
|
||||||
const rowClassNames = [styles.logRow];
|
disabled={disableControls}
|
||||||
if (line.level === 'warn') rowClassNames.push(styles.rowWarn);
|
label={
|
||||||
if (line.level === 'error' || line.level === 'fatal')
|
<span className={styles.switchLabel}>
|
||||||
rowClassNames.push(styles.rowError);
|
<IconTimer size={16} />
|
||||||
return (
|
{t('logs.auto_refresh')}
|
||||||
<div
|
</span>
|
||||||
key={`${logState.visibleFrom + index}-${line.raw}`}
|
}
|
||||||
className={rowClassNames.join(' ')}
|
/>
|
||||||
onDoubleClick={() => {
|
<Button
|
||||||
void copyLogLine(line.raw);
|
variant="secondary"
|
||||||
}}
|
size="sm"
|
||||||
title={t('logs.double_click_copy_hint', {
|
onClick={downloadLogs}
|
||||||
defaultValue: 'Double-click to copy',
|
disabled={logState.buffer.length === 0}
|
||||||
})}
|
className={styles.actionButton}
|
||||||
>
|
>
|
||||||
<div className={styles.timestamp}>{line.timestamp || ''}</div>
|
<span className={styles.buttonContent}>
|
||||||
<div className={styles.rowMain}>
|
<IconDownload size={16} />
|
||||||
<div className={styles.rowMeta}>
|
{t('logs.download_button')}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearLogs}
|
||||||
|
disabled={disableControls}
|
||||||
|
className={styles.actionButton}
|
||||||
|
>
|
||||||
|
<span className={styles.buttonContent}>
|
||||||
|
<IconTrash2 size={16} />
|
||||||
|
{t('logs.clear_button')}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="hint">{t('logs.loading')}</div>
|
||||||
|
) : logState.buffer.length > 0 && parsedVisibleLines.length > 0 ? (
|
||||||
|
<div ref={logViewerRef} className={styles.logPanel} onScroll={handleLogScroll}>
|
||||||
|
{canLoadMore && (
|
||||||
|
<div className={styles.loadMoreBanner}>
|
||||||
|
<span>{t('logs.load_more_hint')}</span>
|
||||||
|
<div className={styles.loadMoreStats}>
|
||||||
|
<span>
|
||||||
|
{t('logs.loaded_lines', { count: parsedVisibleLines.length })}
|
||||||
|
</span>
|
||||||
|
{removedCount > 0 && (
|
||||||
|
<span className={styles.loadMoreCount}>
|
||||||
|
{t('logs.filtered_lines', { count: removedCount })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={styles.loadMoreCount}>
|
||||||
|
{t('logs.hidden_lines', { count: logState.visibleFrom })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.logList}>
|
||||||
|
{parsedVisibleLines.map((line, index) => {
|
||||||
|
const rowClassNames = [styles.logRow];
|
||||||
|
if (line.level === 'warn') rowClassNames.push(styles.rowWarn);
|
||||||
|
if (line.level === 'error' || line.level === 'fatal')
|
||||||
|
rowClassNames.push(styles.rowError);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${logState.visibleFrom + index}-${line.raw}`}
|
||||||
|
className={rowClassNames.join(' ')}
|
||||||
|
onDoubleClick={() => {
|
||||||
|
void copyLogLine(line.raw);
|
||||||
|
}}
|
||||||
|
title={t('logs.double_click_copy_hint', {
|
||||||
|
defaultValue: 'Double-click to copy',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className={styles.timestamp}>{line.timestamp || ''}</div>
|
||||||
|
<div className={styles.rowMain}>
|
||||||
{line.level && (
|
{line.level && (
|
||||||
<span
|
<span
|
||||||
className={[
|
className={[
|
||||||
@@ -761,6 +814,15 @@ export function LogsPage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{line.requestId && (
|
||||||
|
<span
|
||||||
|
className={[styles.badge, styles.requestIdBadge].join(' ')}
|
||||||
|
title={line.requestId}
|
||||||
|
>
|
||||||
|
{line.requestId}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{typeof line.statusCode === 'number' && (
|
{typeof line.statusCode === 'number' && (
|
||||||
<span
|
<span
|
||||||
className={[
|
className={[
|
||||||
@@ -787,64 +849,67 @@ export function LogsPage() {
|
|||||||
{line.method}
|
{line.method}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{line.path && (
|
{line.path && (
|
||||||
<span className={styles.path} title={line.path}>
|
<span className={styles.path} title={line.path}>
|
||||||
{line.path}
|
{line.path}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{line.message && <span className={styles.message}>{line.message}</span>}
|
||||||
</div>
|
</div>
|
||||||
{line.message && <div className={styles.message}>{line.message}</div>}
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : logState.buffer.length > 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t('logs.search_empty_title')}
|
||||||
|
description={t('logs.search_empty_desc')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyState title={t('logs.empty_title')} description={t('logs.empty_desc')} />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'errors' && (
|
||||||
|
<Card
|
||||||
|
extra={
|
||||||
|
<Button variant="secondary" size="sm" onClick={loadErrorLogs} loading={loadingErrors}>
|
||||||
|
{t('common.refresh')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{errorLogs.length === 0 ? (
|
||||||
|
<div className="hint">{t('logs.error_logs_empty')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="item-list">
|
||||||
|
{errorLogs.map((item) => (
|
||||||
|
<div key={item.name} className="item-row">
|
||||||
|
<div className="item-meta">
|
||||||
|
<div className="item-title">{item.name}</div>
|
||||||
|
<div className="item-subtitle">
|
||||||
|
{item.size ? `${(item.size / 1024).toFixed(1)} KB` : ''}{' '}
|
||||||
|
{item.modified ? formatUnixTimestamp(item.modified) : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
<div className="item-actions">
|
||||||
})}
|
<Button
|
||||||
</div>
|
variant="secondary"
|
||||||
</div>
|
size="sm"
|
||||||
) : logState.buffer.length > 0 ? (
|
onClick={() => downloadErrorLog(item.name)}
|
||||||
<EmptyState
|
>
|
||||||
title={t('logs.search_empty_title')}
|
{t('logs.error_logs_download')}
|
||||||
description={t('logs.search_empty_desc')}
|
</Button>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<EmptyState title={t('logs.empty_title')} description={t('logs.empty_desc')} />
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card
|
|
||||||
title={t('logs.error_logs_modal_title')}
|
|
||||||
extra={
|
|
||||||
<Button variant="secondary" size="sm" onClick={loadErrorLogs} loading={loadingErrors}>
|
|
||||||
{t('common.refresh')}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{errorLogs.length === 0 ? (
|
|
||||||
<div className="hint">{t('logs.error_logs_empty')}</div>
|
|
||||||
) : (
|
|
||||||
<div className="item-list">
|
|
||||||
{errorLogs.map((item) => (
|
|
||||||
<div key={item.name} className="item-row">
|
|
||||||
<div className="item-meta">
|
|
||||||
<div className="item-title">{item.name}</div>
|
|
||||||
<div className="item-subtitle">
|
|
||||||
{item.size ? `${(item.size / 1024).toFixed(1)} KB` : ''}{' '}
|
|
||||||
{item.modified ? formatUnixTimestamp(item.modified) : ''}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="item-actions">
|
))}
|
||||||
<Button
|
</div>
|
||||||
variant="secondary"
|
)}
|
||||||
size="sm"
|
</Card>
|
||||||
onClick={() => downloadErrorLog(item.name)}
|
)}
|
||||||
>
|
|
||||||
{t('logs.error_logs_download')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ export function OAuthPage() {
|
|||||||
{t('auth_login.oauth_callback_button')}
|
{t('auth_login.oauth_callback_button')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{state.callbackStatus === 'success' && (
|
{state.callbackStatus === 'success' && state.status === 'waiting' && (
|
||||||
<div className="status-badge success" style={{ marginTop: 8 }}>
|
<div className="status-badge success" style={{ marginTop: 8 }}>
|
||||||
{t('auth_login.oauth_callback_status_success')}
|
{t('auth_login.oauth_callback_status_success')}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ type PendingKey =
|
|||||||
| 'switchProject'
|
| 'switchProject'
|
||||||
| 'switchPreview'
|
| 'switchPreview'
|
||||||
| 'usage'
|
| 'usage'
|
||||||
| 'requestLog'
|
|
||||||
| 'loggingToFile'
|
| 'loggingToFile'
|
||||||
| 'wsAuth';
|
| 'wsAuth';
|
||||||
|
|
||||||
@@ -70,7 +69,7 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
const toggleSetting = async (
|
const toggleSetting = async (
|
||||||
section: PendingKey,
|
section: PendingKey,
|
||||||
rawKey: 'debug' | 'usage-statistics-enabled' | 'request-log' | 'logging-to-file' | 'ws-auth',
|
rawKey: 'debug' | 'usage-statistics-enabled' | 'logging-to-file' | 'ws-auth',
|
||||||
value: boolean,
|
value: boolean,
|
||||||
updater: (val: boolean) => Promise<any>,
|
updater: (val: boolean) => Promise<any>,
|
||||||
successMessage: string
|
successMessage: string
|
||||||
@@ -81,8 +80,6 @@ export function SettingsPage() {
|
|||||||
return config?.debug ?? false;
|
return config?.debug ?? false;
|
||||||
case 'usage-statistics-enabled':
|
case 'usage-statistics-enabled':
|
||||||
return config?.usageStatisticsEnabled ?? false;
|
return config?.usageStatisticsEnabled ?? false;
|
||||||
case 'request-log':
|
|
||||||
return config?.requestLog ?? false;
|
|
||||||
case 'logging-to-file':
|
case 'logging-to-file':
|
||||||
return config?.loggingToFile ?? false;
|
return config?.loggingToFile ?? false;
|
||||||
case 'ws-auth':
|
case 'ws-auth':
|
||||||
@@ -200,21 +197,6 @@ export function SettingsPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ToggleSwitch
|
|
||||||
label={t('basic_settings.request_log_enable')}
|
|
||||||
checked={config?.requestLog ?? false}
|
|
||||||
disabled={disableControls || pending.requestLog || loading}
|
|
||||||
onChange={(value) =>
|
|
||||||
toggleSetting(
|
|
||||||
'requestLog',
|
|
||||||
'request-log',
|
|
||||||
value,
|
|
||||||
configApi.updateRequestLog,
|
|
||||||
t('notification.request_log_updated')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
label={t('basic_settings.logging_to_file_enable')}
|
label={t('basic_settings.logging_to_file_enable')}
|
||||||
checked={config?.loggingToFile ?? false}
|
checked={config?.loggingToFile ?? false}
|
||||||
|
|||||||
@@ -66,11 +66,12 @@
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
:global(.loading-spinner) {
|
.loadingOverlaySpinner {
|
||||||
border-color: rgba(59, 130, 246, 0.25);
|
border-color: rgba(59, 130, 246, 0.25);
|
||||||
border-top-color: var(--primary-color);
|
border-top-color: var(--primary-color);
|
||||||
}
|
box-shadow: 0 0 10px rgba(59, 130, 246, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.loadingOverlayText {
|
.loadingOverlayText {
|
||||||
|
|||||||
@@ -520,7 +520,7 @@ export function UsagePage() {
|
|||||||
{loading && !usage && (
|
{loading && !usage && (
|
||||||
<div className={styles.loadingOverlay} aria-busy="true">
|
<div className={styles.loadingOverlay} aria-busy="true">
|
||||||
<div className={styles.loadingOverlayContent}>
|
<div className={styles.loadingOverlayContent}>
|
||||||
<LoadingSpinner size={28} />
|
<LoadingSpinner size={28} className={styles.loadingOverlaySpinner} />
|
||||||
<span className={styles.loadingOverlayText}>{t('common.loading')}</span>
|
<span className={styles.loadingOverlayText}>{t('common.loading')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,9 +18,6 @@ export const ampcodeApi = {
|
|||||||
updateUpstreamApiKey: (apiKey: string) => apiClient.put('/ampcode/upstream-api-key', { value: apiKey }),
|
updateUpstreamApiKey: (apiKey: string) => apiClient.put('/ampcode/upstream-api-key', { value: apiKey }),
|
||||||
clearUpstreamApiKey: () => apiClient.delete('/ampcode/upstream-api-key'),
|
clearUpstreamApiKey: () => apiClient.delete('/ampcode/upstream-api-key'),
|
||||||
|
|
||||||
updateRestrictManagementToLocalhost: (enabled: boolean) =>
|
|
||||||
apiClient.put('/ampcode/restrict-management-to-localhost', { value: enabled }),
|
|
||||||
|
|
||||||
async getModelMappings(): Promise<AmpcodeModelMapping[]> {
|
async getModelMappings(): Promise<AmpcodeModelMapping[]> {
|
||||||
const data = await apiClient.get('/ampcode/model-mappings');
|
const data = await apiClient.get('/ampcode/model-mappings');
|
||||||
const list = data?.['model-mappings'] ?? data?.modelMappings ?? data?.items ?? data;
|
const list = data?.['model-mappings'] ?? data?.modelMappings ?? data?.items ?? data;
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ class ApiClient {
|
|||||||
(config) => {
|
(config) => {
|
||||||
// 设置 baseURL
|
// 设置 baseURL
|
||||||
config.baseURL = this.apiBase;
|
config.baseURL = this.apiBase;
|
||||||
|
if (config.url) {
|
||||||
|
// Normalize deprecated Gemini endpoint to the current path.
|
||||||
|
config.url = config.url.replace(/\/generative-language-api-key\b/g, '/gemini-api-key');
|
||||||
|
}
|
||||||
|
|
||||||
// 添加认证头
|
// 添加认证头
|
||||||
if (this.managementKey) {
|
if (this.managementKey) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiClient } from './client';
|
import { apiClient } from './client';
|
||||||
|
import { LOGS_TIMEOUT_MS } from '@/utils/constants';
|
||||||
|
|
||||||
export interface LogsQuery {
|
export interface LogsQuery {
|
||||||
after?: number;
|
after?: number;
|
||||||
@@ -25,14 +26,17 @@ export interface ErrorLogsResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const logsApi = {
|
export const logsApi = {
|
||||||
fetchLogs: (params: LogsQuery = {}): Promise<LogsResponse> => apiClient.get('/logs', { params }),
|
fetchLogs: (params: LogsQuery = {}): Promise<LogsResponse> =>
|
||||||
|
apiClient.get('/logs', { params, timeout: LOGS_TIMEOUT_MS }),
|
||||||
|
|
||||||
clearLogs: () => apiClient.delete('/logs'),
|
clearLogs: () => apiClient.delete('/logs'),
|
||||||
|
|
||||||
fetchErrorLogs: (): Promise<ErrorLogsResponse> => apiClient.get('/request-error-logs'),
|
fetchErrorLogs: (): Promise<ErrorLogsResponse> =>
|
||||||
|
apiClient.get('/request-error-logs', { timeout: LOGS_TIMEOUT_MS }),
|
||||||
|
|
||||||
downloadErrorLog: (filename: string) =>
|
downloadErrorLog: (filename: string) =>
|
||||||
apiClient.getRaw(`/request-error-logs/${encodeURIComponent(filename)}`, {
|
apiClient.getRaw(`/request-error-logs/${encodeURIComponent(filename)}`, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
|
timeout: LOGS_TIMEOUT_MS
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const serializeApiKeyEntry = (entry: ApiKeyEntry) => {
|
|||||||
|
|
||||||
const serializeProviderKey = (config: ProviderKeyConfig) => {
|
const serializeProviderKey = (config: ProviderKeyConfig) => {
|
||||||
const payload: Record<string, any> = { 'api-key': config.apiKey };
|
const payload: Record<string, any> = { 'api-key': config.apiKey };
|
||||||
|
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
|
||||||
if (config.baseUrl) payload['base-url'] = config.baseUrl;
|
if (config.baseUrl) payload['base-url'] = config.baseUrl;
|
||||||
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
|
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
|
||||||
const headers = serializeHeaders(config.headers);
|
const headers = serializeHeaders(config.headers);
|
||||||
@@ -62,6 +63,7 @@ const serializeProviderKey = (config: ProviderKeyConfig) => {
|
|||||||
|
|
||||||
const serializeGeminiKey = (config: GeminiKeyConfig) => {
|
const serializeGeminiKey = (config: GeminiKeyConfig) => {
|
||||||
const payload: Record<string, any> = { 'api-key': config.apiKey };
|
const payload: Record<string, any> = { 'api-key': config.apiKey };
|
||||||
|
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
|
||||||
if (config.baseUrl) payload['base-url'] = config.baseUrl;
|
if (config.baseUrl) payload['base-url'] = config.baseUrl;
|
||||||
const headers = serializeHeaders(config.headers);
|
const headers = serializeHeaders(config.headers);
|
||||||
if (headers) payload.headers = headers;
|
if (headers) payload.headers = headers;
|
||||||
@@ -79,6 +81,7 @@ const serializeOpenAIProvider = (provider: OpenAIProviderConfig) => {
|
|||||||
? provider.apiKeyEntries.map((entry) => serializeApiKeyEntry(entry))
|
? provider.apiKeyEntries.map((entry) => serializeApiKeyEntry(entry))
|
||||||
: []
|
: []
|
||||||
};
|
};
|
||||||
|
if (provider.prefix?.trim()) payload.prefix = provider.prefix.trim();
|
||||||
const headers = serializeHeaders(provider.headers);
|
const headers = serializeHeaders(provider.headers);
|
||||||
if (headers) payload.headers = headers;
|
if (headers) payload.headers = headers;
|
||||||
const models = serializeModelAliases(provider.models);
|
const models = serializeModelAliases(provider.models);
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ const normalizeExcludedModels = (input: any): string[] => {
|
|||||||
return normalized;
|
return normalized;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizePrefix = (value: any): string | undefined => {
|
||||||
|
if (value === undefined || value === null) return undefined;
|
||||||
|
const trimmed = String(value).trim();
|
||||||
|
return trimmed ? trimmed : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeApiKeyEntry = (entry: any): ApiKeyEntry | null => {
|
const normalizeApiKeyEntry = (entry: any): ApiKeyEntry | null => {
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
const apiKey = entry['api-key'] ?? entry.apiKey ?? entry.key ?? (typeof entry === 'string' ? entry : '');
|
const apiKey = entry['api-key'] ?? entry.apiKey ?? entry.key ?? (typeof entry === 'string' ? entry : '');
|
||||||
@@ -93,6 +99,8 @@ const normalizeProviderKeyConfig = (item: any): ProviderKeyConfig | null => {
|
|||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
|
|
||||||
const config: ProviderKeyConfig = { apiKey: trimmed };
|
const config: ProviderKeyConfig = { apiKey: trimmed };
|
||||||
|
const prefix = normalizePrefix(item.prefix ?? item['prefix']);
|
||||||
|
if (prefix) config.prefix = prefix;
|
||||||
const baseUrl = item['base-url'] ?? item.baseUrl;
|
const baseUrl = item['base-url'] ?? item.baseUrl;
|
||||||
const proxyUrl = item['proxy-url'] ?? item.proxyUrl;
|
const proxyUrl = item['proxy-url'] ?? item.proxyUrl;
|
||||||
if (baseUrl) config.baseUrl = String(baseUrl);
|
if (baseUrl) config.baseUrl = String(baseUrl);
|
||||||
@@ -118,6 +126,8 @@ const normalizeGeminiKeyConfig = (item: any): GeminiKeyConfig | null => {
|
|||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
|
|
||||||
const config: GeminiKeyConfig = { apiKey: trimmed };
|
const config: GeminiKeyConfig = { apiKey: trimmed };
|
||||||
|
const prefix = normalizePrefix(item.prefix ?? item['prefix']);
|
||||||
|
if (prefix) config.prefix = prefix;
|
||||||
const baseUrl = item['base-url'] ?? item.baseUrl ?? item['base_url'];
|
const baseUrl = item['base-url'] ?? item.baseUrl ?? item['base_url'];
|
||||||
if (baseUrl) config.baseUrl = String(baseUrl);
|
if (baseUrl) config.baseUrl = String(baseUrl);
|
||||||
const headers = normalizeHeaders(item.headers);
|
const headers = normalizeHeaders(item.headers);
|
||||||
@@ -155,6 +165,8 @@ const normalizeOpenAIProvider = (provider: any): OpenAIProviderConfig | null =>
|
|||||||
apiKeyEntries
|
apiKeyEntries
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const prefix = normalizePrefix(provider.prefix ?? provider['prefix']);
|
||||||
|
if (prefix) result.prefix = prefix;
|
||||||
if (headers) result.headers = headers;
|
if (headers) result.headers = headers;
|
||||||
if (models.length) result.models = models;
|
if (models.length) result.models = models;
|
||||||
if (priority !== undefined) result.priority = Number(priority);
|
if (priority !== undefined) result.priority = Number(priority);
|
||||||
@@ -205,15 +217,6 @@ const normalizeAmpcodeConfig = (payload: any): AmpcodeConfig | undefined => {
|
|||||||
const upstreamApiKey = source['upstream-api-key'] ?? source.upstreamApiKey ?? source['upstream_api_key'];
|
const upstreamApiKey = source['upstream-api-key'] ?? source.upstreamApiKey ?? source['upstream_api_key'];
|
||||||
if (upstreamApiKey) config.upstreamApiKey = String(upstreamApiKey);
|
if (upstreamApiKey) config.upstreamApiKey = String(upstreamApiKey);
|
||||||
|
|
||||||
const restrictManagementToLocalhost = normalizeBoolean(
|
|
||||||
source['restrict-management-to-localhost'] ??
|
|
||||||
source.restrictManagementToLocalhost ??
|
|
||||||
source['restrict_management_to_localhost']
|
|
||||||
);
|
|
||||||
if (restrictManagementToLocalhost !== undefined) {
|
|
||||||
config.restrictManagementToLocalhost = restrictManagementToLocalhost;
|
|
||||||
}
|
|
||||||
|
|
||||||
const forceModelMappings = normalizeBoolean(
|
const forceModelMappings = normalizeBoolean(
|
||||||
source['force-model-mappings'] ?? source.forceModelMappings ?? source['force_model_mappings']
|
source['force-model-mappings'] ?? source.forceModelMappings ?? source['force_model_mappings']
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -308,6 +308,12 @@ textarea {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.switch-label-left {
|
||||||
|
.label {
|
||||||
|
order: -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.pill {
|
.pill {
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
border-radius: $radius-full;
|
border-radius: $radius-full;
|
||||||
@@ -410,6 +416,17 @@ textarea {
|
|||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.request-log-modal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: $spacing-md;
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
border: 1px dashed var(--border-color);
|
border: 1px dashed var(--border-color);
|
||||||
border-radius: $radius-lg;
|
border-radius: $radius-lg;
|
||||||
|
|||||||
@@ -336,6 +336,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $spacing-lg;
|
gap: $spacing-lg;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
|
||||||
@media (max-width: $breakpoint-mobile) {
|
@media (max-width: $breakpoint-mobile) {
|
||||||
padding: $spacing-md;
|
padding: $spacing-md;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export interface AmpcodeModelMapping {
|
|||||||
export interface AmpcodeConfig {
|
export interface AmpcodeConfig {
|
||||||
upstreamUrl?: string;
|
upstreamUrl?: string;
|
||||||
upstreamApiKey?: string;
|
upstreamApiKey?: string;
|
||||||
restrictManagementToLocalhost?: boolean;
|
|
||||||
modelMappings?: AmpcodeModelMapping[];
|
modelMappings?: AmpcodeModelMapping[];
|
||||||
forceModelMappings?: boolean;
|
forceModelMappings?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface ApiKeyEntry {
|
|||||||
|
|
||||||
export interface GeminiKeyConfig {
|
export interface GeminiKeyConfig {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
|
prefix?: string;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
excludedModels?: string[];
|
excludedModels?: string[];
|
||||||
@@ -25,6 +26,7 @@ export interface GeminiKeyConfig {
|
|||||||
|
|
||||||
export interface ProviderKeyConfig {
|
export interface ProviderKeyConfig {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
|
prefix?: string;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
proxyUrl?: string;
|
proxyUrl?: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
@@ -34,6 +36,7 @@ export interface ProviderKeyConfig {
|
|||||||
|
|
||||||
export interface OpenAIProviderConfig {
|
export interface OpenAIProviderConfig {
|
||||||
name: string;
|
name: string;
|
||||||
|
prefix?: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
apiKeyEntries: ApiKeyEntry[];
|
apiKeyEntries: ApiKeyEntry[];
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const LOG_REFRESH_DELAY_MS = 500;
|
|||||||
// 日志相关
|
// 日志相关
|
||||||
export const MAX_LOG_LINES = 2000;
|
export const MAX_LOG_LINES = 2000;
|
||||||
export const LOG_FETCH_LIMIT = 2500;
|
export const LOG_FETCH_LIMIT = 2500;
|
||||||
|
export const LOGS_TIMEOUT_MS = 60 * 1000;
|
||||||
|
|
||||||
// 认证文件分页
|
// 认证文件分页
|
||||||
export const DEFAULT_AUTH_FILES_PAGE_SIZE = 20;
|
export const DEFAULT_AUTH_FILES_PAGE_SIZE = 20;
|
||||||
|
|||||||
Reference in New Issue
Block a user