mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-28 12:39:20 -05:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
604fe4f71d | ||
|
|
907bca8aac | ||
|
|
8c58200dcc | ||
|
|
0b4dc453a5 | ||
|
|
35012f99fc | ||
|
|
22ad4f83f6 | ||
|
|
26720d9e49 | ||
|
|
a9aa4a9a08 | ||
|
|
48bcf9f5f0 | ||
|
|
f63f06d879 | ||
|
|
63b356b8d7 | ||
|
|
a6cea2ad3f | ||
|
|
5bb2715094 | ||
|
|
9b40f7e101 | ||
|
|
df872823c1 | ||
|
|
9304653c34 | ||
|
|
b5685b7010 | ||
|
|
89c37b2f02 | ||
|
|
c316cb896e | ||
|
|
bc3c8facfa | ||
|
|
69afb865c0 | ||
|
|
ba30d88840 | ||
|
|
6d0d622bd8 | ||
|
|
ab50c7e9eb | ||
|
|
e507bea2e3 | ||
|
|
0f00ad7200 | ||
|
|
b0b0c93337 | ||
|
|
20fd355b87 | ||
|
|
4388084704 | ||
|
|
fe2535461d | ||
|
|
985598e529 | ||
|
|
b2b6d0588b | ||
|
|
64628ee3ed | ||
|
|
66d4142039 | ||
|
|
cf72295f99 | ||
|
|
89f85c73ff | ||
|
|
adabf1d92b | ||
|
|
c3a6dfadb4 | ||
|
|
d119be0f82 | ||
|
|
1e308ec4c5 |
16
.coveragerc
Normal file
16
.coveragerc
Normal file
@@ -0,0 +1,16 @@
|
||||
# Test coverage configuration
|
||||
# This file tells Jest what to include/exclude from coverage reports
|
||||
|
||||
# Coverage patterns
|
||||
- "src/**/*.{ts,tsx}"
|
||||
|
||||
# Exclusions
|
||||
- "!src/**/*.d.ts"
|
||||
- "!src/index.ts"
|
||||
- "!src/**/__tests__/**"
|
||||
- "!src/**/*.test.{ts,tsx}"
|
||||
- "!src/**/*.spec.{ts,tsx}"
|
||||
- "!**/node_modules/**"
|
||||
- "!coverage/**"
|
||||
- "!dist/**"
|
||||
- "!build/**"
|
||||
50
.github/copilot-instructions.md
vendored
Normal file
50
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
# MCPHub Coding Instructions
|
||||
|
||||
## Project Overview
|
||||
|
||||
MCPHub is a TypeScript/Node.js MCP server management hub that provides unified access through HTTP endpoints.
|
||||
|
||||
**Core Components:**
|
||||
|
||||
- **Backend**: Express.js + TypeScript + ESM (`src/server.ts`)
|
||||
- **Frontend**: React/Vite + Tailwind CSS (`frontend/`)
|
||||
- **MCP Integration**: Connects multiple MCP servers (`src/services/mcpService.ts`)
|
||||
|
||||
## Development Environment
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev # Start both backend and frontend
|
||||
pnpm backend:dev # Backend only
|
||||
pnpm frontend:dev # Frontend only
|
||||
```
|
||||
|
||||
## Project Conventions
|
||||
|
||||
### File Structure
|
||||
|
||||
- `src/services/` - Core business logic
|
||||
- `src/controllers/` - HTTP request handlers
|
||||
- `src/types/index.ts` - TypeScript type definitions
|
||||
- `src/config/index.ts` - Configuration management
|
||||
|
||||
### Key Notes
|
||||
|
||||
- Use ESM modules: Import with `.js` extensions, not `.ts`
|
||||
- Configuration file: `mcp_settings.json`
|
||||
- Endpoint formats: `/mcp/{group|server}` and `/mcp/$smart`
|
||||
- All code comments must be written in English
|
||||
- Frontend uses i18n with resource files in `locales/` folder
|
||||
- Server-side code should use appropriate abstraction layers for extensibility and replaceability
|
||||
|
||||
## Development Process
|
||||
|
||||
- For complex features, implement step by step and wait for confirmation before proceeding to the next step
|
||||
- After implementing features, no separate summary documentation is needed - update README.md and README.zh.md as appropriate
|
||||
|
||||
### Development Entry Points
|
||||
|
||||
- **MCP Servers**: Modify `src/services/mcpService.ts`
|
||||
- **API Endpoints**: Add routes in `src/routes/`, controllers in `src/controllers/`
|
||||
- **Frontend Features**: Start from `frontend/src/pages/`
|
||||
- **Testing**: Follow existing patterns in `tests/`
|
||||
17
.github/workflows/build.yml
vendored
17
.github/workflows/build.yml
vendored
@@ -8,6 +8,9 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
matrix:
|
||||
variant: ${{ startsWith(github.ref, 'refs/tags/') && fromJSON('["base", "full"]') || fromJSON('["base"]') }}
|
||||
@@ -30,16 +33,27 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: endsWith(github.repository, 'mcphub')
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: endsWith(github.repository, 'mcphubx')
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: samanhappy/mcphub
|
||||
images: |
|
||||
${{ endsWith(github.repository, 'mcphub') && github.repository || '' }}
|
||||
${{ endsWith(github.repository, 'mcphubx') && format('ghcr.io/{0}', github.repository) || '' }}
|
||||
tags: |
|
||||
type=raw,value=edge${{ matrix.variant == 'full' && '-full' || '' }},enable=${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
|
||||
type=semver,pattern={{version}}${{ matrix.variant == 'full' && '-full' || '' }},enable=${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
@@ -48,6 +62,7 @@ jobs:
|
||||
latest=false
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
if: endsWith(github.repository, 'mcphub') || endsWith(github.repository, 'mcphubx')
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
|
||||
112
.github/workflows/ci.yml
vendored
Normal file
112
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,112 @@
|
||||
name: CI/CD Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run linter
|
||||
run: pnpm lint
|
||||
|
||||
- name: Run type checking
|
||||
run: pnpm backend:build
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test:ci
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
file: ./coverage/lcov.info
|
||||
flags: unittests
|
||||
name: codecov-umbrella
|
||||
|
||||
# build:
|
||||
# runs-on: ubuntu-latest
|
||||
# needs: test
|
||||
|
||||
# steps:
|
||||
# - name: Checkout code
|
||||
# uses: actions/checkout@v4
|
||||
|
||||
# - name: Setup Node.js
|
||||
# uses: actions/setup-node@v4
|
||||
# with:
|
||||
# node-version: '20.x'
|
||||
|
||||
# - name: Enable Corepack
|
||||
# run: corepack enable
|
||||
|
||||
# - name: Install dependencies
|
||||
# run: pnpm install --frozen-lockfile
|
||||
|
||||
# - name: Build application
|
||||
# run: pnpm build
|
||||
|
||||
# - name: Verify build artifacts
|
||||
# run: node scripts/verify-dist.js
|
||||
|
||||
# integration-test:
|
||||
# runs-on: ubuntu-latest
|
||||
# needs: test
|
||||
|
||||
# services:
|
||||
# postgres:
|
||||
# image: postgres:15
|
||||
# env:
|
||||
# POSTGRES_PASSWORD: postgres
|
||||
# POSTGRES_DB: mcphub_test
|
||||
# options: >-
|
||||
# --health-cmd pg_isready
|
||||
# --health-interval 10s
|
||||
# --health-timeout 5s
|
||||
# --health-retries 5
|
||||
|
||||
# steps:
|
||||
# - name: Checkout code
|
||||
# uses: actions/checkout@v4
|
||||
|
||||
# - name: Setup Node.js
|
||||
# uses: actions/setup-node@v4
|
||||
# with:
|
||||
# node-version: '20.x'
|
||||
|
||||
# - name: Enable Corepack
|
||||
# run: corepack enable
|
||||
|
||||
# - name: Install dependencies
|
||||
# run: pnpm install --frozen-lockfile
|
||||
|
||||
# - name: Build application
|
||||
# run: pnpm build
|
||||
|
||||
# - name: Run integration tests
|
||||
# run: |
|
||||
# export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/mcphub_test"
|
||||
# node test-integration.ts
|
||||
# env:
|
||||
# NODE_ENV: test
|
||||
1
.github/workflows/npm-publish.yml
vendored
1
.github/workflows/npm-publish.yml
vendored
@@ -7,6 +7,7 @@ on:
|
||||
jobs:
|
||||
publish-npm:
|
||||
runs-on: ubuntu-latest
|
||||
if: endsWith(github.repository, 'mcphub')
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -24,3 +24,5 @@ yarn-error.log*
|
||||
.vscode/
|
||||
*.log
|
||||
coverage/
|
||||
|
||||
data/
|
||||
@@ -21,6 +21,9 @@ ENV REQUEST_TIMEOUT=$REQUEST_TIMEOUT
|
||||
ARG BASE_PATH=""
|
||||
ENV BASE_PATH=$BASE_PATH
|
||||
|
||||
ARG READONLY=false
|
||||
ENV READONLY=$READONLY
|
||||
|
||||
ENV PNPM_HOME=/usr/local/share/pnpm
|
||||
ENV PATH=$PNPM_HOME:$PATH
|
||||
RUN mkdir -p $PNPM_HOME && \
|
||||
|
||||
@@ -57,7 +57,7 @@ Create a `mcp_settings.json` file to customize your server settings:
|
||||
**Recommended**: Mount your custom config:
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 -v $(pwd)/mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
|
||||
docker run -p 3000:3000 -v ./mcp_settings.json:/app/mcp_settings.json -v ./data:/app/data samanhappy/mcphub
|
||||
```
|
||||
|
||||
or run with default settings:
|
||||
|
||||
@@ -57,7 +57,7 @@ MCPHub 通过将多个 MCP(Model Context Protocol)服务器组织为灵活
|
||||
**推荐**:挂载自定义配置:
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 -v $(pwd)/mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
|
||||
docker run -p 3000:3000 -v ./mcp_settings.json:/app/mcp_settings.json -v ./data:/app/data samanhappy/mcphub
|
||||
```
|
||||
|
||||
或使用默认配置运行:
|
||||
|
||||
@@ -27,10 +27,7 @@ MCPHub uses several configuration files:
|
||||
"args": ["arg1", "arg2"],
|
||||
"env": {
|
||||
"ENV_VAR": "value"
|
||||
},
|
||||
"cwd": "/working/directory",
|
||||
"timeout": 30000,
|
||||
"restart": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,8 +47,7 @@ MCPHub uses several configuration files:
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless"],
|
||||
"timeout": 60000
|
||||
"args": ["@playwright/mcp@latest", "--headless"]
|
||||
},
|
||||
"slack": {
|
||||
"command": "npx",
|
||||
@@ -79,12 +75,6 @@ MCPHub uses several configuration files:
|
||||
| Field | Type | Default | Description |
|
||||
| -------------- | ------- | --------------- | --------------------------- |
|
||||
| `env` | object | `{}` | Environment variables |
|
||||
| `cwd` | string | `process.cwd()` | Working directory |
|
||||
| `timeout` | number | `30000` | Startup timeout (ms) |
|
||||
| `restart` | boolean | `true` | Auto-restart on failure |
|
||||
| `maxRestarts` | number | `5` | Maximum restart attempts |
|
||||
| `restartDelay` | number | `5000` | Delay between restarts (ms) |
|
||||
| `stdio` | string | `pipe` | stdio configuration |
|
||||
|
||||
## Common MCP Server Examples
|
||||
|
||||
@@ -262,42 +252,14 @@ MCPHub supports environment variable substitution using `${VAR_NAME}` syntax:
|
||||
"args": ["-m", "api_server"],
|
||||
"env": {
|
||||
"API_KEY": "${API_KEY}",
|
||||
"API_URL": "${API_BASE_URL}/v1",
|
||||
"DEBUG": "${NODE_ENV:development}"
|
||||
"API_URL": "${API_BASE_URL}/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Default values can be specified with `${VAR_NAME:default}`:
|
||||
|
||||
```json
|
||||
{
|
||||
"timeout": "${MCP_TIMEOUT:30000}",
|
||||
"maxRestarts": "${MCP_MAX_RESTARTS:5}"
|
||||
}
|
||||
```
|
||||
|
||||
### Conditional Configuration
|
||||
|
||||
Use different configurations based on environment:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"database": {
|
||||
"command": "python",
|
||||
"args": ["-m", "db_server"],
|
||||
"env": {
|
||||
"DB_URL": "${NODE_ENV:development == 'production' ? DATABASE_URL : DEV_DATABASE_URL}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Server Scripts
|
||||
{/* ### Custom Server Scripts
|
||||
|
||||
#### Local Python Server
|
||||
|
||||
@@ -373,7 +335,7 @@ Complement `mcp_settings.json` with server metadata:
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
``` */}
|
||||
|
||||
## Group Management
|
||||
|
||||
@@ -385,25 +347,18 @@ Complement `mcp_settings.json` with server metadata:
|
||||
"production": {
|
||||
"name": "Production Tools",
|
||||
"description": "Stable production servers",
|
||||
"servers": ["fetch", "slack", "github"],
|
||||
"access": "authenticated",
|
||||
"rateLimit": {
|
||||
"requestsPerMinute": 100,
|
||||
"burstLimit": 20
|
||||
}
|
||||
"servers": ["fetch", "slack", "github"]
|
||||
},
|
||||
"experimental": {
|
||||
"name": "Experimental Features",
|
||||
"description": "Beta and experimental servers",
|
||||
"servers": ["experimental-ai", "beta-search"],
|
||||
"access": "admin",
|
||||
"enabled": false
|
||||
"servers": ["experimental-ai", "beta-search"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Access Control
|
||||
{/* ### Access Control
|
||||
|
||||
| Access Level | Description |
|
||||
| --------------- | -------------------------- |
|
||||
@@ -422,9 +377,9 @@ MCPHub supports hot reloading of configurations:
|
||||
# Reload configurations without restart
|
||||
curl -X POST http://localhost:3000/api/admin/reload-config \
|
||||
-H "Authorization: Bearer your-admin-token"
|
||||
```
|
||||
``` */}
|
||||
|
||||
### Configuration Validation
|
||||
{/* ### Configuration Validation
|
||||
|
||||
MCPHub validates configurations on startup and reload:
|
||||
|
||||
@@ -436,7 +391,7 @@ MCPHub validates configurations on startup and reload:
|
||||
"requireDocumentation": true
|
||||
}
|
||||
}
|
||||
```
|
||||
``` */}
|
||||
|
||||
## Best Practices
|
||||
|
||||
@@ -453,7 +408,7 @@ MCPHub validates configurations on startup and reload:
|
||||
}
|
||||
```
|
||||
|
||||
2. **Limit server permissions**:
|
||||
{/* 2. **Limit server permissions**:
|
||||
```json
|
||||
{
|
||||
"filesystem": {
|
||||
@@ -464,9 +419,9 @@ MCPHub validates configurations on startup and reload:
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
``` */}
|
||||
|
||||
### Performance
|
||||
{/* ### Performance
|
||||
|
||||
1. **Set appropriate timeouts**:
|
||||
|
||||
@@ -486,9 +441,9 @@ MCPHub validates configurations on startup and reload:
|
||||
"MEMORY_LIMIT": "512MB"
|
||||
}
|
||||
}
|
||||
```
|
||||
``` */}
|
||||
|
||||
### Monitoring
|
||||
{/* ### Monitoring
|
||||
|
||||
1. **Enable health checks**:
|
||||
|
||||
@@ -510,9 +465,9 @@ MCPHub validates configurations on startup and reload:
|
||||
"LOG_FORMAT": "json"
|
||||
}
|
||||
}
|
||||
```
|
||||
``` */}
|
||||
|
||||
## Troubleshooting
|
||||
{/* ## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
@@ -521,9 +476,9 @@ MCPHub validates configurations on startup and reload:
|
||||
```bash
|
||||
# Test command manually
|
||||
uvx mcp-server-fetch
|
||||
```
|
||||
``` */}
|
||||
|
||||
**Environment variables not found**: Verify `.env` file
|
||||
{/* **Environment variables not found**: Verify `.env` file
|
||||
|
||||
```bash
|
||||
# Check environment
|
||||
@@ -535,9 +490,9 @@ printenv | grep API_KEY
|
||||
```bash
|
||||
# Verify executable permissions
|
||||
ls -la /path/to/server
|
||||
```
|
||||
``` */}
|
||||
|
||||
### Debug Configuration
|
||||
{/* ### Debug Configuration
|
||||
|
||||
Enable debug mode for detailed logging:
|
||||
|
||||
@@ -550,8 +505,8 @@ Enable debug mode for detailed logging:
|
||||
"logStartup": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
``` */}
|
||||
{/*
|
||||
### Validation Errors
|
||||
|
||||
Common validation errors and solutions:
|
||||
@@ -559,6 +514,6 @@ Common validation errors and solutions:
|
||||
1. **Missing required fields**: Add `command` and `args`
|
||||
2. **Invalid timeout**: Use number, not string
|
||||
3. **Environment variable not found**: Check `.env` file
|
||||
4. **Command not found**: Verify installation and PATH
|
||||
4. **Command not found**: Verify installation and PATH */}
|
||||
|
||||
This comprehensive guide covers all aspects of configuring MCP servers in MCPHub for various use cases and environments.
|
||||
|
||||
@@ -27,27 +27,16 @@
|
||||
"pages": [
|
||||
"features/server-management",
|
||||
"features/group-management",
|
||||
"features/smart-routing",
|
||||
"features/authentication",
|
||||
"features/monitoring"
|
||||
"features/smart-routing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Configuration",
|
||||
"pages": [
|
||||
"configuration/mcp-settings",
|
||||
"configuration/environment-variables",
|
||||
"configuration/docker-setup",
|
||||
"configuration/nginx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Development",
|
||||
"pages": [
|
||||
"development/getting-started",
|
||||
"development/architecture",
|
||||
"development/contributing"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -67,51 +56,16 @@
|
||||
"pages": [
|
||||
"zh/features/server-management",
|
||||
"zh/features/group-management",
|
||||
"zh/features/smart-routing",
|
||||
"zh/features/authentication",
|
||||
"zh/features/monitoring"
|
||||
"zh/features/smart-routing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "配置指南",
|
||||
"pages": [
|
||||
"zh/configuration/mcp-settings",
|
||||
"zh/configuration/environment-variables",
|
||||
"zh/configuration/docker-setup",
|
||||
"zh/configuration/nginx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "开发指南",
|
||||
"pages": [
|
||||
"zh/development/getting-started",
|
||||
"zh/development/architecture",
|
||||
"zh/development/contributing"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "API Reference",
|
||||
"groups": [
|
||||
{
|
||||
"group": "MCP Endpoints",
|
||||
"pages": [
|
||||
"api-reference/introduction",
|
||||
"api-reference/mcp-http",
|
||||
"api-reference/mcp-sse",
|
||||
"api-reference/smart-routing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Management API",
|
||||
"pages": [
|
||||
"api-reference/servers",
|
||||
"api-reference/groups",
|
||||
"api-reference/auth",
|
||||
"api-reference/logs",
|
||||
"api-reference/config"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -144,13 +98,13 @@
|
||||
"links": [
|
||||
{
|
||||
"label": "Demo",
|
||||
"href": "http://localhost:3000"
|
||||
"href": "https://demo.mcphubx.com"
|
||||
}
|
||||
],
|
||||
"primary": {
|
||||
"type": "button",
|
||||
"label": "Get Started",
|
||||
"href": "https://docs.hubmcp.dev/quickstart"
|
||||
"href": "https://docs.mcphubx.com/quickstart"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
|
||||
@@ -30,9 +30,6 @@ Groups are named collections of MCP servers that can be accessed through dedicat
|
||||
3. **Fill Group Details**:
|
||||
|
||||
- **Name**: Unique identifier for the group
|
||||
- **Display Name**: Human-readable name
|
||||
- **Description**: Purpose and contents of the group
|
||||
- **Access Level**: Public, Private, or Restricted
|
||||
|
||||
4. **Add Servers**: Select servers to include in the group
|
||||
|
||||
@@ -46,14 +43,11 @@ curl -X POST http://localhost:3000/api/groups \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||
-d '{
|
||||
"name": "web-automation",
|
||||
"displayName": "Web Automation Tools",
|
||||
"description": "Browser automation and web scraping tools",
|
||||
"servers": ["playwright", "fetch"],
|
||||
"accessLevel": "public"
|
||||
"servers": ["playwright", "fetch"]
|
||||
}'
|
||||
```
|
||||
|
||||
### Via Configuration File
|
||||
{/* ### Via Configuration File
|
||||
|
||||
Define groups in your `mcp_settings.json`:
|
||||
|
||||
@@ -66,20 +60,16 @@ Define groups in your `mcp_settings.json`:
|
||||
},
|
||||
"groups": {
|
||||
"web-tools": {
|
||||
"displayName": "Web Tools",
|
||||
"description": "Web scraping and browser automation",
|
||||
"name": "web",
|
||||
"servers": ["fetch", "playwright"],
|
||||
"accessLevel": "public"
|
||||
},
|
||||
"communication": {
|
||||
"displayName": "Communication Tools",
|
||||
"description": "Messaging and collaboration tools",
|
||||
"name": "communication",
|
||||
"servers": ["slack"],
|
||||
"accessLevel": "private"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
``` */}
|
||||
|
||||
## Group Types and Use Cases
|
||||
|
||||
@@ -177,7 +167,7 @@ Define groups in your `mcp_settings.json`:
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Group Access Control
|
||||
{/* ## Group Access Control
|
||||
|
||||
### Access Levels
|
||||
|
||||
@@ -254,7 +244,7 @@ curl -X DELETE http://localhost:3000/api/groups/web-tools/members/user123 \
|
||||
# List group members
|
||||
curl http://localhost:3000/api/groups/web-tools/members \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||
```
|
||||
``` */}
|
||||
|
||||
## Group Endpoints
|
||||
|
||||
@@ -346,7 +336,7 @@ Response will only include tools from `fetch` and `playwright` servers.
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Batch Server Updates
|
||||
{/* ### Batch Server Updates
|
||||
|
||||
Update multiple servers at once:
|
||||
|
||||
@@ -357,9 +347,9 @@ curl -X PUT http://localhost:3000/api/groups/web-tools/servers \
|
||||
-d '{
|
||||
"servers": ["fetch", "playwright", "selenium"]
|
||||
}'
|
||||
```
|
||||
``` */}
|
||||
|
||||
## Group Monitoring
|
||||
{/* ## Group Monitoring
|
||||
|
||||
### Group Status
|
||||
|
||||
@@ -393,9 +383,9 @@ Metrics include:
|
||||
- Request count by tool
|
||||
- Response times
|
||||
- Error rates
|
||||
- User activity
|
||||
- User activity */}
|
||||
|
||||
## Advanced Group Features
|
||||
{/* ## Advanced Group Features
|
||||
|
||||
### Nested Groups
|
||||
|
||||
@@ -474,7 +464,7 @@ Define policies for group behavior:
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
``` */}
|
||||
|
||||
## Best Practices
|
||||
|
||||
@@ -494,7 +484,7 @@ Define policies for group behavior:
|
||||
**Use Descriptive Names**: Choose names that clearly indicate the group's purpose and contents.
|
||||
</Tip>
|
||||
|
||||
### Security Considerations
|
||||
{/* ### Security Considerations
|
||||
|
||||
<Warning>
|
||||
**Principle of Least Privilege**: Only give users access to groups they actually need.
|
||||
@@ -507,7 +497,7 @@ Define policies for group behavior:
|
||||
|
||||
<Warning>
|
||||
**Regular Access Reviews**: Periodically review group memberships and remove unnecessary access.
|
||||
</Warning>
|
||||
</Warning> */}
|
||||
|
||||
### Performance Optimization
|
||||
|
||||
|
||||
@@ -311,7 +311,7 @@ Servers can use environment variables for configuration:
|
||||
- `${VAR_NAME:-default}`: Uses default if variable not set
|
||||
- `${VAR_NAME:+value}`: Uses value if variable is set
|
||||
|
||||
### Working Directory
|
||||
{/* ### Working Directory
|
||||
|
||||
Set the working directory for server execution:
|
||||
|
||||
@@ -323,7 +323,7 @@ Set the working directory for server execution:
|
||||
"cwd": "/path/to/server/directory"
|
||||
}
|
||||
}
|
||||
```
|
||||
``` */}
|
||||
|
||||
### Command Variations
|
||||
|
||||
@@ -352,7 +352,7 @@ Different ways to specify server commands:
|
||||
```
|
||||
</Tab>
|
||||
|
||||
<Tab title="Direct Python">
|
||||
{/* <Tab title="Direct Python">
|
||||
```json
|
||||
{
|
||||
"direct-python": {
|
||||
@@ -373,7 +373,7 @@ Different ways to specify server commands:
|
||||
}
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
</Tab> */}
|
||||
</Tabs>
|
||||
|
||||
## Advanced Features
|
||||
@@ -382,12 +382,12 @@ Different ways to specify server commands:
|
||||
|
||||
MCPHub supports hot reloading of server configurations:
|
||||
|
||||
1. **Config File Changes**: Automatically detects changes to `mcp_settings.json`
|
||||
2. **Dashboard Updates**: Immediately applies changes made through the web interface
|
||||
3. **API Updates**: Real-time updates via REST API calls
|
||||
4. **Zero Downtime**: Graceful server restarts without affecting other servers
|
||||
{/* 1. **Config File Changes**: Automatically detects changes to `mcp_settings.json` */}
|
||||
1. **Dashboard Updates**: Immediately applies changes made through the web interface
|
||||
2. **API Updates**: Real-time updates via REST API calls
|
||||
3. **Zero Downtime**: Graceful server restarts without affecting other servers
|
||||
|
||||
### Resource Limits
|
||||
{/* ### Resource Limits
|
||||
|
||||
Control server resource usage:
|
||||
|
||||
@@ -403,9 +403,9 @@ Control server resource usage:
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
``` */}
|
||||
|
||||
### Dependency Management
|
||||
{/* ### Dependency Management
|
||||
|
||||
Handle server dependencies:
|
||||
|
||||
@@ -439,7 +439,7 @@ Handle server dependencies:
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
</AccordionGroup> */}
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ Smart Routing requires additional setup compared to basic MCPHub usage:
|
||||
2. **Embedding Service**: OpenAI API or compatible service
|
||||
3. **Environment Configuration**: Proper configuration variables
|
||||
|
||||
### Quick Setup
|
||||
{/* ### Quick Setup
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Docker Compose">
|
||||
@@ -265,7 +265,7 @@ EMBEDDING_BATCH_SIZE=100
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
</AccordionGroup> */}
|
||||
|
||||
## Using Smart Routing
|
||||
|
||||
@@ -287,7 +287,7 @@ Access Smart Routing through the special `$smart` endpoint:
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Basic Usage
|
||||
{/* ### Basic Usage
|
||||
|
||||
Connect your AI client to the Smart Routing endpoint and make natural language requests:
|
||||
|
||||
@@ -330,9 +330,9 @@ Response:
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
``` */}
|
||||
|
||||
### Advanced Queries
|
||||
{/* ### Advanced Queries
|
||||
|
||||
Smart Routing supports various query types:
|
||||
|
||||
@@ -405,9 +405,9 @@ Smart Routing supports various query types:
|
||||
}'
|
||||
```
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
</AccordionGroup> */}
|
||||
|
||||
### Tool Execution
|
||||
{/* ### Tool Execution
|
||||
|
||||
Once Smart Routing finds relevant tools, you can execute them directly:
|
||||
|
||||
@@ -426,9 +426,9 @@ curl -X POST http://localhost:3000/mcp/$smart \
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
``` */}
|
||||
|
||||
## Performance Optimization
|
||||
{/* ## Performance Optimization
|
||||
|
||||
### Embedding Cache
|
||||
|
||||
@@ -585,7 +585,7 @@ curl -X POST http://localhost:3000/api/smart-routing/feedback \
|
||||
"successful": true,
|
||||
"comments": "Perfect tool for the task"
|
||||
}'
|
||||
```
|
||||
``` */}
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
title: MCPHub Documentation
|
||||
title: MCPHub
|
||||
description: 'The Unified Hub for Model Context Protocol (MCP) Servers'
|
||||
---
|
||||
|
||||
<img className="block dark:hidden" src="/images/hero-light.png" alt="Hero Light" />
|
||||
<img className="hidden dark:block" src="/images/hero-dark.png" alt="Hero Dark" />
|
||||
{/* <img className="block dark:hidden" src="/images/hero-light.png" alt="Hero Light" />
|
||||
<img className="hidden dark:block" src="/images/hero-dark.png" alt="Hero Dark" /> */}
|
||||
|
||||
# Welcome to MCPHub
|
||||
|
||||
@@ -16,12 +16,12 @@ MCPHub makes it easy to manage and scale multiple MCP (Model Context Protocol) s
|
||||
<Card title="Unified Management" icon="server" href="/features/server-management">
|
||||
Centrally manage multiple MCP servers with hot-swappable configuration
|
||||
</Card>
|
||||
<Card title="Smart Routing" icon="route" href="/features/smart-routing">
|
||||
AI-powered tool discovery using vector semantic search
|
||||
</Card>
|
||||
<Card title="Group Management" icon="users" href="/features/group-management">
|
||||
Organize servers into logical groups for streamlined access control
|
||||
</Card>
|
||||
<Card title="Smart Routing" icon="route" href="/features/smart-routing">
|
||||
AI-powered tool discovery using vector semantic search
|
||||
</Card>
|
||||
<Card title="Real-time Monitoring" icon="chart-line" href="/features/monitoring">
|
||||
Monitor server status and performance from a unified dashboard
|
||||
</Card>
|
||||
|
||||
@@ -72,7 +72,6 @@ Optional for Smart Routing:
|
||||
-p 3000:3000 \
|
||||
-e PORT=3000 \
|
||||
-e BASE_PATH="" \
|
||||
-e REQUEST_TIMEOUT=60000 \
|
||||
samanhappy/mcphub:latest
|
||||
```
|
||||
|
||||
@@ -144,12 +143,9 @@ Optional for Smart Routing:
|
||||
|
||||
# Run with custom port
|
||||
PORT=8080 mcphub
|
||||
|
||||
# Run with custom config path
|
||||
MCP_SETTINGS_PATH=/path/to/mcp_settings.json mcphub
|
||||
```
|
||||
|
||||
#### 3. Local Installation
|
||||
{/* #### 3. Local Installation
|
||||
|
||||
You can also install MCPHub locally in a project:
|
||||
|
||||
@@ -170,8 +166,7 @@ Optional for Smart Routing:
|
||||
|
||||
# Run MCPHub
|
||||
./start.sh
|
||||
```
|
||||
|
||||
``` */}
|
||||
</Tab>
|
||||
|
||||
<Tab title="Local Development">
|
||||
@@ -419,7 +414,7 @@ Smart Routing provides AI-powered tool discovery using vector semantic search.
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Environment Configuration
|
||||
{/* ### Environment Configuration
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
@@ -435,13 +430,13 @@ EMBEDDING_MODEL=text-embedding-3-small
|
||||
|
||||
# Optional: Enable smart routing
|
||||
ENABLE_SMART_ROUTING=true
|
||||
```
|
||||
``` */}
|
||||
|
||||
## Verification
|
||||
|
||||
After installation, verify MCPHub is working:
|
||||
|
||||
### 1. Health Check
|
||||
{/* ### 1. Health Check
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/api/health
|
||||
@@ -455,9 +450,9 @@ Expected response:
|
||||
"version": "x.x.x",
|
||||
"uptime": 123
|
||||
}
|
||||
```
|
||||
``` */}
|
||||
|
||||
### 2. Dashboard Access
|
||||
### Dashboard Access
|
||||
|
||||
Open your browser and navigate to:
|
||||
|
||||
@@ -465,7 +460,7 @@ Open your browser and navigate to:
|
||||
http://localhost:3000
|
||||
```
|
||||
|
||||
### 3. API Test
|
||||
{/* ### 3. API Test
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/mcp \
|
||||
@@ -476,7 +471,7 @@ curl -X POST http://localhost:3000/mcp \
|
||||
"method": "tools/list",
|
||||
"params": {}
|
||||
}'
|
||||
```
|
||||
``` */}
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 1.1 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -106,12 +106,10 @@ Once your servers are configured, connect your AI clients using MCPHub endpoints
|
||||
Access all configured MCP servers: ``` http://localhost:3000/mcp ```
|
||||
</Tab>
|
||||
<Tab title="Specific Group">
|
||||
Access servers in a specific group: ``` http://localhost:3000/mcp/{group - name}
|
||||
```
|
||||
Access servers in a specific group: ``` http://localhost:3000/mcp/{groupName} ```
|
||||
</Tab>
|
||||
<Tab title="Individual Server">
|
||||
Access a single server: ``` http://localhost:3000/mcp/{server - name}
|
||||
```
|
||||
Access a single server: ``` http://localhost:3000/mcp/{serverName} ```
|
||||
</Tab>
|
||||
<Tab title="Smart Routing">
|
||||
Use AI-powered tool discovery: ``` http://localhost:3000/mcp/$smart ```
|
||||
@@ -172,7 +170,7 @@ Here are some popular MCP servers you can add:
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Verification
|
||||
{/* ## Verification
|
||||
|
||||
Test your setup by making a simple request:
|
||||
|
||||
@@ -187,7 +185,7 @@ curl -X POST http://localhost:3000/mcp \
|
||||
}'
|
||||
```
|
||||
|
||||
You should receive a list of available tools from your configured MCP servers.
|
||||
You should receive a list of available tools from your configured MCP servers. */}
|
||||
|
||||
## Next Steps
|
||||
|
||||
|
||||
172
docs/testing-framework.md
Normal file
172
docs/testing-framework.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# 测试框架和自动化测试实现报告
|
||||
|
||||
## 概述
|
||||
|
||||
本项目已成功引入现代化的测试框架和自动化测试流程。实现了基于Jest的测试环境,支持TypeScript、ES模块,并包含完整的CI/CD配置。
|
||||
|
||||
## 已实现的功能
|
||||
|
||||
### 1. 测试框架配置
|
||||
|
||||
- **Jest配置**: 使用`jest.config.cjs`配置文件,支持ES模块和TypeScript
|
||||
- **覆盖率报告**: 配置了代码覆盖率收集和报告
|
||||
- **测试环境**: 支持Node.js环境的单元测试和集成测试
|
||||
- **模块映射**: 配置了路径别名支持
|
||||
|
||||
### 2. 测试工具和辅助函数
|
||||
|
||||
创建了完善的测试工具库 (`tests/utils/testHelpers.ts`):
|
||||
|
||||
- **认证工具**: JWT token生成和管理
|
||||
- **HTTP测试**: Supertest集成用于API测试
|
||||
- **数据生成**: 测试数据工厂函数
|
||||
- **响应断言**: 自定义API响应验证器
|
||||
- **环境管理**: 测试环境变量配置
|
||||
|
||||
### 3. 测试用例实现
|
||||
|
||||
已实现的测试场景:
|
||||
|
||||
#### 基础配置测试 (`tests/basic.test.ts`)
|
||||
- Jest配置验证
|
||||
- 异步操作支持测试
|
||||
- 自定义匹配器验证
|
||||
|
||||
#### 认证逻辑测试 (`tests/auth.logic.test.ts`)
|
||||
- 用户登录逻辑
|
||||
- 密码验证
|
||||
- JWT生成和验证
|
||||
- 错误处理场景
|
||||
- 用户数据验证
|
||||
|
||||
#### 路径工具测试 (`tests/utils/pathLogic.test.ts`)
|
||||
- 配置文件路径解析
|
||||
- 环境变量处理
|
||||
- 文件系统操作
|
||||
- 错误处理和边界条件
|
||||
- 跨平台路径处理
|
||||
|
||||
### 4. CI/CD配置
|
||||
|
||||
GitHub Actions配置 (`.github/workflows/ci.yml`):
|
||||
|
||||
- **多Node.js版本支持**: 18.x和20.x
|
||||
- **自动化测试流程**:
|
||||
- 代码检查 (ESLint)
|
||||
- 类型检查 (TypeScript)
|
||||
- 单元测试执行
|
||||
- 覆盖率报告
|
||||
- **构建验证**: 应用构建和产物验证
|
||||
- **集成测试**: 包含数据库环境的集成测试
|
||||
|
||||
### 5. 测试脚本
|
||||
|
||||
在`package.json`中添加的测试命令:
|
||||
|
||||
```json
|
||||
{
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"test:verbose": "jest --verbose",
|
||||
"test:ci": "jest --ci --coverage --watchAll=false"
|
||||
}
|
||||
```
|
||||
|
||||
## 测试结果
|
||||
|
||||
当前测试统计:
|
||||
- **测试套件**: 3个
|
||||
- **测试用例**: 19个
|
||||
- **通过率**: 100%
|
||||
- **执行时间**: ~15秒
|
||||
|
||||
### 测试覆盖的功能模块
|
||||
|
||||
1. **认证系统**: 用户登录、JWT处理、密码验证
|
||||
2. **配置管理**: 文件路径解析、环境变量处理
|
||||
3. **基础设施**: Jest配置、测试工具验证
|
||||
|
||||
## 技术特点
|
||||
|
||||
### 现代化特性
|
||||
|
||||
- **ES模块支持**: 完全支持ES2022模块语法
|
||||
- **TypeScript集成**: 类型安全的测试编写
|
||||
- **异步测试**: Promise和async/await支持
|
||||
- **模拟系统**: Jest mock功能的深度使用
|
||||
- **参数化测试**: 数据驱动的测试用例
|
||||
|
||||
### 最佳实践
|
||||
|
||||
- **测试隔离**: 每个测试用例独立运行
|
||||
- **Mock管理**: 统一的mock清理和重置
|
||||
- **错误处理**: 完整的错误场景测试
|
||||
- **边界测试**: 输入验证和边界条件覆盖
|
||||
- **文档化**: 清晰的测试用例命名和描述
|
||||
|
||||
## 后续扩展计划
|
||||
|
||||
### 短期目标
|
||||
|
||||
1. **API测试**: 为REST API端点添加集成测试
|
||||
2. **数据库测试**: 添加数据模型和存储层测试
|
||||
3. **中间件测试**: 认证和权限中间件测试
|
||||
4. **服务层测试**: 核心业务逻辑测试
|
||||
|
||||
### 中期目标
|
||||
|
||||
1. **端到端测试**: 使用Playwright或Cypress
|
||||
2. **性能测试**: API响应时间和负载测试
|
||||
3. **安全测试**: 输入验证和安全漏洞测试
|
||||
4. **契约测试**: API契约验证
|
||||
|
||||
### 长期目标
|
||||
|
||||
1. **测试数据管理**: 测试数据库和fixture管理
|
||||
2. **视觉回归测试**: UI组件的视觉测试
|
||||
3. **监控集成**: 生产环境测试监控
|
||||
4. **自动化测试报告**: 详细的测试报告和趋势分析
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 添加新测试用例
|
||||
|
||||
1. 在`tests/`目录下创建对应的测试文件
|
||||
2. 使用`testHelpers.ts`中的工具函数
|
||||
3. 遵循命名约定: `*.test.ts`或`*.spec.ts`
|
||||
4. 确保测试用例具有清晰的描述和断言
|
||||
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
# 运行所有测试
|
||||
pnpm test
|
||||
|
||||
# 监听模式
|
||||
pnpm test:watch
|
||||
|
||||
# 生成覆盖率报告
|
||||
pnpm test:coverage
|
||||
|
||||
# CI模式运行
|
||||
pnpm test:ci
|
||||
```
|
||||
|
||||
### Mock最佳实践
|
||||
|
||||
- 在`beforeEach`中清理所有mock
|
||||
- 使用具体的mock实现而不是空函数
|
||||
- 验证mock被正确调用
|
||||
- 保持mock的一致性和可维护性
|
||||
|
||||
## 结论
|
||||
|
||||
本项目已成功建立了完整的现代化测试框架,具备以下优势:
|
||||
|
||||
1. **高度可扩展**: 易于添加新的测试用例和测试类型
|
||||
2. **开发友好**: 丰富的工具函数和清晰的结构
|
||||
3. **CI/CD就绪**: 完整的自动化流水线配置
|
||||
4. **质量保证**: 代码覆盖率和持续测试验证
|
||||
|
||||
这个测试框架为项目的持续发展和质量保证提供了坚实的基础,支持敏捷开发和持续集成的最佳实践。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -49,448 +49,369 @@ curl -X POST http://localhost:3000/api/servers \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||
-d '{
|
||||
"name": "my-server",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/files"],
|
||||
"env": {
|
||||
"NODE_ENV": "production"
|
||||
},
|
||||
"cwd": "/app"
|
||||
"name": "fetch-server",
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-fetch"],
|
||||
"env": {}
|
||||
}'
|
||||
```
|
||||
|
||||
## 服务器配置
|
||||
## 流行的 MCP 服务器示例
|
||||
|
||||
### 通用配置选项
|
||||
<AccordionGroup>
|
||||
<Accordion title="Web 抓取服务器">
|
||||
提供网页抓取和 HTTP 请求功能:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "filesystem-server",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/allowed/path"],
|
||||
"env": {
|
||||
"NODE_ENV": "production",
|
||||
"DEBUG": "mcp:*",
|
||||
"MAX_FILES": "1000"
|
||||
},
|
||||
"cwd": "/app/workspace",
|
||||
"timeout": 30000,
|
||||
"retries": 3,
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
```json
|
||||
{
|
||||
"fetch": {
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-fetch"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Python 服务器示例
|
||||
**可用工具:**
|
||||
- `fetch`: 发起 HTTP 请求
|
||||
- `fetch_html`: 抓取网页
|
||||
- `fetch_json`: 从 API 获取 JSON 数据
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "python-server",
|
||||
"command": "python",
|
||||
"args": ["-m", "mcp_server", "--config", "config.json"],
|
||||
"env": {
|
||||
"PYTHONPATH": "/app/python",
|
||||
"API_KEY": "${API_KEY}",
|
||||
"LOG_LEVEL": "INFO"
|
||||
},
|
||||
"cwd": "/app/python-server"
|
||||
}
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
### Node.js 服务器示例
|
||||
<Accordion title="Playwright 浏览器自动化">
|
||||
用于网页交互的浏览器自动化:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "node-server",
|
||||
"command": "node",
|
||||
"args": ["server.js", "--port", "3001"],
|
||||
"env": {
|
||||
"NODE_ENV": "production",
|
||||
"PORT": "3001",
|
||||
"DATABASE_URL": "${DATABASE_URL}"
|
||||
},
|
||||
"cwd": "/app/node-server"
|
||||
}
|
||||
```
|
||||
```json
|
||||
{
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**可用工具:**
|
||||
- `playwright_navigate`: 导航到网页
|
||||
- `playwright_screenshot`: 截取屏幕截图
|
||||
- `playwright_click`: 点击元素
|
||||
- `playwright_fill`: 填写表单
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="文件系统操作">
|
||||
文件和目录管理:
|
||||
|
||||
```json
|
||||
{
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/allowed/path"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**可用工具:**
|
||||
- `read_file`: 读取文件内容
|
||||
- `write_file`: 写入文件
|
||||
- `create_directory`: 创建目录
|
||||
- `list_directory`: 列出目录内容
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="SQLite 数据库">
|
||||
数据库操作:
|
||||
|
||||
```json
|
||||
{
|
||||
"sqlite": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-sqlite", "/path/to/database.db"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**可用工具:**
|
||||
- `execute_query`: 执行 SQL 查询
|
||||
- `describe_tables`: 获取表结构
|
||||
- `create_table`: 创建新表
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Slack 集成">
|
||||
Slack 工作空间集成:
|
||||
|
||||
```json
|
||||
{
|
||||
"slack": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-slack"],
|
||||
"env": {
|
||||
"SLACK_BOT_TOKEN": "xoxb-your-bot-token",
|
||||
"SLACK_TEAM_ID": "T1234567890"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**可用工具:**
|
||||
- `send_slack_message`: 发送消息到频道
|
||||
- `list_slack_channels`: 列出可用频道
|
||||
- `get_slack_thread`: 获取线程消息
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="GitHub 集成">
|
||||
GitHub 仓库操作:
|
||||
|
||||
```json
|
||||
{
|
||||
"github": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||
"env": {
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_your_token"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**可用工具:**
|
||||
- `create_or_update_file`: 创建/更新仓库文件
|
||||
- `search_repositories`: 搜索 GitHub 仓库
|
||||
- `create_issue`: 创建问题
|
||||
- `create_pull_request`: 创建拉取请求
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Google Drive">
|
||||
Google Drive 文件操作:
|
||||
|
||||
```json
|
||||
{
|
||||
"gdrive": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-gdrive"],
|
||||
"env": {
|
||||
"GDRIVE_CLIENT_ID": "your-client-id",
|
||||
"GDRIVE_CLIENT_SECRET": "your-client-secret",
|
||||
"GDRIVE_REDIRECT_URI": "your-redirect-uri"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**可用工具:**
|
||||
- `gdrive_search`: 搜索文件和文件夹
|
||||
- `gdrive_read`: 读取文件内容
|
||||
- `gdrive_create`: 创建新文件
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="高德地图(中国)">
|
||||
中国地图和位置服务:
|
||||
|
||||
```json
|
||||
{
|
||||
"amap": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@amap/amap-maps-mcp-server"],
|
||||
"env": {
|
||||
"AMAP_MAPS_API_KEY": "your-api-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**可用工具:**
|
||||
- `search_location`: 搜索位置
|
||||
- `get_directions`: 获取路线指引
|
||||
- `reverse_geocode`: 将坐标转换为地址
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## 服务器生命周期管理
|
||||
|
||||
### 启动服务器
|
||||
|
||||
```bash
|
||||
# 启动特定服务器
|
||||
curl -X POST http://localhost:3000/api/servers/my-server/start \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
服务器会在以下情况下自动启动:
|
||||
|
||||
# 启动所有服务器
|
||||
curl -X POST http://localhost:3000/api/servers/start-all \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
- MCPHub 启动时
|
||||
- 通过仪表板或 API 添加服务器时
|
||||
- 服务器配置更新时
|
||||
- 手动重启已停止的服务器时
|
||||
|
||||
### 停止服务器
|
||||
|
||||
```bash
|
||||
# 停止特定服务器
|
||||
curl -X POST http://localhost:3000/api/servers/my-server/stop \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
您可以通过以下方式停止服务器:
|
||||
|
||||
# 优雅停止(等待当前请求完成)
|
||||
curl -X POST http://localhost:3000/api/servers/my-server/stop \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"graceful": true, "timeout": 30000}'
|
||||
```
|
||||
- **通过仪表板**: 切换服务器状态开关
|
||||
- **通过 API**: 发送 POST 请求到 `/api/servers/{name}/toggle`
|
||||
- **自动停止**: 服务器崩溃或遇到错误时会自动停止
|
||||
|
||||
### 重启服务器
|
||||
|
||||
```bash
|
||||
# 重启服务器
|
||||
curl -X POST http://localhost:3000/api/servers/my-server/restart \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
服务器会在以下情况下自动重启:
|
||||
|
||||
## 热配置重载
|
||||
|
||||
### 更新服务器配置
|
||||
|
||||
无需重启即可更新配置:
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:3000/api/servers/my-server/config \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{
|
||||
"env": {
|
||||
"DEBUG": "mcp:verbose",
|
||||
"NEW_SETTING": "value"
|
||||
},
|
||||
"args": ["--verbose", "--new-flag"]
|
||||
}'
|
||||
```
|
||||
|
||||
### 批量配置更新
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:3000/api/servers/bulk-update \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{
|
||||
"servers": ["server1", "server2"],
|
||||
"config": {
|
||||
"env": {
|
||||
"LOG_LEVEL": "DEBUG"
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
- 配置更改时
|
||||
- 环境变量更新后
|
||||
- 通过仪表板或 API 手动触发时
|
||||
|
||||
## 服务器状态监控
|
||||
|
||||
### 检查服务器状态
|
||||
### 状态指示器
|
||||
|
||||
```bash
|
||||
# 获取所有服务器状态
|
||||
curl -X GET http://localhost:3000/api/servers/status \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
每个服务器都显示状态指示器:
|
||||
|
||||
# 获取特定服务器状态
|
||||
curl -X GET http://localhost:3000/api/servers/my-server/status \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
- 🟢 **运行中**: 服务器处于活动状态并响应
|
||||
- 🟡 **启动中**: 服务器正在初始化
|
||||
- 🔴 **已停止**: 服务器未运行
|
||||
- ⚠️ **错误**: 服务器遇到错误
|
||||
|
||||
响应示例:
|
||||
### 实时日志
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-server",
|
||||
"status": "running",
|
||||
"pid": 12345,
|
||||
"uptime": 3600000,
|
||||
"memory": {
|
||||
"rss": 123456789,
|
||||
"heapTotal": 98765432,
|
||||
"heapUsed": 87654321
|
||||
},
|
||||
"cpu": {
|
||||
"user": 1000000,
|
||||
"system": 500000
|
||||
},
|
||||
"lastRestart": "2024-01-01T12:00:00.000Z"
|
||||
}
|
||||
```
|
||||
实时查看服务器日志:
|
||||
|
||||
1. **仪表板日志**: 点击服务器查看其日志
|
||||
2. **API 日志**: 通过 `/api/logs` 端点访问日志
|
||||
3. **流式日志**: 通过 WebSocket 订阅日志流
|
||||
|
||||
### 健康检查
|
||||
|
||||
配置自动健康检查:
|
||||
MCPHub 自动执行健康检查:
|
||||
|
||||
- **初始化检查**: 验证服务器成功启动
|
||||
- **工具发现**: 确认检测到可用工具
|
||||
- **响应检查**: 测试服务器响应性
|
||||
- **资源监控**: 跟踪 CPU 和内存使用情况
|
||||
|
||||
## 配置管理
|
||||
|
||||
### 环境变量
|
||||
|
||||
服务器可以使用环境变量进行配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-server",
|
||||
"command": "node",
|
||||
"args": ["server.js"],
|
||||
"healthCheck": {
|
||||
"enabled": true,
|
||||
"interval": 30000,
|
||||
"timeout": 5000,
|
||||
"retries": 3,
|
||||
"endpoint": "/health",
|
||||
"expectedStatus": 200
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 负载均衡
|
||||
|
||||
### 配置多实例
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "load-balanced-server",
|
||||
"instances": 3,
|
||||
"command": "node",
|
||||
"args": ["server.js"],
|
||||
"loadBalancer": {
|
||||
"strategy": "round-robin",
|
||||
"healthCheck": true,
|
||||
"stickySession": false
|
||||
},
|
||||
"env": {
|
||||
"PORT": "${PORT}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 负载均衡策略
|
||||
|
||||
- **round-robin**: 轮询分发请求
|
||||
- **least-connections**: 分发到连接数最少的实例
|
||||
- **weighted**: 基于权重分发
|
||||
- **ip-hash**: 基于客户端 IP 的一致性哈希
|
||||
|
||||
## 资源限制
|
||||
|
||||
### 设置资源限制
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "resource-limited-server",
|
||||
"command": "python",
|
||||
"args": ["server.py"],
|
||||
"resources": {
|
||||
"memory": {
|
||||
"limit": "512MB",
|
||||
"warning": "400MB"
|
||||
},
|
||||
"cpu": {
|
||||
"limit": "50%",
|
||||
"priority": "normal"
|
||||
},
|
||||
"processes": {
|
||||
"max": 10
|
||||
"server-name": {
|
||||
"command": "python",
|
||||
"args": ["server.py"],
|
||||
"env": {
|
||||
"API_KEY": "${YOUR_API_KEY}",
|
||||
"DEBUG": "true",
|
||||
"MAX_CONNECTIONS": "10"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 监控资源使用
|
||||
**环境变量展开:**
|
||||
|
||||
```bash
|
||||
# 获取资源使用统计
|
||||
curl -X GET http://localhost:3000/api/servers/my-server/resources \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
- `${VAR_NAME}`: 展开为环境变量值
|
||||
- `${VAR_NAME:-default}`: 如果变量未设置则使用默认值
|
||||
- `${VAR_NAME:+value}`: 如果变量已设置则使用指定值
|
||||
|
||||
## 日志管理
|
||||
### 命令变体
|
||||
|
||||
### 配置日志记录
|
||||
指定服务器命令的不同方式:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-server",
|
||||
"command": "node",
|
||||
"args": ["server.js"],
|
||||
"logging": {
|
||||
"level": "info",
|
||||
"file": "/var/log/mcphub/my-server.log",
|
||||
"maxSize": "100MB",
|
||||
"maxFiles": 5,
|
||||
"rotate": true,
|
||||
"format": "json"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 查看日志
|
||||
|
||||
```bash
|
||||
# 获取实时日志
|
||||
curl -X GET http://localhost:3000/api/servers/my-server/logs \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# 获取带过滤器的日志
|
||||
curl -X GET "http://localhost:3000/api/servers/my-server/logs?level=error&limit=100" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
## 环境变量管理
|
||||
|
||||
### 动态环境变量
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "dynamic-server",
|
||||
"command": "python",
|
||||
"args": ["server.py"],
|
||||
"env": {
|
||||
"API_KEY": "${secrets:api_key}",
|
||||
"DATABASE_URL": "${vault:db_url}",
|
||||
"CURRENT_TIME": "${time:iso}",
|
||||
"SERVER_ID": "${server:id}",
|
||||
"HOSTNAME": "${system:hostname}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 环境变量模板
|
||||
|
||||
支持的模板变量:
|
||||
|
||||
- `${secrets:key}`: 从密钥存储获取
|
||||
- `${vault:path}`: 从 Vault 获取
|
||||
- `${env:VAR}`: 从系统环境变量获取
|
||||
- `${time:format}`: 当前时间戳
|
||||
- `${server:property}`: 服务器属性
|
||||
- `${system:property}`: 系统属性
|
||||
|
||||
## 服务发现
|
||||
|
||||
### 自动服务发现
|
||||
|
||||
```json
|
||||
{
|
||||
"serviceDiscovery": {
|
||||
"enabled": true,
|
||||
"provider": "consul",
|
||||
"config": {
|
||||
"host": "localhost",
|
||||
"port": 8500,
|
||||
"serviceName": "mcp-server",
|
||||
"tags": ["mcp", "ai", "api"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 注册服务
|
||||
|
||||
```bash
|
||||
# 手动注册服务
|
||||
curl -X POST http://localhost:3000/api/servers/my-server/register \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{
|
||||
"service": {
|
||||
"name": "my-mcp-service",
|
||||
"tags": ["mcp", "production"],
|
||||
"port": 3001,
|
||||
"check": {
|
||||
"http": "http://localhost:3001/health",
|
||||
"interval": "30s"
|
||||
<Tabs>
|
||||
<Tab title="npm/npx">
|
||||
```json
|
||||
{
|
||||
"npm-server": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "package-name", "--option", "value"]
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
```
|
||||
</Tab>
|
||||
|
||||
<Tab title="Python/uvx">
|
||||
```json
|
||||
{
|
||||
"python-server": {
|
||||
"command": "uvx",
|
||||
"args": ["package-name", "--config", "config.json"]
|
||||
}
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## 高级功能
|
||||
|
||||
### 热重载
|
||||
|
||||
MCPHub 支持服务器配置的热重载:
|
||||
|
||||
1. **仪表板更新**: 立即应用通过 Web 界面进行的更改
|
||||
2. **API 更新**: 通过 REST API 调用进行实时更新
|
||||
3. **零停机时间**: 优雅的服务器重启,不影响其他服务器
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见问题
|
||||
<AccordionGroup>
|
||||
<Accordion title="服务器无法启动">
|
||||
**检查以下项目:**
|
||||
- 命令在 PATH 中可用
|
||||
- 已设置所有必需的环境变量
|
||||
- 工作目录存在且可访问
|
||||
- 网络端口未被阻塞
|
||||
- 依赖项已安装
|
||||
|
||||
1. **服务器启动失败**
|
||||
**调试步骤:**
|
||||
1. 在仪表板中检查服务器日志
|
||||
2. 在终端中手动测试命令
|
||||
3. 验证环境变量展开
|
||||
4. 检查文件权限
|
||||
|
||||
```bash
|
||||
# 检查服务器日志
|
||||
curl -X GET http://localhost:3000/api/servers/my-server/logs?level=error \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
2. **配置无效**
|
||||
<Accordion title="服务器持续崩溃">
|
||||
**常见原因:**
|
||||
- 无效的配置参数
|
||||
- 缺少 API 密钥或凭据
|
||||
- 超出资源限制
|
||||
- 依赖项冲突
|
||||
|
||||
```bash
|
||||
# 验证配置
|
||||
curl -X POST http://localhost:3000/api/servers/validate \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d @server-config.json
|
||||
```
|
||||
**解决方案:**
|
||||
1. 查看服务器日志中的错误消息
|
||||
2. 使用最小配置进行测试
|
||||
3. 验证所有凭据和 API 密钥
|
||||
4. 检查系统资源可用性
|
||||
|
||||
3. **性能问题**
|
||||
```bash
|
||||
# 获取性能指标
|
||||
curl -X GET http://localhost:3000/api/servers/my-server/metrics \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
### 调试模式
|
||||
<Accordion title="工具未显示">
|
||||
**可能的问题:**
|
||||
- 服务器未完全初始化
|
||||
- 工具发现超时
|
||||
- 通信协议不匹配
|
||||
- 服务器报告错误
|
||||
|
||||
启用详细调试:
|
||||
**调试步骤:**
|
||||
1. 等待服务器初始化完成
|
||||
2. 检查服务器日志中的工具注册消息
|
||||
3. 测试与服务器的直接通信
|
||||
4. 验证 MCP 协议兼容性
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "debug-server",
|
||||
"command": "node",
|
||||
"args": ["--inspect=0.0.0.0:9229", "server.js"],
|
||||
"env": {
|
||||
"DEBUG": "*",
|
||||
"LOG_LEVEL": "debug",
|
||||
"NODE_ENV": "development"
|
||||
},
|
||||
"debugging": {
|
||||
"enabled": true,
|
||||
"port": 9229,
|
||||
"breakOnStart": false
|
||||
}
|
||||
}
|
||||
```
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## 高级配置
|
||||
## 下一步
|
||||
|
||||
### 自定义钩子
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "hooked-server",
|
||||
"command": "node",
|
||||
"args": ["server.js"],
|
||||
"hooks": {
|
||||
"beforeStart": ["./scripts/setup.sh"],
|
||||
"afterStart": ["./scripts/notify.sh"],
|
||||
"beforeStop": ["./scripts/cleanup.sh"],
|
||||
"onError": ["./scripts/alert.sh"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 配置模板
|
||||
|
||||
```json
|
||||
{
|
||||
"templates": {
|
||||
"python-server": {
|
||||
"command": "python",
|
||||
"args": ["-m", "mcp_server"],
|
||||
"env": {
|
||||
"PYTHONPATH": "/app/python",
|
||||
"LOG_LEVEL": "INFO"
|
||||
}
|
||||
}
|
||||
},
|
||||
"servers": {
|
||||
"my-python-server": {
|
||||
"extends": "python-server",
|
||||
"args": ["-m", "mcp_server", "--config", "custom.json"],
|
||||
"env": {
|
||||
"API_KEY": "custom-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
有关更多配置选项,请参阅 [MCP 设置配置](/zh/configuration/mcp-settings) 和 [环境变量](/zh/configuration/environment-variables) 文档。
|
||||
<CardGroup cols={2}>
|
||||
<Card title="分组管理" icon="users" href="/zh/features/group-management">
|
||||
将服务器组织成逻辑分组
|
||||
</Card>
|
||||
<Card title="智能路由" icon="route" href="/zh/features/smart-routing">
|
||||
设置 AI 驱动的工具发现
|
||||
</Card>
|
||||
<Card title="API 参考" icon="code" href="/zh/api-reference/servers">
|
||||
服务器管理 API 文档
|
||||
</Card>
|
||||
<Card title="配置指南" icon="cog" href="/zh/configuration/mcp-settings">
|
||||
详细配置选项
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -1,691 +1,367 @@
|
||||
---
|
||||
title: '智能路由'
|
||||
description: '自动负载均衡和请求路由到最佳的 MCP 服务器实例'
|
||||
description: '使用向量语义搜索的 AI 工具发现系统'
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
MCPHub 的智能路由系统自动将传入请求路由到最适合的 MCP 服务器实例。系统考虑服务器负载、响应时间、功能可用性和业务规则来做出路由决策。
|
||||
智能路由是 MCPHub 的智能工具发现系统,它使用向量语义搜索来自动找到与任何给定任务最相关的工具。AI 客户端无需手动指定使用哪些工具,只需描述他们想要完成的任务,智能路由就会识别并提供对最合适工具的访问。
|
||||
|
||||
## 路由策略
|
||||
## 智能路由的工作原理
|
||||
|
||||
### 轮询路由
|
||||
### 1. 工具索引
|
||||
|
||||
最简单的路由策略,按顺序分发请求:
|
||||
当服务器启动时,智能路由会自动:
|
||||
|
||||
```json
|
||||
{
|
||||
"routing": {
|
||||
"strategy": "round-robin",
|
||||
"targets": [
|
||||
{
|
||||
"serverId": "server-1",
|
||||
"weight": 1,
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"serverId": "server-2",
|
||||
"weight": 1,
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"serverId": "server-3",
|
||||
"weight": 1,
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
- 从 MCP 服务器发现所有可用工具
|
||||
- 提取工具元数据(名称、描述、参数)
|
||||
- 将工具信息转换为向量嵌入
|
||||
- 使用 pgvector 将嵌入存储在 PostgreSQL 中
|
||||
|
||||
### 加权轮询
|
||||
### 2. 语义搜索
|
||||
|
||||
基于服务器容量分配不同权重:
|
||||
当进行查询时:
|
||||
|
||||
```json
|
||||
{
|
||||
"routing": {
|
||||
"strategy": "weighted-round-robin",
|
||||
"targets": [
|
||||
{
|
||||
"serverId": "high-performance-server",
|
||||
"weight": 3,
|
||||
"specs": {
|
||||
"cpu": "8 cores",
|
||||
"memory": "32GB"
|
||||
}
|
||||
},
|
||||
{
|
||||
"serverId": "standard-server-1",
|
||||
"weight": 2,
|
||||
"specs": {
|
||||
"cpu": "4 cores",
|
||||
"memory": "16GB"
|
||||
}
|
||||
},
|
||||
{
|
||||
"serverId": "standard-server-2",
|
||||
"weight": 1,
|
||||
"specs": {
|
||||
"cpu": "2 cores",
|
||||
"memory": "8GB"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
- 用户查询被转换为向量嵌入
|
||||
- 相似性搜索使用余弦相似度找到匹配的工具
|
||||
- 动态阈值过滤掉不相关的结果
|
||||
- 结果按相关性得分排序
|
||||
|
||||
### 最少连接数
|
||||
### 3. 智能过滤
|
||||
|
||||
将请求路由到当前连接数最少的服务器:
|
||||
智能路由应用多个过滤器:
|
||||
|
||||
```json
|
||||
{
|
||||
"routing": {
|
||||
"strategy": "least-connections",
|
||||
"balancingMode": "dynamic",
|
||||
"healthCheck": {
|
||||
"enabled": true,
|
||||
"interval": 10000
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- **相关性阈值**:只返回高于相似性阈值的工具
|
||||
- **上下文感知**:考虑对话上下文
|
||||
- **工具可用性**:确保工具当前可访问
|
||||
- **权限过滤**:尊重用户访问权限
|
||||
|
||||
### 基于响应时间
|
||||
### 4. 工具执行
|
||||
|
||||
路由到响应时间最短的服务器:
|
||||
找到的工具可以直接执行:
|
||||
|
||||
```json
|
||||
{
|
||||
"routing": {
|
||||
"strategy": "fastest-response",
|
||||
"metrics": {
|
||||
"measurementWindow": "5m",
|
||||
"sampleSize": 100,
|
||||
"excludeSlowRequests": true,
|
||||
"slowRequestThreshold": "5s"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- 参数验证确保正确的工具使用
|
||||
- 错误处理提供有用的反馈
|
||||
- 响应格式保持一致性
|
||||
- 日志记录跟踪工具使用情况进行分析
|
||||
|
||||
## 基于功能的路由
|
||||
## 前置条件
|
||||
|
||||
### 工具特定路由
|
||||
智能路由需要比基础 MCPHub 使用更多的设置:
|
||||
|
||||
根据请求的工具类型路由到专门的服务器:
|
||||
### 必需组件
|
||||
|
||||
```json
|
||||
{
|
||||
"routing": {
|
||||
"strategy": "capability-based",
|
||||
"rules": [
|
||||
{
|
||||
"condition": {
|
||||
"tool": "filesystem"
|
||||
},
|
||||
"targets": ["filesystem-server-1", "filesystem-server-2"],
|
||||
"strategy": "least-connections"
|
||||
},
|
||||
{
|
||||
"condition": {
|
||||
"tool": "web-search"
|
||||
},
|
||||
"targets": ["search-server-1", "search-server-2"],
|
||||
"strategy": "round-robin"
|
||||
},
|
||||
{
|
||||
"condition": {
|
||||
"tool": "database"
|
||||
},
|
||||
"targets": ["db-server"],
|
||||
"strategy": "single"
|
||||
}
|
||||
],
|
||||
"fallback": {
|
||||
"targets": ["general-server-1", "general-server-2"],
|
||||
"strategy": "round-robin"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
1. **带有 pgvector 的 PostgreSQL**:用于嵌入存储的向量数据库
|
||||
2. **嵌入服务**:OpenAI API 或兼容服务
|
||||
3. **环境配置**:正确的配置变量
|
||||
|
||||
### 内容感知路由
|
||||
## 使用智能路由
|
||||
|
||||
基于请求内容进行智能路由:
|
||||
### 智能路由端点
|
||||
|
||||
```json
|
||||
{
|
||||
"routing": {
|
||||
"strategy": "content-aware",
|
||||
"rules": [
|
||||
{
|
||||
"condition": {
|
||||
"content.language": "python"
|
||||
},
|
||||
"targets": ["python-specialized-server"],
|
||||
"reason": "Python代码分析专用服务器"
|
||||
},
|
||||
{
|
||||
"condition": {
|
||||
"content.size": "> 1MB"
|
||||
},
|
||||
"targets": ["high-memory-server"],
|
||||
"reason": "大文件处理专用服务器"
|
||||
},
|
||||
{
|
||||
"condition": {
|
||||
"content.type": "image"
|
||||
},
|
||||
"targets": ["image-processing-server"],
|
||||
"reason": "图像处理专用服务器"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
通过特殊的 `$smart` 端点访问智能路由:
|
||||
|
||||
## 地理位置路由
|
||||
<Tabs>
|
||||
<Tab title="HTTP MCP">
|
||||
```
|
||||
http://localhost:3000/mcp/$smart
|
||||
```
|
||||
</Tab>
|
||||
|
||||
### 基于客户端位置
|
||||
<Tab title="SSE (Legacy)">
|
||||
```
|
||||
http://localhost:3000/sse/$smart
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
根据客户端地理位置路由到最近的服务器:
|
||||
{/* ## 性能优化
|
||||
|
||||
```json
|
||||
{
|
||||
"routing": {
|
||||
"strategy": "geo-location",
|
||||
"regions": [
|
||||
{
|
||||
"name": "北美",
|
||||
"countries": ["US", "CA", "MX"],
|
||||
"servers": ["us-east-1", "us-west-1", "ca-central-1"],
|
||||
"strategy": "least-latency"
|
||||
},
|
||||
{
|
||||
"name": "欧洲",
|
||||
"countries": ["DE", "FR", "UK", "NL"],
|
||||
"servers": ["eu-west-1", "eu-central-1"],
|
||||
"strategy": "round-robin"
|
||||
},
|
||||
{
|
||||
"name": "亚太",
|
||||
"countries": ["CN", "JP", "KR", "SG"],
|
||||
"servers": ["ap-southeast-1", "ap-northeast-1"],
|
||||
"strategy": "fastest-response"
|
||||
}
|
||||
],
|
||||
"fallback": {
|
||||
"servers": ["global-server-1"],
|
||||
"strategy": "single"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
### 嵌入缓存
|
||||
|
||||
### 延迟优化
|
||||
智能路由缓存嵌入以提高性能:
|
||||
|
||||
```bash
|
||||
# 配置延迟监控
|
||||
curl -X PUT http://localhost:3000/api/routing/latency-config \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{
|
||||
# 配置缓存设置
|
||||
EMBEDDING_CACHE_TTL=3600 # 缓存 1 小时
|
||||
EMBEDDING_CACHE_SIZE=10000 # 最多缓存 10k 个嵌入
|
||||
EMBEDDING_CACHE_CLEANUP=300 # 每 5 分钟清理一次
|
||||
```
|
||||
|
||||
### 批处理
|
||||
|
||||
工具批量索引以提高效率:
|
||||
|
||||
```bash
|
||||
# 嵌入生成的批大小
|
||||
EMBEDDING_BATCH_SIZE=100
|
||||
|
||||
# 并发嵌入请求
|
||||
EMBEDDING_CONCURRENCY=5
|
||||
|
||||
# 索引更新频率
|
||||
INDEX_UPDATE_INTERVAL=3600 # 每小时重新索引
|
||||
```
|
||||
|
||||
### 数据库优化
|
||||
|
||||
为向量操作优化 PostgreSQL:
|
||||
|
||||
```sql
|
||||
-- 创建索引以获得更好的性能
|
||||
CREATE INDEX ON tool_embeddings USING hnsw (embedding vector_cosine_ops);
|
||||
|
||||
-- 调整 PostgreSQL 设置
|
||||
ALTER SYSTEM SET shared_preload_libraries = 'vector';
|
||||
ALTER SYSTEM SET max_connections = 200;
|
||||
ALTER SYSTEM SET shared_buffers = '256MB';
|
||||
ALTER SYSTEM SET effective_cache_size = '1GB';
|
||||
```
|
||||
|
||||
## 监控和分析
|
||||
|
||||
### 智能路由指标
|
||||
|
||||
监控智能路由性能:
|
||||
|
||||
```bash
|
||||
# 获取智能路由统计信息
|
||||
curl http://localhost:3000/api/smart-routing/stats \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||
```
|
||||
|
||||
响应包括:
|
||||
|
||||
- 查询计数和频率
|
||||
- 平均响应时间
|
||||
- 嵌入缓存命中率
|
||||
- 最受欢迎的工具
|
||||
- 查询模式
|
||||
|
||||
### 工具使用分析
|
||||
|
||||
跟踪哪些工具被发现和使用:
|
||||
|
||||
```bash
|
||||
# 获取工具使用分析
|
||||
curl http://localhost:3000/api/smart-routing/analytics \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||
```
|
||||
|
||||
指标包括:
|
||||
|
||||
- 工具发现率
|
||||
- 执行成功率
|
||||
- 用户满意度评分
|
||||
- 查询到执行的转换率
|
||||
|
||||
### 性能监控
|
||||
|
||||
监控系统性能:
|
||||
|
||||
```bash
|
||||
# 数据库性能
|
||||
curl http://localhost:3000/api/smart-routing/db-stats \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||
|
||||
# 嵌入服务状态
|
||||
curl http://localhost:3000/api/smart-routing/embedding-stats \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||
```
|
||||
|
||||
## 高级功能
|
||||
|
||||
### 自定义嵌入
|
||||
|
||||
使用自定义嵌入模型:
|
||||
|
||||
```bash
|
||||
# Hugging Face 模型
|
||||
EMBEDDING_SERVICE=huggingface
|
||||
HUGGINGFACE_MODEL=sentence-transformers/all-MiniLM-L6-v2
|
||||
HUGGINGFACE_API_KEY=your_api_key
|
||||
|
||||
# 本地嵌入服务
|
||||
EMBEDDING_SERVICE=local
|
||||
EMBEDDING_SERVICE_URL=http://localhost:8080/embeddings
|
||||
```
|
||||
|
||||
### 查询增强
|
||||
|
||||
增强查询以获得更好的结果:
|
||||
|
||||
```json
|
||||
{
|
||||
"queryEnhancement": {
|
||||
"enabled": true,
|
||||
"measurementInterval": 30000,
|
||||
"regions": [
|
||||
{"id": "us-east", "endpoint": "ping.us-east.example.com"},
|
||||
{"id": "eu-west", "endpoint": "ping.eu-west.example.com"},
|
||||
{"id": "ap-southeast", "endpoint": "ping.ap-southeast.example.com"}
|
||||
],
|
||||
"routing": {
|
||||
"preferLowLatency": true,
|
||||
"maxLatencyThreshold": "200ms",
|
||||
"fallbackOnTimeout": true
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
## 负载感知路由
|
||||
|
||||
### 实时负载监控
|
||||
|
||||
```json
|
||||
{
|
||||
"routing": {
|
||||
"strategy": "load-aware",
|
||||
"loadMetrics": {
|
||||
"cpu": {
|
||||
"threshold": 80,
|
||||
"weight": 0.4
|
||||
},
|
||||
"memory": {
|
||||
"threshold": 85,
|
||||
"weight": 0.3
|
||||
},
|
||||
"connections": {
|
||||
"threshold": 1000,
|
||||
"weight": 0.2
|
||||
},
|
||||
"responseTime": {
|
||||
"threshold": "2s",
|
||||
"weight": 0.1
|
||||
}
|
||||
},
|
||||
"adaptation": {
|
||||
"enabled": true,
|
||||
"adjustmentInterval": 60000,
|
||||
"emergencyThreshold": 95
|
||||
}
|
||||
"expandAcronyms": true,
|
||||
"addSynonyms": true,
|
||||
"contextualExpansion": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 预测性负载均衡
|
||||
### 结果过滤
|
||||
|
||||
基于条件过滤结果:
|
||||
|
||||
```json
|
||||
{
|
||||
"routing": {
|
||||
"strategy": "predictive",
|
||||
"prediction": {
|
||||
"algorithm": "linear-regression",
|
||||
"trainingWindow": "7d",
|
||||
"predictionHorizon": "1h",
|
||||
"factors": ["historical_load", "time_of_day", "day_of_week", "seasonal_patterns"]
|
||||
},
|
||||
"adaptation": {
|
||||
"preemptiveScaling": true,
|
||||
"scaleUpThreshold": 70,
|
||||
"scaleDownThreshold": 30
|
||||
}
|
||||
"resultFiltering": {
|
||||
"minRelevanceScore": 0.7,
|
||||
"maxResults": 10,
|
||||
"preferredServers": ["fetch", "playwright"],
|
||||
"excludeServers": ["deprecated-server"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 故障转移和恢复
|
||||
### 反馈学习
|
||||
|
||||
### 自动故障转移
|
||||
|
||||
```json
|
||||
{
|
||||
"routing": {
|
||||
"strategy": "high-availability",
|
||||
"failover": {
|
||||
"enabled": true,
|
||||
"detection": {
|
||||
"healthCheckFailures": 3,
|
||||
"timeoutThreshold": "10s",
|
||||
"checkInterval": 5000
|
||||
},
|
||||
"recovery": {
|
||||
"automaticRecovery": true,
|
||||
"recoveryChecks": 5,
|
||||
"recoveryInterval": 30000
|
||||
}
|
||||
},
|
||||
"clusters": [
|
||||
{
|
||||
"name": "primary",
|
||||
"servers": ["server-1", "server-2"],
|
||||
"priority": 1
|
||||
},
|
||||
{
|
||||
"name": "secondary",
|
||||
"servers": ["backup-server-1", "backup-server-2"],
|
||||
"priority": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 断路器模式
|
||||
|
||||
```json
|
||||
{
|
||||
"routing": {
|
||||
"circuitBreaker": {
|
||||
"enabled": true,
|
||||
"failureThreshold": 10,
|
||||
"timeWindow": 60000,
|
||||
"halfOpenRetries": 3,
|
||||
"fallback": {
|
||||
"type": "cached-response",
|
||||
"ttl": 300000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 会话亲和性
|
||||
|
||||
### 粘性会话
|
||||
|
||||
保持用户会话与特定服务器的关联:
|
||||
|
||||
```json
|
||||
{
|
||||
"routing": {
|
||||
"strategy": "session-affinity",
|
||||
"affinity": {
|
||||
"type": "cookie",
|
||||
"cookieName": "mcphub-server-id",
|
||||
"ttl": 3600000,
|
||||
"fallbackOnUnavailable": true
|
||||
},
|
||||
"sessionStore": {
|
||||
"type": "redis",
|
||||
"config": {
|
||||
"host": "localhost",
|
||||
"port": 6379,
|
||||
"db": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 基于用户 ID 的路由
|
||||
|
||||
```json
|
||||
{
|
||||
"routing": {
|
||||
"strategy": "user-based",
|
||||
"userRouting": {
|
||||
"algorithm": "consistent-hashing",
|
||||
"hashFunction": "sha256",
|
||||
"virtualNodes": 100,
|
||||
"replicationFactor": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 动态路由配置
|
||||
|
||||
### 运行时配置更新
|
||||
基于用户反馈改进结果:
|
||||
|
||||
```bash
|
||||
# 更新路由配置
|
||||
curl -X PUT http://localhost:3000/api/routing/config \
|
||||
# 对搜索结果提供反馈
|
||||
curl -X POST http://localhost:3000/api/smart-routing/feedback \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||
-d '{
|
||||
"strategy": "weighted-round-robin",
|
||||
"weights": {
|
||||
"server-1": 3,
|
||||
"server-2": 2,
|
||||
"server-3": 1
|
||||
},
|
||||
"applyImmediately": true
|
||||
"queryId": "search-123",
|
||||
"toolName": "fetch_html",
|
||||
"rating": 5,
|
||||
"successful": true,
|
||||
"comments": "完美适合这个任务的工具"
|
||||
}'
|
||||
```
|
||||
|
||||
### A/B 测试路由
|
||||
|
||||
```json
|
||||
{
|
||||
"routing": {
|
||||
"strategy": "ab-testing",
|
||||
"experiments": [
|
||||
{
|
||||
"name": "new-algorithm-test",
|
||||
"enabled": true,
|
||||
"trafficSplit": {
|
||||
"control": 70,
|
||||
"variant": 30
|
||||
},
|
||||
"rules": {
|
||||
"control": {
|
||||
"strategy": "round-robin",
|
||||
"servers": ["stable-server-1", "stable-server-2"]
|
||||
},
|
||||
"variant": {
|
||||
"strategy": "ai-optimized",
|
||||
"servers": ["experimental-server-1"]
|
||||
}
|
||||
},
|
||||
"metrics": ["response_time", "error_rate", "user_satisfaction"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 路由分析和监控
|
||||
|
||||
### 实时路由指标
|
||||
|
||||
```bash
|
||||
# 获取路由统计
|
||||
curl -X GET http://localhost:3000/api/routing/metrics \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-01-01T12:00:00Z",
|
||||
"totalRequests": 15420,
|
||||
"routingDistribution": {
|
||||
"server-1": { "requests": 6168, "percentage": 40 },
|
||||
"server-2": { "requests": 4626, "percentage": 30 },
|
||||
"server-3": { "requests": 3084, "percentage": 20 },
|
||||
"backup-server": { "requests": 1542, "percentage": 10 }
|
||||
},
|
||||
"performance": {
|
||||
"avgResponseTime": "245ms",
|
||||
"p95ResponseTime": "580ms",
|
||||
"errorRate": "0.3%"
|
||||
},
|
||||
"failovers": {
|
||||
"total": 2,
|
||||
"byServer": {
|
||||
"server-2": 1,
|
||||
"server-3": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 路由决策日志
|
||||
|
||||
```bash
|
||||
# 启用路由决策日志
|
||||
curl -X PUT http://localhost:3000/api/routing/logging \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{
|
||||
"enabled": true,
|
||||
"level": "info",
|
||||
"includeDecisionFactors": true,
|
||||
"sampleRate": 0.1
|
||||
}'
|
||||
```
|
||||
|
||||
## 自定义路由规则
|
||||
|
||||
### 基于业务逻辑的路由
|
||||
|
||||
```json
|
||||
{
|
||||
"routing": {
|
||||
"strategy": "custom-rules",
|
||||
"rules": [
|
||||
{
|
||||
"name": "premium-users",
|
||||
"priority": 1,
|
||||
"condition": "user.tier === 'premium'",
|
||||
"action": {
|
||||
"targetServers": ["premium-server-1", "premium-server-2"],
|
||||
"strategy": "least-connections",
|
||||
"qos": {
|
||||
"maxResponseTime": "1s",
|
||||
"priority": "high"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "high-volume-requests",
|
||||
"priority": 2,
|
||||
"condition": "request.size > 10MB",
|
||||
"action": {
|
||||
"targetServers": ["high-capacity-server"],
|
||||
"strategy": "single",
|
||||
"timeout": "60s"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "batch-processing",
|
||||
"priority": 3,
|
||||
"condition": "request.type === 'batch'",
|
||||
"action": {
|
||||
"targetServers": ["batch-server-1", "batch-server-2"],
|
||||
"strategy": "queue-based",
|
||||
"queueConfig": {
|
||||
"maxSize": 1000,
|
||||
"timeout": "5m"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### JavaScript 路由函数
|
||||
|
||||
```javascript
|
||||
// 自定义路由函数
|
||||
function customRouting(request, servers, metrics) {
|
||||
const { user, content, timestamp } = request;
|
||||
|
||||
// 工作时间优先使用高性能服务器
|
||||
const isBusinessHours =
|
||||
new Date(timestamp).getHours() >= 9 && new Date(timestamp).getHours() <= 17;
|
||||
|
||||
if (isBusinessHours && user.priority === 'high') {
|
||||
return servers.filter((s) => s.tags.includes('high-performance'));
|
||||
}
|
||||
|
||||
// 基于内容类型的特殊路由
|
||||
if (content.type === 'code-analysis') {
|
||||
return servers.filter((s) => s.capabilities.includes('code-analysis'));
|
||||
}
|
||||
|
||||
// 默认负载均衡
|
||||
return servers.sort((a, b) => a.currentLoad - b.currentLoad);
|
||||
}
|
||||
```
|
||||
|
||||
## 路由优化
|
||||
|
||||
### 机器学习优化
|
||||
|
||||
```json
|
||||
{
|
||||
"routing": {
|
||||
"strategy": "ml-optimized",
|
||||
"mlConfig": {
|
||||
"algorithm": "reinforcement-learning",
|
||||
"rewardFunction": "response_time_weighted",
|
||||
"trainingData": {
|
||||
"features": [
|
||||
"server_load",
|
||||
"response_time_history",
|
||||
"request_complexity",
|
||||
"user_pattern",
|
||||
"time_of_day"
|
||||
],
|
||||
"targetMetric": "overall_satisfaction"
|
||||
},
|
||||
"updateFrequency": "hourly",
|
||||
"explorationRate": 0.1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 缓存感知路由
|
||||
|
||||
```json
|
||||
{
|
||||
"routing": {
|
||||
"strategy": "cache-aware",
|
||||
"caching": {
|
||||
"enabled": true,
|
||||
"levels": [
|
||||
{
|
||||
"type": "local",
|
||||
"ttl": 300,
|
||||
"maxSize": "100MB"
|
||||
},
|
||||
{
|
||||
"type": "distributed",
|
||||
"provider": "redis",
|
||||
"ttl": 3600,
|
||||
"maxSize": "1GB"
|
||||
}
|
||||
],
|
||||
"routing": {
|
||||
"preferCachedServers": true,
|
||||
"cacheHitBonus": 0.3,
|
||||
"cacheMissThreshold": 0.8
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
``` */}
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 路由调试
|
||||
<AccordionGroup>
|
||||
<Accordion title="数据库连接问题">
|
||||
**症状:**
|
||||
- 智能路由不可用
|
||||
- 数据库连接错误
|
||||
- 嵌入存储失败
|
||||
|
||||
```bash
|
||||
# 调试特定请求的路由决策
|
||||
curl -X POST http://localhost:3000/api/routing/debug \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{
|
||||
"request": {
|
||||
"userId": "user123",
|
||||
"tool": "filesystem",
|
||||
"content": {"type": "read", "path": "/data/file.txt"}
|
||||
},
|
||||
"traceRoute": true
|
||||
}'
|
||||
```
|
||||
**解决方案:**
|
||||
1. 验证 PostgreSQL 是否正在运行
|
||||
2. 检查 DATABASE_URL 格式
|
||||
3. 确保安装了 pgvector 扩展
|
||||
4. 手动测试连接:
|
||||
```bash
|
||||
psql $DATABASE_URL -c "SELECT 1;"
|
||||
```
|
||||
|
||||
### 路由性能分析
|
||||
</Accordion>
|
||||
|
||||
```bash
|
||||
# 获取路由性能报告
|
||||
curl -X GET http://localhost:3000/api/routing/performance \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-G -d "timeRange=1h" -d "detailed=true"
|
||||
```
|
||||
<Accordion title="嵌入服务问题">
|
||||
**症状:**
|
||||
- 工具索引失败
|
||||
- 查询处理错误
|
||||
- API 速率限制错误
|
||||
|
||||
### 常见问题
|
||||
**解决方案:**
|
||||
1. 验证 API 密钥有效性
|
||||
2. 检查网络连接
|
||||
3. 监控速率限制
|
||||
4. 测试嵌入服务:
|
||||
```bash
|
||||
curl -X POST https://api.openai.com/v1/embeddings \
|
||||
-H "Authorization: Bearer $OPENAI_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"input": "test", "model": "text-embedding-3-small"}'
|
||||
```
|
||||
|
||||
1. **不均匀的负载分布**
|
||||
</Accordion>
|
||||
|
||||
- 检查服务器权重配置
|
||||
- 验证健康检查设置
|
||||
- 分析请求模式
|
||||
<Accordion title="搜索结果不佳">
|
||||
**症状:**
|
||||
- 返回不相关的工具
|
||||
- 相关性得分低
|
||||
- 缺少预期的工具
|
||||
|
||||
2. **频繁的故障转移**
|
||||
**解决方案:**
|
||||
1. 调整相似性阈值
|
||||
2. 使用更好的描述重新索引工具
|
||||
3. 使用更具体的查询
|
||||
4. 检查工具元数据质量
|
||||
```bash
|
||||
# 重新索引所有工具
|
||||
curl -X POST http://localhost:3000/api/smart-routing/reindex \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||
```
|
||||
|
||||
- 调整健康检查阈值
|
||||
- 检查网络连接稳定性
|
||||
- 优化服务器资源
|
||||
</Accordion>
|
||||
|
||||
3. **路由延迟过高**
|
||||
- 简化路由规则
|
||||
- 优化路由算法
|
||||
- 使用缓存加速决策
|
||||
<Accordion title="性能问题">
|
||||
**症状:**
|
||||
- 查询响应缓慢
|
||||
- 数据库负载高
|
||||
- 内存使用激增
|
||||
|
||||
有关更多信息,请参阅 [监控](/zh/features/monitoring) 和 [服务器管理](/zh/features/server-management) 文档。
|
||||
**解决方案:**
|
||||
1. 优化数据库配置
|
||||
2. 增加缓存大小
|
||||
3. 减少批处理大小
|
||||
4. 监控系统资源
|
||||
```bash
|
||||
# 检查系统性能
|
||||
curl http://localhost:3000/api/smart-routing/performance \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 查询编写
|
||||
|
||||
<Tip>
|
||||
**要具体描述**:在查询中使用具体、描述性的语言以获得更好的工具匹配。
|
||||
</Tip>
|
||||
|
||||
<Tip>
|
||||
**包含上下文**:提供有关您的任务或领域的相关上下文以获得更准确的结果。
|
||||
</Tip>
|
||||
|
||||
<Tip>**使用自然语言**:像向人类描述任务一样编写查询。</Tip>
|
||||
|
||||
### 工具描述
|
||||
|
||||
<Warning>
|
||||
**质量元数据**:确保 MCP 服务器提供高质量的工具描述和元数据。
|
||||
</Warning>
|
||||
|
||||
<Warning>**定期更新**:随着功能的发展保持工具描述的最新状态。</Warning>
|
||||
|
||||
<Warning>
|
||||
**一致的命名**:在工具和服务器中使用一致的命名约定。
|
||||
</Warning>
|
||||
|
||||
### 系统维护
|
||||
|
||||
<Info>**定期重新索引**:定期重新索引工具以确保嵌入质量。</Info>
|
||||
|
||||
<Info>**监控性能**:跟踪查询模式并根据使用情况进行优化。</Info>
|
||||
|
||||
<Info>
|
||||
**更新模型**:随着新嵌入模型的出现,考虑更新到更新的模型。
|
||||
</Info>
|
||||
|
||||
## 下一步
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="身份验证" icon="shield" href="/zh/features/authentication">
|
||||
用户管理和访问控制
|
||||
</Card>
|
||||
<Card title="监控" icon="chart-line" href="/zh/features/monitoring">
|
||||
系统监控和分析
|
||||
</Card>
|
||||
<Card title="API 参考" icon="code" href="/zh/api-reference/smart-routing">
|
||||
完整的智能路由 API 文档
|
||||
</Card>
|
||||
<Card title="配置" icon="cog" href="/zh/configuration/environment-variables">
|
||||
高级配置选项
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
---
|
||||
title: '欢迎使用 MCPHub'
|
||||
description: 'MCPHub 是一个强大的 Model Context Protocol (MCP) 服务器管理平台,提供智能路由、负载均衡和实时监控功能'
|
||||
title: '欢迎使用'
|
||||
description: 'MCPHub 是一个强大的 Model Context Protocol (MCP) 服务器管理平台,提供分组管理、智能路由和实时监控等功能'
|
||||
---
|
||||
|
||||
<img className="block dark:hidden" src="/images/hero-light.png" alt="MCPHub Hero Light" />
|
||||
<img className="hidden dark:block" src="/images/hero-dark.png" alt="MCPHub Hero Dark" />
|
||||
{/* <img className="block dark:hidden" src="/images/hero-light.png" alt="MCPHub Hero Light" />
|
||||
<img className="hidden dark:block" src="/images/hero-dark.png" alt="MCPHub Hero Dark" /> */}
|
||||
|
||||
## 什么是 MCPHub?
|
||||
|
||||
MCPHub 是一个现代化的 Model Context Protocol (MCP) 服务器管理平台,旨在简化 AI 模型服务的部署、管理和监控。通过智能路由和负载均衡技术,MCPHub 帮助您构建高可用、可扩展的 AI 服务架构。
|
||||
MCPHub 是一个现代化的 Model Context Protocol (MCP) 服务器管理平台,旨在简化 AI 模型服务的部署、管理和监控。通过分组管理和智能路由技术,MCPHub 帮助您构建高可用、可扩展的 AI 服务架构。
|
||||
|
||||
### 核心功能
|
||||
|
||||
- **🚀 智能路由** - 基于负载、延迟和健康状态的智能请求分发
|
||||
- **⚖️ 负载均衡** - 多种负载均衡策略,确保最优性能
|
||||
- **🏗️ 分组管理** - 灵活的服务器分组和配置管理
|
||||
- **🚀 智能路由** - 基于语义检索的智能路由分发
|
||||
- **📊 实时监控** - 全面的性能指标和健康检查
|
||||
- **🔐 安全认证** - 企业级身份认证和访问控制
|
||||
- **🏗️ 服务器组管理** - 灵活的服务器分组和配置管理
|
||||
- **🔄 故障转移** - 自动故障检测和流量切换
|
||||
- **🔐 安全认证** - 身份认证和访问控制
|
||||
|
||||
## 快速开始
|
||||
|
||||
|
||||
570
docs/zh/installation.mdx
Normal file
570
docs/zh/installation.mdx
Normal file
@@ -0,0 +1,570 @@
|
||||
---
|
||||
title: '安装指南'
|
||||
description: '各种平台的详细安装说明'
|
||||
---
|
||||
|
||||
## 先决条件
|
||||
|
||||
在安装 MCPHub 之前,请确保您具备以下先决条件:
|
||||
|
||||
- **Node.js** 18+ (用于本地开发)
|
||||
- **Docker** (推荐用于生产环境)
|
||||
- **pnpm** (用于本地开发)
|
||||
|
||||
智能路由的可选要求:
|
||||
|
||||
- **PostgreSQL** 带 pgvector 扩展
|
||||
- **OpenAI API Key** 或兼容的嵌入服务
|
||||
|
||||
## 安装方法
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Docker (推荐)">
|
||||
### Docker 安装
|
||||
|
||||
Docker 是在生产环境中部署 MCPHub 的推荐方式。
|
||||
|
||||
#### 1. 基础安装
|
||||
|
||||
```bash
|
||||
# 拉取最新镜像
|
||||
docker pull samanhappy/mcphub:latest
|
||||
|
||||
# 使用默认设置运行
|
||||
docker run -d \
|
||||
--name mcphub \
|
||||
-p 3000:3000 \
|
||||
samanhappy/mcphub:latest
|
||||
```
|
||||
|
||||
#### 2. 使用自定义配置
|
||||
|
||||
```bash
|
||||
# 创建您的配置文件
|
||||
cat > mcp_settings.json << 'EOF'
|
||||
{
|
||||
"mcpServers": {
|
||||
"fetch": {
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-fetch"]
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless"]
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# 使用挂载的配置运行
|
||||
docker run -d \
|
||||
--name mcphub \
|
||||
-p 3000:3000 \
|
||||
-v $(pwd)/mcp_settings.json:/app/mcp_settings.json \
|
||||
samanhappy/mcphub:latest
|
||||
```
|
||||
|
||||
#### 3. 使用环境变量
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name mcphub \
|
||||
-p 3000:3000 \
|
||||
-e PORT=3000 \
|
||||
-e BASE_PATH="" \
|
||||
samanhappy/mcphub:latest
|
||||
```
|
||||
|
||||
#### 4. Docker Compose
|
||||
|
||||
创建 `docker-compose.yml` 文件:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
mcphub:
|
||||
image: samanhappy/mcphub:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./mcp_settings.json:/app/mcp_settings.json
|
||||
environment:
|
||||
- PORT=3000
|
||||
- BASE_PATH=""
|
||||
- REQUEST_TIMEOUT=60000
|
||||
restart: unless-stopped
|
||||
|
||||
# 可选:用于智能路由的 PostgreSQL
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
environment:
|
||||
POSTGRES_DB: mcphub
|
||||
POSTGRES_USER: mcphub
|
||||
POSTGRES_PASSWORD: mcphub_password
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
```
|
||||
|
||||
运行命令:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab title="npm 包">
|
||||
### npm 包安装
|
||||
|
||||
将 MCPHub 安装为全局 npm 包:
|
||||
|
||||
#### 1. 全局安装
|
||||
|
||||
```bash
|
||||
# 全局安装
|
||||
npm install -g @samanhappy/mcphub
|
||||
|
||||
# 或使用 yarn
|
||||
yarn global add @samanhappy/mcphub
|
||||
|
||||
# 或使用 pnpm
|
||||
pnpm add -g @samanhappy/mcphub
|
||||
```
|
||||
|
||||
#### 2. 运行 MCPHub
|
||||
|
||||
```bash
|
||||
# 使用默认设置运行
|
||||
mcphub
|
||||
|
||||
# 使用自定义端口运行
|
||||
PORT=8080 mcphub
|
||||
```
|
||||
|
||||
{/* #### 3. 本地安装
|
||||
|
||||
您也可以在项目中本地安装 MCPHub:
|
||||
|
||||
```bash
|
||||
# 创建新目录
|
||||
mkdir my-mcphub
|
||||
cd my-mcphub
|
||||
|
||||
# 初始化 package.json
|
||||
npm init -y
|
||||
|
||||
# 本地安装 MCPHub
|
||||
npm install @samanhappy/mcphub
|
||||
|
||||
# 创建启动脚本
|
||||
echo '#!/bin/bash\nnpx mcphub' > start.sh
|
||||
chmod +x start.sh
|
||||
|
||||
# 运行 MCPHub
|
||||
./start.sh
|
||||
``` */}
|
||||
</Tab>
|
||||
|
||||
<Tab title="本地开发">
|
||||
### 本地开发环境设置
|
||||
|
||||
用于开发、自定义或贡献:
|
||||
|
||||
#### 1. 克隆仓库
|
||||
|
||||
```bash
|
||||
# 克隆仓库
|
||||
git clone https://github.com/samanhappy/mcphub.git
|
||||
cd mcphub
|
||||
```
|
||||
|
||||
#### 2. 安装依赖
|
||||
|
||||
```bash
|
||||
# 使用 pnpm 安装依赖(推荐)
|
||||
pnpm install
|
||||
|
||||
# 或使用 npm
|
||||
npm install
|
||||
|
||||
# 或使用 yarn
|
||||
yarn install
|
||||
```
|
||||
|
||||
#### 3. 开发模式
|
||||
|
||||
```bash
|
||||
# 在开发模式下同时启动后端和前端
|
||||
pnpm dev
|
||||
|
||||
# 这将启动:
|
||||
# - 后端在 http://localhost:3001
|
||||
# - 前端在 http://localhost:5173
|
||||
# - 前端代理 API 调用到后端
|
||||
```
|
||||
|
||||
#### 4. 生产构建
|
||||
|
||||
```bash
|
||||
# 构建后端和前端
|
||||
pnpm build
|
||||
|
||||
# 启动生产服务器
|
||||
pnpm start
|
||||
```
|
||||
|
||||
#### 5. 开发脚本
|
||||
|
||||
```bash
|
||||
# 仅后端(用于 API 开发)
|
||||
pnpm backend:dev
|
||||
|
||||
# 仅前端(当后端单独运行时)
|
||||
pnpm frontend:dev
|
||||
|
||||
# 运行测试
|
||||
pnpm test
|
||||
|
||||
# 代码检查
|
||||
pnpm lint
|
||||
|
||||
# 代码格式化
|
||||
pnpm format
|
||||
```
|
||||
|
||||
<Note>
|
||||
在 Windows 上,您可能需要分别运行后端和前端:
|
||||
```bash
|
||||
# 终端 1:后端
|
||||
pnpm backend:dev
|
||||
|
||||
# 终端 2:前端
|
||||
pnpm frontend:dev
|
||||
```
|
||||
</Note>
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab title="Kubernetes">
|
||||
### Kubernetes 部署
|
||||
|
||||
使用这些清单在 Kubernetes 上部署 MCPHub:
|
||||
|
||||
#### 1. 设置的 ConfigMap
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: mcphub-config
|
||||
data:
|
||||
mcp_settings.json: |
|
||||
{
|
||||
"mcpServers": {
|
||||
"fetch": {
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-fetch"]
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 部署
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: mcphub
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: mcphub
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: mcphub
|
||||
spec:
|
||||
containers:
|
||||
- name: mcphub
|
||||
image: samanhappy/mcphub:latest
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
env:
|
||||
- name: PORT
|
||||
value: "3000"
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /app/mcp_settings.json
|
||||
subPath: mcp_settings.json
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: mcphub-config
|
||||
```
|
||||
|
||||
#### 3. 服务
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: mcphub-service
|
||||
spec:
|
||||
selector:
|
||||
app: mcphub
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 3000
|
||||
type: ClusterIP
|
||||
```
|
||||
|
||||
#### 4. Ingress (可选)
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: mcphub-ingress
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-buffering: "off"
|
||||
spec:
|
||||
rules:
|
||||
- host: mcphub.yourdomain.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: mcphub-service
|
||||
port:
|
||||
number: 80
|
||||
```
|
||||
|
||||
部署命令:
|
||||
```bash
|
||||
kubectl apply -f mcphub-configmap.yaml
|
||||
kubectl apply -f mcphub-deployment.yaml
|
||||
kubectl apply -f mcphub-service.yaml
|
||||
kubectl apply -f mcphub-ingress.yaml
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## 智能路由设置 (可选)
|
||||
|
||||
智能路由使用向量语义搜索提供 AI 驱动的工具发现。
|
||||
|
||||
### 先决条件
|
||||
|
||||
1. **PostgreSQL 带 pgvector 扩展**
|
||||
2. **OpenAI API Key** (或兼容的嵌入服务)
|
||||
|
||||
### 数据库设置
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Docker PostgreSQL">
|
||||
```bash
|
||||
# 运行带 pgvector 的 PostgreSQL
|
||||
docker run -d \
|
||||
--name mcphub-postgres \
|
||||
-e POSTGRES_DB=mcphub \
|
||||
-e POSTGRES_USER=mcphub \
|
||||
-e POSTGRES_PASSWORD=your_password \
|
||||
-p 5432:5432 \
|
||||
pgvector/pgvector:pg16
|
||||
```
|
||||
</Tab>
|
||||
|
||||
<Tab title="现有 PostgreSQL">
|
||||
如果您有现有的 PostgreSQL 实例:
|
||||
|
||||
```sql
|
||||
-- 连接到您的 PostgreSQL 实例
|
||||
-- 创建数据库
|
||||
CREATE DATABASE mcphub;
|
||||
|
||||
-- 连接到 mcphub 数据库
|
||||
\c mcphub;
|
||||
|
||||
-- 启用 pgvector 扩展
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
```
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab title="云 PostgreSQL">
|
||||
对于云提供商(AWS RDS、Google Cloud SQL 等):
|
||||
|
||||
1. 在您的云提供商控制台中启用 pgvector 扩展
|
||||
2. 创建名为 `mcphub` 的数据库
|
||||
3. 记下连接详细信息
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
{/* ### 环境配置
|
||||
|
||||
设置以下环境变量:
|
||||
|
||||
```bash
|
||||
# 数据库连接
|
||||
DATABASE_URL=postgresql://mcphub:your_password@localhost:5432/mcphub
|
||||
|
||||
# 用于嵌入的 OpenAI API
|
||||
OPENAI_API_KEY=your_openai_api_key
|
||||
|
||||
# 可选:自定义嵌入模型
|
||||
EMBEDDING_MODEL=text-embedding-3-small
|
||||
|
||||
# 可选:启用智能路由
|
||||
ENABLE_SMART_ROUTING=true
|
||||
``` */}
|
||||
|
||||
## 验证
|
||||
|
||||
安装后,验证 MCPHub 是否正常工作:
|
||||
|
||||
{/* ### 1. 健康检查
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/api/health
|
||||
```
|
||||
|
||||
预期响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "x.x.x",
|
||||
"uptime": 123
|
||||
}
|
||||
``` */}
|
||||
|
||||
### 控制台访问
|
||||
|
||||
打开浏览器并导航到:
|
||||
|
||||
```
|
||||
http://localhost:3000
|
||||
```
|
||||
|
||||
{/* ### 3. API 测试
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/list",
|
||||
"params": {}
|
||||
}'
|
||||
``` */}
|
||||
|
||||
## 故障排除
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Docker 问题">
|
||||
**端口已被使用:**
|
||||
```bash
|
||||
# 检查是什么在使用端口 3000
|
||||
lsof -i :3000
|
||||
|
||||
# 使用不同的端口
|
||||
docker run -p 8080:3000 samanhappy/mcphub
|
||||
```
|
||||
|
||||
**容器无法启动:**
|
||||
```bash
|
||||
# 检查容器日志
|
||||
docker logs mcphub
|
||||
|
||||
# 交互式运行以进行调试
|
||||
docker run -it --rm samanhappy/mcphub /bin/bash
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="npm 安装问题">
|
||||
**权限错误:**
|
||||
```bash
|
||||
# 使用 npx 而不是全局安装
|
||||
npx @samanhappy/mcphub
|
||||
|
||||
# 或修复 npm 权限
|
||||
npm config set prefix ~/.npm-global
|
||||
export PATH=~/.npm-global/bin:$PATH
|
||||
```
|
||||
|
||||
**Node 版本问题:**
|
||||
```bash
|
||||
# 检查 Node 版本
|
||||
node --version
|
||||
|
||||
# 使用 nvm 安装 Node 18+
|
||||
nvm install 18
|
||||
nvm use 18
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="网络问题">
|
||||
**无法访问控制台:**
|
||||
- 检查 MCPHub 是否在运行:`ps aux | grep mcphub`
|
||||
- 验证端口绑定:`netstat -tlnp | grep 3000`
|
||||
- 检查防火墙设置
|
||||
- 尝试通过 `127.0.0.1:3000` 而不是 `localhost:3000` 访问
|
||||
|
||||
**AI 客户端无法连接:**
|
||||
- 确保端点 URL 正确
|
||||
- 检查 MCPHub 是否在代理后面
|
||||
- 验证 Kubernetes/Docker 环境中的网络策略
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="智能路由问题">
|
||||
**数据库连接失败:**
|
||||
```bash
|
||||
# 测试数据库连接
|
||||
psql $DATABASE_URL -c "SELECT 1;"
|
||||
|
||||
# 检查是否安装了 pgvector
|
||||
psql $DATABASE_URL -c "CREATE EXTENSION IF NOT EXISTS vector;"
|
||||
```
|
||||
|
||||
**嵌入服务错误:**
|
||||
- 验证 OpenAI API 密钥是否有效
|
||||
- 检查互联网连接
|
||||
- 监控速率限制
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## 下一步
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="配置" icon="cog" href="/zh/configuration/mcp-settings">
|
||||
配置您的 MCP 服务器和设置
|
||||
</Card>
|
||||
<Card title="快速开始" icon="rocket" href="/zh/quickstart">
|
||||
5分钟内启动并运行
|
||||
</Card>
|
||||
<Card title="服务器管理" icon="server" href="/zh/features/server-management">
|
||||
了解如何管理您的 MCP 服务器
|
||||
</Card>
|
||||
<Card title="API 参考" icon="code" href="/zh/api-reference/introduction">
|
||||
探索完整的 API 文档
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -1,304 +1,212 @@
|
||||
---
|
||||
title: '快速开始'
|
||||
description: '5 分钟内部署 MCPHub 并连接您的第一个 MCP 服务器'
|
||||
title: '快速开始指南'
|
||||
description: '5 分钟内运行 MCPHub'
|
||||
---
|
||||
|
||||
## 欢迎使用 MCPHub!
|
||||
## 安装
|
||||
|
||||
本指南将帮助您在 5 分钟内完成 MCPHub 的部署和配置,并连接您的第一个 MCP 服务器。
|
||||
|
||||
## 前提条件
|
||||
|
||||
在开始之前,请确保您的系统满足以下要求:
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion icon="desktop" title="系统要求">
|
||||
- **操作系统**: Linux、macOS 或 Windows
|
||||
- **内存**: 最少 2GB RAM(推荐 4GB+)
|
||||
- **存储**: 至少 1GB 可用空间
|
||||
- **网络**: 稳定的互联网连接
|
||||
</Accordion>
|
||||
|
||||
<Accordion icon="code" title="软件依赖">
|
||||
- **Node.js**: 18.0+ 版本
|
||||
- **Docker**: 最新版本(可选,用于容器化部署)
|
||||
- **Git**: 用于代码管理
|
||||
|
||||
检查版本:
|
||||
```bash
|
||||
node --version # 应该 >= 18.0.0
|
||||
npm --version # 应该 >= 8.0.0
|
||||
docker --version # 可选
|
||||
```
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## 安装 MCPHub
|
||||
|
||||
### 方式一:使用 npm(推荐)
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion icon="download" title="安装 MCPHub CLI">
|
||||
首先安装 MCPHub 命令行工具:
|
||||
<Tabs>
|
||||
<Tab title="Docker(推荐)">
|
||||
使用 Docker 是最快的开始方式:
|
||||
|
||||
```bash
|
||||
npm install -g @mcphub/cli
|
||||
# 使用默认配置运行
|
||||
docker run -p 3000:3000 samanhappy/mcphub
|
||||
```
|
||||
|
||||
验证安装:
|
||||
或者挂载自定义配置:
|
||||
|
||||
```bash
|
||||
mcphub --version
|
||||
# 使用自定义 MCP 设置运行
|
||||
docker run -p 3000:3000 \
|
||||
-v $(pwd)/mcp_settings.json:/app/mcp_settings.json \
|
||||
samanhappy/mcphub
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion icon="folder-plus" title="创建新项目">
|
||||
创建一个新的 MCPHub 项目:
|
||||
</Tab>
|
||||
<Tab title="本地开发">
|
||||
用于开发或自定义:
|
||||
|
||||
```bash
|
||||
# 创建项目
|
||||
mcphub init my-mcphub-project
|
||||
cd my-mcphub-project
|
||||
# 克隆仓库
|
||||
git clone https://github.com/samanhappy/mcphub.git
|
||||
cd mcphub
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
pnpm install
|
||||
|
||||
# 启动开发服务器
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
这会同时启动后端(端口 3001)和前端(端口 5173)的开发模式。
|
||||
|
||||
<Accordion icon="gear" title="配置环境">
|
||||
复制并编辑环境变量文件:
|
||||
</Tab>
|
||||
<Tab title="npm 包">
|
||||
将 MCPHub 安装为全局包:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 全局安装
|
||||
npm install -g @samanhappy/mcphub
|
||||
|
||||
# 运行 MCPHub
|
||||
mcphub
|
||||
```
|
||||
|
||||
编辑 `.env` 文件,设置基本配置:
|
||||
```bash
|
||||
# 服务器配置
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
# 数据库配置(使用内置 SQLite)
|
||||
DATABASE_URL=sqlite:./data/mcphub.db
|
||||
## 初始设置
|
||||
|
||||
# JWT 密钥(请更改为安全的随机字符串)
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-me
|
||||
### 1. 访问控制面板
|
||||
|
||||
# 管理员账户
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=admin123
|
||||
```
|
||||
打开浏览器并导航到:
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
### 方式二:使用 Docker
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion icon="docker" title="Docker 快速部署">
|
||||
使用 Docker Compose 一键部署:
|
||||
|
||||
```bash
|
||||
# 下载配置文件
|
||||
curl -O https://raw.githubusercontent.com/mcphub/mcphub/main/docker-compose.yml
|
||||
|
||||
# 启动服务
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
或者直接运行 Docker 容器:
|
||||
```bash
|
||||
docker run -d \
|
||||
--name mcphub \
|
||||
-p 3000:3000 \
|
||||
-e NODE_ENV=production \
|
||||
-e JWT_SECRET=your-secret-key \
|
||||
mcphub/server:latest
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## 启动 MCPHub
|
||||
|
||||
### 开发模式启动
|
||||
|
||||
```bash
|
||||
# 初始化数据库
|
||||
npm run db:setup
|
||||
|
||||
# 启动开发服务器
|
||||
npm run dev
|
||||
```
|
||||
http://localhost:3000
|
||||
```
|
||||
|
||||
### 生产模式启动
|
||||
### 2. 登录
|
||||
|
||||
```bash
|
||||
# 构建应用
|
||||
npm run build
|
||||
使用默认凭据:
|
||||
|
||||
# 启动生产服务器
|
||||
npm start
|
||||
```
|
||||
|
||||
<Note>开发模式下,MCPHub 会在 `http://localhost:3000` 启动,并具有热重载功能。</Note>
|
||||
|
||||
## 首次访问和配置
|
||||
|
||||
### 1. 访问管理界面
|
||||
|
||||
打开浏览器,访问 `http://localhost:3000`,您将看到 MCPHub 的欢迎页面。
|
||||
|
||||
### 2. 登录管理员账户
|
||||
|
||||
使用您在 `.env` 文件中设置的管理员凭据登录:
|
||||
|
||||
- **邮箱**: `admin@example.com`
|
||||
- **用户名**: `admin`
|
||||
- **密码**: `admin123`
|
||||
|
||||
<Warning>首次登录后,请立即更改默认密码以确保安全!</Warning>
|
||||
<Warning>为了安全起见,请在首次登录后立即更改这些默认凭据。</Warning>
|
||||
|
||||
### 3. 完成初始配置
|
||||
### 3. 配置您的第一个 MCP 服务器
|
||||
|
||||
登录后,系统会引导您完成初始配置:
|
||||
1. 在控制面板中点击 **"添加服务器"**
|
||||
2. 输入服务器详细信息:
|
||||
- **名称**: 唯一标识符(例如 `fetch`)
|
||||
- **命令**: 可执行命令(`uvx`)
|
||||
- **参数**: 命令参数(`["mcp-server-fetch"]`)
|
||||
- **环境**: 任何所需的环境变量
|
||||
|
||||
1. **更改管理员密码**
|
||||
2. **设置组织信息**
|
||||
3. **配置基本设置**
|
||||
fetch 服务器的示例配置:
|
||||
|
||||
## 添加您的第一个 MCP 服务器
|
||||
|
||||
### 1. 准备 MCP 服务器
|
||||
|
||||
如果您还没有 MCP 服务器,可以使用我们的示例服务器进行测试:
|
||||
|
||||
```bash
|
||||
# 克隆示例服务器
|
||||
git clone https://github.com/mcphub/example-mcp-server.git
|
||||
cd example-mcp-server
|
||||
|
||||
# 安装依赖并启动
|
||||
npm install
|
||||
npm start
|
||||
```json
|
||||
{
|
||||
"name": "fetch",
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-fetch"],
|
||||
"env": {}
|
||||
}
|
||||
```
|
||||
|
||||
示例服务器将在 `http://localhost:3001` 启动。
|
||||
## 基本使用
|
||||
|
||||
### 2. 在 MCPHub 中添加服务器
|
||||
### 连接 AI 客户端
|
||||
|
||||
在 MCPHub 管理界面中:
|
||||
一旦配置了服务器,使用 MCPHub 端点连接您的 AI 客户端:
|
||||
|
||||
1. 点击 **"添加服务器"** 按钮
|
||||
2. 填写服务器信息:
|
||||
```
|
||||
名称: Example MCP Server
|
||||
端点: http://localhost:3001
|
||||
描述: 示例 MCP 服务器用于测试
|
||||
```
|
||||
3. 选择功能类型(如:chat、completion、analysis)
|
||||
4. 点击 **"测试连接"** 验证服务器可达性
|
||||
5. 点击 **"保存"** 完成添加
|
||||
<Tabs>
|
||||
<Tab title="所有服务器">
|
||||
访问所有已配置的 MCP 服务器:``` http://localhost:3000/mcp ```
|
||||
</Tab>
|
||||
<Tab title="特定组">
|
||||
访问特定组中的服务器:``` http://localhost:3000/mcp/{groupName} ```
|
||||
</Tab>
|
||||
<Tab title="单个服务器">
|
||||
访问单个服务器:``` http://localhost:3000/mcp/{serverName} ```
|
||||
</Tab>
|
||||
<Tab title="智能路由">
|
||||
使用 AI 驱动的工具发现:``` http://localhost:3000/mcp/$smart ```
|
||||
<Info>智能路由需要使用 pgvector 的 PostgreSQL 和 OpenAI API 密钥。</Info>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### 3. 验证服务器状态
|
||||
### 示例:添加热门 MCP 服务器
|
||||
|
||||
添加成功后,您应该能在服务器列表中看到新添加的服务器,状态显示为 **"活跃"**(绿色)。
|
||||
以下是一些您可以添加的热门 MCP 服务器:
|
||||
|
||||
## 测试路由功能
|
||||
|
||||
### 发送测试请求
|
||||
|
||||
使用 cURL 或其他 HTTP 客户端测试路由功能:
|
||||
|
||||
```bash
|
||||
# 发送聊天请求
|
||||
curl -X POST http://localhost:3000/api/chat \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Hello, this is a test message!"
|
||||
<AccordionGroup>
|
||||
<Accordion title="Web Fetch 服务器">
|
||||
```json
|
||||
{
|
||||
"name": "fetch",
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-fetch"]
|
||||
}
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Playwright 浏览器自动化">
|
||||
```json
|
||||
{
|
||||
"name": "playwright",
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless"]
|
||||
}
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="高德地图(需要 API 密钥)">
|
||||
```json
|
||||
{
|
||||
"name": "amap",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@amap/amap-maps-mcp-server"],
|
||||
"env": {
|
||||
"AMAP_MAPS_API_KEY": "your-api-key-here"
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### 查看请求日志
|
||||
|
||||
在 MCPHub 管理界面的 **"监控"** 页面中,您可以实时查看:
|
||||
|
||||
- 请求数量和响应时间
|
||||
- 服务器健康状态
|
||||
- 错误日志和统计
|
||||
}
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Slack 集成">
|
||||
```json
|
||||
{
|
||||
"name": "slack",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-slack"],
|
||||
"env": {
|
||||
"SLACK_BOT_TOKEN": "your-bot-token",
|
||||
"SLACK_TEAM_ID": "your-team-id"
|
||||
}
|
||||
}
|
||||
```
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## 后续步骤
|
||||
|
||||
恭喜!您已经成功部署了 MCPHub 并添加了第一个 MCP 服务器。接下来您可以:
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="配置负载均衡" icon="balance-scale" href="/zh/features/smart-routing">
|
||||
学习如何配置智能路由和负载均衡策略
|
||||
<Card title="服务器管理" icon="server" href="/zh/features/server-management">
|
||||
学习高级服务器配置和管理
|
||||
</Card>
|
||||
<Card title="添加更多服务器" icon="plus" href="/zh/features/server-management">
|
||||
了解服务器管理的高级功能
|
||||
<Card title="组管理" icon="users" href="/zh/features/group-management">
|
||||
将服务器组织成逻辑组
|
||||
</Card>
|
||||
<Card title="设置监控告警" icon="bell" href="/zh/features/monitoring">
|
||||
配置性能监控和告警通知
|
||||
<Card title="智能路由" icon="route" href="/zh/features/smart-routing">
|
||||
设置 AI 驱动的工具发现
|
||||
</Card>
|
||||
<Card title="API 集成" icon="code" href="/zh/api-reference/introduction">
|
||||
将 MCPHub 集成到您的应用程序中
|
||||
<Card title="API 参考" icon="code" href="/zh/api-reference/introduction">
|
||||
探索完整的 API 文档
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## 常见问题
|
||||
## 故障排除
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion icon="question" title="无法连接到 MCP 服务器">
|
||||
**可能原因**:
|
||||
- 服务器地址错误或服务器未启动
|
||||
- 防火墙阻止连接
|
||||
- 网络配置问题
|
||||
|
||||
**解决方案**:
|
||||
1. 验证服务器是否正在运行:`curl http://localhost:3001/health`
|
||||
2. 检查防火墙设置
|
||||
3. 确认网络连接正常
|
||||
|
||||
<Accordion title="服务器无法启动">
|
||||
- 检查 MCP 服务器命令是否在您的 PATH 中可访问
|
||||
- 验证环境变量是否正确设置
|
||||
- 检查 MCPHub 日志以获取详细错误信息
|
||||
</Accordion>
|
||||
|
||||
<Accordion icon="question" title="服务器状态显示为离线">
|
||||
**可能原因**:
|
||||
- 健康检查失败
|
||||
- 服务器响应超时
|
||||
- 服务器崩溃或重启
|
||||
|
||||
**解决方案**:
|
||||
1. 检查服务器日志
|
||||
2. 调整健康检查间隔
|
||||
3. 重启服务器进程
|
||||
|
||||
<Accordion title="无法从 AI 客户端连接">
|
||||
- 确保 MCPHub 在正确的端口上运行
|
||||
- 检查防火墙设置
|
||||
- 验证端点 URL 格式
|
||||
</Accordion>
|
||||
|
||||
<Accordion icon="question" title="忘记管理员密码">
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 重置管理员密码
|
||||
npm run reset-admin-password
|
||||
```
|
||||
或者删除数据库文件重新初始化:
|
||||
```bash
|
||||
rm data/mcphub.db
|
||||
npm run db:setup
|
||||
```
|
||||
<Accordion title="身份验证问题">
|
||||
- 验证凭据是否正确
|
||||
- 检查 JWT 令牌是否有效
|
||||
- 尝试清除浏览器缓存和 cookie
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## 获取帮助
|
||||
|
||||
如果您在设置过程中遇到问题:
|
||||
|
||||
- 📖 查看 [完整文档](/zh/development/getting-started)
|
||||
- 🐛 在 [GitHub](https://github.com/mcphub/mcphub/issues) 上报告问题
|
||||
- 💬 加入 [Discord 社区](https://discord.gg/mcphub) 获取实时帮助
|
||||
- 📧 发送邮件至 support@mcphub.io
|
||||
需要更多帮助?加入我们的 [Discord 社区](https://discord.gg/qMKNsn5Q) 获取支持!
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MCP Hub Dashboard</title>
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-100">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom';
|
||||
import { BrowserRouter as Router, Route, Routes, Navigate, useParams } from 'react-router-dom';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { ToastProvider } from './contexts/ToastContext';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
@@ -9,11 +9,18 @@ import LoginPage from './pages/LoginPage';
|
||||
import DashboardPage from './pages/Dashboard';
|
||||
import ServersPage from './pages/ServersPage';
|
||||
import GroupsPage from './pages/GroupsPage';
|
||||
import UsersPage from './pages/UsersPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import MarketPage from './pages/MarketPage';
|
||||
import LogsPage from './pages/LogsPage';
|
||||
import { getBasePath } from './utils/runtime';
|
||||
|
||||
// Helper component to redirect cloud server routes to market
|
||||
const CloudRedirect: React.FC = () => {
|
||||
const { serverName } = useParams<{ serverName: string }>();
|
||||
return <Navigate to={`/market/${serverName}?tab=cloud`} replace />;
|
||||
};
|
||||
|
||||
function App() {
|
||||
const basename = getBasePath();
|
||||
return (
|
||||
@@ -31,8 +38,15 @@ function App() {
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/servers" element={<ServersPage />} />
|
||||
<Route path="/groups" element={<GroupsPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/market" element={<MarketPage />} />
|
||||
<Route path="/market/:serverName" element={<MarketPage />} />
|
||||
{/* Legacy cloud routes redirect to market with cloud tab */}
|
||||
<Route path="/cloud" element={<Navigate to="/market?tab=cloud" replace />} />
|
||||
<Route
|
||||
path="/cloud/:serverName"
|
||||
element={<CloudRedirect />}
|
||||
/>
|
||||
<Route path="/logs" element={<LogsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useGroupData } from '@/hooks/useGroupData'
|
||||
import { useServerData } from '@/hooks/useServerData'
|
||||
import { GroupFormData, Server } from '@/types'
|
||||
import { ToggleGroup } from './ui/ToggleGroup'
|
||||
import { GroupFormData, Server, IGroupServerConfig } from '@/types'
|
||||
import { ServerToolConfig } from './ServerToolConfig'
|
||||
|
||||
interface AddGroupFormProps {
|
||||
onAdd: () => void
|
||||
@@ -21,7 +21,7 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
|
||||
const [formData, setFormData] = useState<GroupFormData>({
|
||||
name: '',
|
||||
description: '',
|
||||
servers: []
|
||||
servers: [] as IGroupServerConfig[]
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,9 +50,8 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
|
||||
}
|
||||
|
||||
const result = await createGroup(formData.name, formData.description, formData.servers)
|
||||
|
||||
if (!result) {
|
||||
setError(t('groups.createError'))
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('groups.createError'))
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
@@ -66,64 +65,68 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
|
||||
<div className="p-6">
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-2xl w-full max-h-[90vh] flex flex-col">
|
||||
<div className="p-6 flex-shrink-0">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('groups.addNew')}</h2>
|
||||
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded-md border border-gray-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="name">
|
||||
{t('groups.name')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
placeholder={t('groups.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ToggleGroup
|
||||
className="mb-6"
|
||||
label={t('groups.servers')}
|
||||
noOptionsText={t('groups.noServerOptions')}
|
||||
values={formData.servers}
|
||||
options={availableServers.map(server => ({
|
||||
value: server.name,
|
||||
label: server.name
|
||||
}))}
|
||||
onChange={(servers) => setFormData(prev => ({ ...prev, servers }))}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? t('common.submitting') : t('common.create')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex-1 overflow-y-auto px-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="name">
|
||||
{t('groups.name')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder={t('groups.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2">
|
||||
{t('groups.configureTools')}
|
||||
</label>
|
||||
<ServerToolConfig
|
||||
servers={availableServers}
|
||||
value={formData.servers as IGroupServerConfig[]}
|
||||
onChange={(servers) => setFormData(prev => ({ ...prev, servers }))}
|
||||
className="border border-gray-200 rounded-lg p-4 bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 p-6 pt-4 border-t border-gray-200 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:opacity-50 transition-colors"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? t('common.submitting') : t('common.create')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ServerForm from './ServerForm'
|
||||
import { getApiUrl } from '../utils/runtime';
|
||||
import { apiPost } from '../utils/fetchInterceptor'
|
||||
import { detectVariables } from '../utils/variableDetection'
|
||||
|
||||
interface AddServerFormProps {
|
||||
onAdd: () => void
|
||||
@@ -11,35 +12,34 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [confirmationVisible, setConfirmationVisible] = useState(false)
|
||||
const [pendingPayload, setPendingPayload] = useState<any>(null)
|
||||
const [detectedVariables, setDetectedVariables] = useState<string[]>([])
|
||||
|
||||
const toggleModal = () => {
|
||||
setModalVisible(!modalVisible)
|
||||
setError(null) // Clear any previous errors when toggling modal
|
||||
setConfirmationVisible(false) // Close confirmation dialog
|
||||
setPendingPayload(null) // Clear pending payload
|
||||
}
|
||||
|
||||
const handleSubmit = async (payload: any) => {
|
||||
const handleConfirmSubmit = async () => {
|
||||
if (pendingPayload) {
|
||||
await submitServer(pendingPayload)
|
||||
setConfirmationVisible(false)
|
||||
setPendingPayload(null)
|
||||
}
|
||||
}
|
||||
|
||||
const submitServer = async (payload: any) => {
|
||||
try {
|
||||
setError(null)
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/servers'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || ''
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const result = await apiPost('/servers', payload)
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
if (!result.success) {
|
||||
// Use specific error message from the response if available
|
||||
if (result && result.message) {
|
||||
setError(result.message)
|
||||
} else if (response.status === 400) {
|
||||
setError(t('server.invalidData'))
|
||||
} else if (response.status === 409) {
|
||||
setError(t('server.alreadyExists', { serverName: payload.name }))
|
||||
} else {
|
||||
setError(t('server.addError'))
|
||||
}
|
||||
@@ -65,11 +65,31 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (payload: any) => {
|
||||
try {
|
||||
// Check for variables in the payload
|
||||
const variables = detectVariables(payload)
|
||||
|
||||
if (variables.length > 0) {
|
||||
// Show confirmation dialog
|
||||
setDetectedVariables(variables)
|
||||
setPendingPayload(payload)
|
||||
setConfirmationVisible(true)
|
||||
} else {
|
||||
// Submit directly if no variables found
|
||||
await submitServer(payload)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error processing server submission:', err)
|
||||
setError(t('errors.serverAdd'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={toggleModal}
|
||||
className="w-full bg-blue-100 text-blue-800 rounded hover:bg-blue-200 py-2 px-4 flex items-center justify-center"
|
||||
className="w-full bg-blue-100 text-blue-800 rounded hover:bg-blue-200 py-2 px-4 flex items-center justify-center btn-primary"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
|
||||
@@ -87,6 +107,60 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmationVisible && (
|
||||
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
{t('server.confirmVariables')}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{t('server.variablesDetected')}
|
||||
</p>
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 mb-4">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h4 className="text-sm font-medium text-yellow-800">
|
||||
{t('server.detectedVariables')}:
|
||||
</h4>
|
||||
<ul className="mt-1 text-sm text-yellow-700">
|
||||
{detectedVariables.map((variable, index) => (
|
||||
<li key={index} className="font-mono">
|
||||
${`{${variable}}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm mb-6">
|
||||
{t('server.confirmVariablesMessage')}
|
||||
</p>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setConfirmationVisible(false)
|
||||
setPendingPayload(null)
|
||||
}}
|
||||
className="px-4 py-2 text-gray-600 border border-gray-300 rounded hover:bg-gray-50 btn-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmSubmit}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 btn-primary"
|
||||
>
|
||||
{t('server.confirmAndAdd')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,17 +31,17 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
|
||||
// Validate passwords match
|
||||
if (formData.newPassword !== confirmPassword) {
|
||||
setError(t('auth.passwordsNotMatch'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await changePassword(formData);
|
||||
|
||||
|
||||
if (response.success) {
|
||||
setSuccess(true);
|
||||
if (onSuccess) {
|
||||
@@ -60,7 +60,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
return (
|
||||
<div className="p-6 bg-white rounded-lg shadow-md">
|
||||
<h2 className="text-xl font-bold mb-4">{t('auth.changePassword')}</h2>
|
||||
|
||||
|
||||
{success ? (
|
||||
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
|
||||
{t('auth.changePasswordSuccess')}
|
||||
@@ -72,7 +72,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="currentPassword">
|
||||
{t('auth.currentPassword')}
|
||||
@@ -81,13 +81,13 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
type="password"
|
||||
id="currentPassword"
|
||||
name="currentPassword"
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
|
||||
value={formData.currentPassword}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="newPassword">
|
||||
{t('auth.newPassword')}
|
||||
@@ -96,14 +96,14 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
type="password"
|
||||
id="newPassword"
|
||||
name="newPassword"
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
|
||||
value={formData.newPassword}
|
||||
onChange={handleChange}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="confirmPassword">
|
||||
{t('auth.confirmPassword')}
|
||||
@@ -112,14 +112,14 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
|
||||
value={confirmPassword}
|
||||
onChange={handleChange}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
{onCancel && (
|
||||
<button
|
||||
@@ -134,7 +134,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
className="py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 btn-primary"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center">
|
||||
|
||||
144
frontend/src/components/CloudServerCard.tsx
Normal file
144
frontend/src/components/CloudServerCard.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CloudServer } from '@/types';
|
||||
|
||||
interface CloudServerCardProps {
|
||||
server: CloudServer;
|
||||
onClick: (server: CloudServer) => void;
|
||||
}
|
||||
|
||||
const CloudServerCard: React.FC<CloudServerCardProps> = ({ server, onClick }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleClick = () => {
|
||||
onClick(server);
|
||||
};
|
||||
|
||||
// Extract a brief description from content if description is too long
|
||||
const getDisplayDescription = () => {
|
||||
if (server.description && server.description.length <= 150) {
|
||||
return server.description;
|
||||
}
|
||||
|
||||
// Try to extract a summary from content
|
||||
if (server.content) {
|
||||
const lines = server.content.split('\n').filter(line => line.trim());
|
||||
for (const line of lines) {
|
||||
if (line.length > 50 && line.length <= 150) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return server.description ?
|
||||
server.description.slice(0, 150) + '...' :
|
||||
t('cloud.noDescription');
|
||||
};
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
const year = date.getFullYear();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
return `${year}/${month}/${day}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Get initials for avatar
|
||||
const getAuthorInitials = (name: string) => {
|
||||
return name
|
||||
.split(' ')
|
||||
.map(word => word.charAt(0))
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-white border border-gray-200 rounded-xl p-4 hover:shadow-lg hover:border-blue-400 hover:-translate-y-1 transition-all duration-300 cursor-pointer group relative overflow-hidden h-full flex flex-col"
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Background gradient overlay on hover */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-50/0 to-purple-50/0 group-hover:from-blue-50/30 group-hover:to-purple-50/30 transition-all duration-300 pointer-events-none" />
|
||||
|
||||
{/* Server Header */}
|
||||
<div className="relative z-10 flex-1 flex flex-col">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-bold text-gray-900 group-hover:text-blue-600 transition-colors duration-200 mb-2 line-clamp-2">
|
||||
{server.title || server.name}
|
||||
</h3>
|
||||
|
||||
{/* Author Section */}
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<div className="w-7 h-7 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center text-white text-xs font-semibold">
|
||||
{getAuthorInitials(server.author_name)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700">{server.author_name}</p>
|
||||
{server.updated_at && (
|
||||
<p className="text-xs text-gray-500">
|
||||
{t('cloud.updated')} {formatDate(server.updated_at)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server Type Badge */}
|
||||
<div className="flex flex-col items-end space-y-2">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
MCP Server
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="mb-3 flex-1">
|
||||
<p className="text-gray-600 text-sm leading-relaxed line-clamp-2">
|
||||
{getDisplayDescription()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tools Info */}
|
||||
{server.tools && server.tools.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span className="text-sm text-gray-600 font-medium">
|
||||
{server.tools.length} {server.tools.length === 1 ? t('cloud.tool') : t('cloud.tools')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer - 固定在底部 */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-gray-100 mt-auto">
|
||||
<div className="flex items-center space-x-2 text-xs text-gray-500">
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>{formatDate(server.created_at)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-blue-600 text-sm font-medium group-hover:text-blue-700 transition-colors">
|
||||
<span>{t('cloud.viewDetails')}</span>
|
||||
<svg className="w-4 h-4 ml-1 transform group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CloudServerCard;
|
||||
573
frontend/src/components/CloudServerDetail.tsx
Normal file
573
frontend/src/components/CloudServerDetail.tsx
Normal file
@@ -0,0 +1,573 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CloudServer, CloudServerTool, ServerConfig } from '@/types';
|
||||
import { apiGet } from '@/utils/fetchInterceptor';
|
||||
import { useSettingsData } from '@/hooks/useSettingsData';
|
||||
import MCPRouterApiKeyError from './MCPRouterApiKeyError';
|
||||
import ServerForm from './ServerForm';
|
||||
|
||||
interface CloudServerDetailProps {
|
||||
serverName: string;
|
||||
onBack: () => void;
|
||||
onCallTool?: (serverName: string, toolName: string, args: Record<string, any>) => Promise<any>;
|
||||
fetchServerTools?: (serverName: string) => Promise<CloudServerTool[]>;
|
||||
onInstall?: (server: CloudServer, config: ServerConfig) => void;
|
||||
installing?: boolean;
|
||||
isInstalled?: boolean;
|
||||
}
|
||||
|
||||
const CloudServerDetail: React.FC<CloudServerDetailProps> = ({
|
||||
serverName,
|
||||
onBack,
|
||||
onCallTool,
|
||||
fetchServerTools,
|
||||
onInstall,
|
||||
installing = false,
|
||||
isInstalled = false
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { mcpRouterConfig } = useSettingsData();
|
||||
const [server, setServer] = useState<CloudServer | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tools, setTools] = useState<CloudServerTool[]>([]);
|
||||
const [loadingTools, setLoadingTools] = useState(false);
|
||||
const [toolsApiKeyError, setToolsApiKeyError] = useState(false);
|
||||
const [toolCallLoading, setToolCallLoading] = useState<string | null>(null);
|
||||
const [toolCallResults, setToolCallResults] = useState<Record<string, any>>({});
|
||||
const [toolArgs, setToolArgs] = useState<Record<string, Record<string, any>>>({});
|
||||
const [expandedSchemas, setExpandedSchemas] = useState<Record<string, boolean>>({});
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [installError, setInstallError] = useState<string | null>(null);
|
||||
|
||||
// Helper function to check if error is MCPRouter API key not configured
|
||||
const isMCPRouterApiKeyError = (errorMessage: string) => {
|
||||
console.error('Checking for MCPRouter API key error:', errorMessage);
|
||||
return errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' ||
|
||||
errorMessage.toLowerCase().includes('mcprouter api key not configured');
|
||||
};
|
||||
|
||||
// Helper function to determine button state for install
|
||||
const getInstallButtonProps = () => {
|
||||
if (isInstalled) {
|
||||
return {
|
||||
className: "bg-green-600 cursor-default px-4 py-2 rounded text-sm font-medium text-white",
|
||||
disabled: true,
|
||||
text: t('market.installed')
|
||||
};
|
||||
} else if (installing) {
|
||||
return {
|
||||
className: "bg-gray-400 cursor-not-allowed px-4 py-2 rounded text-sm font-medium text-white",
|
||||
disabled: true,
|
||||
text: t('market.installing')
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
className: "bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white transition-colors",
|
||||
disabled: false,
|
||||
text: t('market.install')
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Handle install button click
|
||||
const handleInstall = () => {
|
||||
if (!isInstalled && onInstall) {
|
||||
setModalVisible(true);
|
||||
setInstallError(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle modal close
|
||||
const handleModalClose = () => {
|
||||
setModalVisible(false);
|
||||
setInstallError(null);
|
||||
};
|
||||
|
||||
// Handle install form submission
|
||||
const handleInstallSubmit = async (payload: any) => {
|
||||
try {
|
||||
if (!server || !onInstall) return;
|
||||
|
||||
setInstallError(null);
|
||||
onInstall(server, payload.config);
|
||||
setModalVisible(false);
|
||||
} catch (err) {
|
||||
console.error('Error installing server:', err);
|
||||
setInstallError(t('errors.serverInstall'));
|
||||
}
|
||||
};
|
||||
|
||||
// Load server details
|
||||
useEffect(() => {
|
||||
const loadServerDetails = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await apiGet(`/cloud/servers/${serverName}`);
|
||||
|
||||
if (response && response.success && response.data) {
|
||||
setServer(response.data);
|
||||
setTools(response.data.tools || []);
|
||||
} else {
|
||||
setError(t('cloud.serverNotFound'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load server details:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadServerDetails();
|
||||
}, [serverName, t]);
|
||||
|
||||
// Load tools if not already loaded
|
||||
useEffect(() => {
|
||||
const loadTools = async () => {
|
||||
if (server && (!server.tools || server.tools.length === 0) && fetchServerTools) {
|
||||
setLoadingTools(true);
|
||||
setToolsApiKeyError(false);
|
||||
try {
|
||||
const fetchedTools = await fetchServerTools(server.name);
|
||||
setTools(fetchedTools);
|
||||
} catch (error) {
|
||||
console.error('Failed to load tools:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (isMCPRouterApiKeyError(errorMessage)) {
|
||||
setToolsApiKeyError(true);
|
||||
}
|
||||
} finally {
|
||||
setLoadingTools(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadTools();
|
||||
}, [server?.name, server?.tools, fetchServerTools]);
|
||||
|
||||
// Format creation date
|
||||
const formatDate = (dateStr: string) => {
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle tool argument changes
|
||||
const handleArgChange = (toolName: string, argName: string, value: any) => {
|
||||
setToolArgs(prev => ({
|
||||
...prev,
|
||||
[toolName]: {
|
||||
...prev[toolName],
|
||||
[argName]: value
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
// Handle tool call
|
||||
const handleCallTool = async (toolName: string) => {
|
||||
if (!onCallTool || !server) return;
|
||||
|
||||
setToolCallLoading(toolName);
|
||||
try {
|
||||
const args = toolArgs[toolName] || {};
|
||||
const result = await onCallTool(server.server_key, toolName, args);
|
||||
setToolCallResults(prev => ({
|
||||
...prev,
|
||||
[toolName]: result
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Tool call failed:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
setToolCallResults(prev => ({
|
||||
...prev,
|
||||
[toolName]: { error: errorMessage }
|
||||
}));
|
||||
} finally {
|
||||
setToolCallLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle schema visibility
|
||||
const toggleSchema = (toolName: string) => {
|
||||
setExpandedSchemas(prev => ({
|
||||
...prev,
|
||||
[toolName]: !prev[toolName]
|
||||
}));
|
||||
};
|
||||
|
||||
// Render tool input field based on schema
|
||||
const renderToolInput = (tool: CloudServerTool, propName: string, propSchema: any) => {
|
||||
const currentValue = toolArgs[tool.name]?.[propName] || '';
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
let value: any = e.target.value;
|
||||
|
||||
// Convert value based on schema type
|
||||
if (propSchema.type === 'number' || propSchema.type === 'integer') {
|
||||
value = value === '' ? undefined : Number(value);
|
||||
} else if (propSchema.type === 'boolean') {
|
||||
value = e.target.value === 'true';
|
||||
}
|
||||
|
||||
handleArgChange(tool.name, propName, value);
|
||||
};
|
||||
|
||||
if (propSchema.type === 'boolean') {
|
||||
return (
|
||||
<select
|
||||
value={currentValue === true ? 'true' : currentValue === false ? 'false' : ''}
|
||||
onChange={handleChange}
|
||||
className="w-full border rounded-md px-3 py-2 border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
|
||||
>
|
||||
<option value=""></option>
|
||||
<option value="true">True</option>
|
||||
<option value="false">False</option>
|
||||
</select>
|
||||
);
|
||||
} else if (propSchema.type === 'number' || propSchema.type === 'integer') {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
step={propSchema.type === 'integer' ? '1' : 'any'}
|
||||
value={currentValue || ''}
|
||||
onChange={handleChange}
|
||||
className="w-full border rounded-md px-3 py-2 border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue || ''}
|
||||
onChange={handleChange}
|
||||
className="w-full border rounded-md px-3 py-2 border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="inline-flex items-center text-gray-600 hover:text-gray-900 transition-colors group"
|
||||
>
|
||||
<svg className="h-5 w-5 mr-2 transform group-hover:-translate-x-1 transition-transform" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{t('cloud.backToList')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="bg-white rounded-xl shadow-sm p-12">
|
||||
<div className="flex flex-col items-center">
|
||||
<svg className="animate-spin h-12 w-12 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p className="text-gray-600 text-lg">{t('app.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : error && !isMCPRouterApiKeyError(error) ? (
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-center">
|
||||
<svg className="h-5 w-5 text-red-400 mr-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<p className="text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : !server ? (
|
||||
<div className="bg-white rounded-xl shadow-sm p-12">
|
||||
<div className="text-center">
|
||||
<svg className="h-12 w-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9.172 16.172a4 4 0 015.656 0M9 12h6m-6-4h6m2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
<p className="text-gray-600 text-lg">{t('cloud.serverNotFound')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Server Header Card */}
|
||||
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-gray-100 to-gray-200 px-6 py-4">
|
||||
<div className="flex justify-between items-end">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-2">
|
||||
{server.title || server.name}
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-4 text-gray-600">
|
||||
<span className="text-sm bg-white/60 text-gray-700 px-3 py-1 rounded-full">
|
||||
{server.name}
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
<svg className="h-4 w-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{t('cloud.by')} {server.author_name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right flex flex-col items-end gap-3">
|
||||
<div className="text-xs text-gray-500">
|
||||
{t('cloud.updated')}: {formatDate(server.updated_at)}
|
||||
</div>
|
||||
{onInstall && !isMCPRouterApiKeyError(error || '') && !toolsApiKeyError && (
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
disabled={getInstallButtonProps().disabled}
|
||||
className={getInstallButtonProps().className}
|
||||
>
|
||||
{getInstallButtonProps().text}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description Card */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<svg className="h-5 w-5 text-gray-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{t('cloud.description')}
|
||||
</h2>
|
||||
<p className="text-gray-700 leading-relaxed">{server.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Content Card */}
|
||||
{server.content && (
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<svg className="h-5 w-5 text-gray-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
{t('cloud.details')}
|
||||
</h2>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 overflow-auto">
|
||||
<pre className="text-sm text-gray-800 whitespace-pre-wrap">{server.content}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tools Card */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<svg className="h-5 w-5 text-gray-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
{t('cloud.tools')}
|
||||
{tools.length > 0 && (
|
||||
<span className="ml-2 bg-blue-100 text-blue-800 text-sm font-medium px-2.5 py-0.5 rounded-full">
|
||||
{tools.length}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
{/* Check for API key error */}
|
||||
{toolsApiKeyError && (
|
||||
<MCPRouterApiKeyError />
|
||||
)}
|
||||
|
||||
{loadingTools ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<svg className="animate-spin h-8 w-8 text-blue-500 mr-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span className="text-gray-600">{t('cloud.loadingTools')}</span>
|
||||
</div>
|
||||
) : tools.length === 0 && !toolsApiKeyError ? (
|
||||
<div className="text-center py-12">
|
||||
<svg className="h-12 w-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
<p className="text-gray-600">{t('cloud.noTools')}</p>
|
||||
</div>
|
||||
) : tools.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{tools.map((tool, index) => (
|
||||
<div key={index} className="border border-gray-200 rounded-lg p-6 hover:border-gray-300 transition-colors">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2 flex items-center">
|
||||
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2 py-1 rounded mr-3">
|
||||
TOOL
|
||||
</span>
|
||||
{tool.name}
|
||||
</h3>
|
||||
<p className="text-gray-600 leading-relaxed whitespace-pre-wrap">{tool.description}</p>
|
||||
</div>
|
||||
{onCallTool && (
|
||||
<button
|
||||
onClick={() => handleCallTool(tool.name)}
|
||||
disabled={toolCallLoading === tool.name}
|
||||
className="ml-4 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center min-w-[100px] justify-center"
|
||||
>
|
||||
{toolCallLoading === tool.name ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{t('cloud.calling')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.828 14.828a4 4 0 01-5.656 0M9 10h6m2 8l4-4H7l4 4z" />
|
||||
</svg>
|
||||
{t('cloud.callTool')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tool inputs */}
|
||||
{tool.inputSchema && tool.inputSchema.properties && Object.keys(tool.inputSchema.properties).length > 0 && (
|
||||
<div className="border-t border-gray-100 pt-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<h4 className="text-sm font-medium text-gray-700">{t('cloud.parameters')}</h4>
|
||||
<button
|
||||
onClick={() => toggleSchema(tool.name)}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 focus:outline-none flex items-center gap-1 transition-colors"
|
||||
>
|
||||
{t('cloud.viewSchema')}
|
||||
<svg
|
||||
className={`h-3 w-3 transition-transform duration-200 ${expandedSchemas[tool.name] ? 'rotate-90' : 'rotate-0'}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Schema content */}
|
||||
{expandedSchemas[tool.name] && (
|
||||
<div className="mb-4">
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3 overflow-auto">
|
||||
<pre className="text-sm text-gray-800">
|
||||
{JSON.stringify(tool.inputSchema, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{Object.entries(tool.inputSchema.properties).map(([propName, propSchema]: [string, any]) => (
|
||||
<div key={propName} className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{propName}
|
||||
{tool.inputSchema.required?.includes(propName) && (
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
)}
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
<p className="text-xs text-gray-500">{propSchema.description}</p>
|
||||
)}
|
||||
{renderToolInput(tool, propName, propSchema)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tool call result */}
|
||||
{toolCallResults[tool.name] && (
|
||||
<div className="border-t border-gray-100 pt-4 mt-4">
|
||||
{toolCallResults[tool.name].error ? (
|
||||
<>
|
||||
{isMCPRouterApiKeyError(toolCallResults[tool.name].error) ? (
|
||||
<MCPRouterApiKeyError />
|
||||
) : (
|
||||
<>
|
||||
<h4 className="text-sm font-medium text-red-600 mb-3 flex items-center">
|
||||
<svg className="h-4 w-4 text-red-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{t('cloud.error')}
|
||||
</h4>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<pre className="text-sm text-red-800 whitespace-pre-wrap overflow-auto">
|
||||
{toolCallResults[tool.name].error}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3 flex items-center">
|
||||
<svg className="h-4 w-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{t('cloud.result')}
|
||||
</h4>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<pre className="text-sm text-gray-800 whitespace-pre-wrap overflow-auto">
|
||||
{JSON.stringify(toolCallResults[tool.name], null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Install Modal */}
|
||||
{modalVisible && server && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<ServerForm
|
||||
onSubmit={handleInstallSubmit}
|
||||
onCancel={handleModalClose}
|
||||
modalTitle={t('cloud.installServer', { name: server.title || server.name })}
|
||||
formError={installError}
|
||||
initialData={{
|
||||
name: server.name,
|
||||
status: 'disconnected',
|
||||
config: {
|
||||
type: 'streamable-http',
|
||||
url: server.server_url,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${mcpRouterConfig.apiKey || '<MCPROUTER_API_KEY>'}`,
|
||||
'HTTP-Referer': mcpRouterConfig.referer || '<YOUR_APP_URL>',
|
||||
'X-Title': mcpRouterConfig.title || '<YOUR_APP_NAME>'
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CloudServerDetail;
|
||||
394
frontend/src/components/DxtUploadForm.tsx
Normal file
394
frontend/src/components/DxtUploadForm.tsx
Normal file
@@ -0,0 +1,394 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { apiPost, apiGet, apiPut, fetchWithInterceptors } from '@/utils/fetchInterceptor';
|
||||
import { getApiUrl } from '@/utils/runtime';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
|
||||
interface DxtUploadFormProps {
|
||||
onSuccess: (serverConfig: any) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface DxtUploadResponse {
|
||||
success: boolean;
|
||||
data?: {
|
||||
manifest: any;
|
||||
extractDir: string;
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const DxtUploadForm: React.FC<DxtUploadFormProps> = ({ onSuccess, onCancel }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [showServerForm, setShowServerForm] = useState(false);
|
||||
const [manifestData, setManifestData] = useState<any>(null);
|
||||
const [extractDir, setExtractDir] = useState<string>('');
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [pendingServerName, setPendingServerName] = useState<string>('');
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
const file = files[0];
|
||||
if (file.name.endsWith('.dxt')) {
|
||||
setSelectedFile(file);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(t('dxt.invalidFileType'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
if (file.name.endsWith('.dxt')) {
|
||||
setSelectedFile(file);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(t('dxt.invalidFileType'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile) {
|
||||
setError(t('dxt.noFileSelected'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('dxtFile', selectedFile);
|
||||
|
||||
const response = await fetchWithInterceptors(getApiUrl('/dxt/upload'), {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const result: DxtUploadResponse = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || `HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
if (result.success && result.data) {
|
||||
setManifestData(result.data.manifest);
|
||||
setExtractDir(result.data.extractDir);
|
||||
setShowServerForm(true);
|
||||
} else {
|
||||
throw new Error(result.message || t('dxt.uploadFailed'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('DXT upload error:', err);
|
||||
setError(err instanceof Error ? err.message : t('dxt.uploadFailed'));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstallServer = async (serverName: string, forceOverride: boolean = false) => {
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Convert DXT manifest to MCPHub stdio server configuration
|
||||
const serverConfig = convertDxtToMcpConfig(manifestData, extractDir, serverName);
|
||||
|
||||
// First, check if server exists
|
||||
if (!forceOverride) {
|
||||
const checkResult = await apiGet('/servers');
|
||||
|
||||
if (checkResult.success) {
|
||||
const existingServer = checkResult.data?.find((server: any) => server.name === serverName);
|
||||
|
||||
if (existingServer) {
|
||||
// Server exists, show confirmation dialog
|
||||
setPendingServerName(serverName);
|
||||
setShowConfirmDialog(true);
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Install or override the server
|
||||
let result;
|
||||
if (forceOverride) {
|
||||
result = await apiPut(`/servers/${encodeURIComponent(serverName)}`, {
|
||||
name: serverName,
|
||||
config: serverConfig,
|
||||
});
|
||||
} else {
|
||||
result = await apiPost('/servers', {
|
||||
name: serverName,
|
||||
config: serverConfig,
|
||||
});
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
onSuccess(serverConfig);
|
||||
} else {
|
||||
throw new Error(result.message || t('dxt.installFailed'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('DXT install error:', err);
|
||||
setError(err instanceof Error ? err.message : t('dxt.installFailed'));
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmOverride = () => {
|
||||
setShowConfirmDialog(false);
|
||||
if (pendingServerName) {
|
||||
handleInstallServer(pendingServerName, true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelOverride = () => {
|
||||
setShowConfirmDialog(false);
|
||||
setPendingServerName('');
|
||||
setIsUploading(false);
|
||||
};
|
||||
|
||||
const convertDxtToMcpConfig = (manifest: any, extractPath: string, _serverName: string) => {
|
||||
const mcpConfig = manifest.server?.mcp_config || {};
|
||||
|
||||
// Convert DXT manifest to MCPHub stdio configuration
|
||||
const config: any = {
|
||||
type: 'stdio',
|
||||
command: mcpConfig.command || 'node',
|
||||
args: (mcpConfig.args || []).map((arg: string) =>
|
||||
arg.replace('${__dirname}', extractPath)
|
||||
),
|
||||
};
|
||||
|
||||
// Add environment variables if they exist
|
||||
if (mcpConfig.env && Object.keys(mcpConfig.env).length > 0) {
|
||||
config.env = { ...mcpConfig.env };
|
||||
|
||||
// Replace ${__dirname} in environment variables
|
||||
Object.keys(config.env).forEach(key => {
|
||||
if (typeof config.env[key] === 'string') {
|
||||
config.env[key] = config.env[key].replace('${__dirname}', extractPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
if (showServerForm && manifestData) {
|
||||
return (
|
||||
<>
|
||||
<ConfirmDialog
|
||||
isOpen={showConfirmDialog}
|
||||
onClose={handleCancelOverride}
|
||||
onConfirm={handleConfirmOverride}
|
||||
title={t('dxt.serverExistsTitle')}
|
||||
message={t('dxt.serverExistsConfirm', { serverName: pendingServerName })}
|
||||
confirmText={t('dxt.override')}
|
||||
cancelText={t('common.cancel')}
|
||||
variant="warning"
|
||||
/>
|
||||
|
||||
<div className={`fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 ${showConfirmDialog ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
<div className="bg-white shadow rounded-lg p-6 w-full max-w-2xl max-h-screen overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900">{t('dxt.installServer')}</h2>
|
||||
<button onClick={onCancel} className="text-gray-500 hover:text-gray-700">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4 rounded">
|
||||
<p className="text-red-700">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Extension Info */}
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<h3 className="font-medium text-gray-900 mb-2">{t('dxt.extensionInfo')}</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div><strong>{t('dxt.name')}:</strong> {manifestData.display_name || manifestData.name}</div>
|
||||
<div><strong>{t('dxt.version')}:</strong> {manifestData.version}</div>
|
||||
<div><strong>{t('dxt.description')}:</strong> {manifestData.description}</div>
|
||||
{manifestData.author && (
|
||||
<div><strong>{t('dxt.author')}:</strong> {manifestData.author.name}</div>
|
||||
)}
|
||||
{manifestData.tools && manifestData.tools.length > 0 && (
|
||||
<div>
|
||||
<strong>{t('dxt.tools')}:</strong>
|
||||
<ul className="list-disc list-inside ml-4">
|
||||
{manifestData.tools.map((tool: any, index: number) => (
|
||||
<li key={index}>{tool.name} - {tool.description}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server Configuration */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{t('dxt.serverName')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="serverName"
|
||||
defaultValue={manifestData.name}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
|
||||
placeholder={t('dxt.serverNamePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end space-x-4">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={isUploading}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50 btn-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const nameInput = document.getElementById('serverName') as HTMLInputElement;
|
||||
const serverName = nameInput?.value.trim() || manifestData.name;
|
||||
handleInstallServer(serverName);
|
||||
}}
|
||||
disabled={isUploading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center btn-primary"
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{t('dxt.installing')}
|
||||
</>
|
||||
) : (
|
||||
t('dxt.install')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white shadow rounded-lg p-6 w-full max-w-lg">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900">{t('dxt.uploadTitle')}</h2>
|
||||
<button onClick={onCancel} className="text-gray-500 hover:text-gray-700">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4 rounded">
|
||||
<p className="text-red-700">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File Drop Zone */}
|
||||
<div
|
||||
className={`relative border-2 border-dashed rounded-lg p-8 text-center transition-colors ${isDragging
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: selectedFile
|
||||
? 'border-gray-500 '
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{selectedFile ? (
|
||||
<div className="space-y-2">
|
||||
<svg className="mx-auto h-12 w-12 text-green-200" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="text-sm text-gray-900 font-medium">{selectedFile.name}</p>
|
||||
<p className="text-xs text-gray-500">{(selectedFile.size / 1024 / 1024).toFixed(2)} MB</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-sm text-gray-900">{t('dxt.dropFileHere')}</p>
|
||||
<p className="text-xs text-gray-500">{t('dxt.orClickToSelect')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept=".dxt"
|
||||
onChange={handleFileSelect}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end space-x-4">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={isUploading}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50 btn-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedFile || isUploading}
|
||||
className="px-4 py-2 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center btn-primary"
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{t('dxt.uploading')}
|
||||
</>
|
||||
) : (
|
||||
t('dxt.upload')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DxtUploadForm;
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Group, GroupFormData, Server } from '@/types'
|
||||
import { Group, GroupFormData, Server, IGroupServerConfig } from '@/types'
|
||||
import { useGroupData } from '@/hooks/useGroupData'
|
||||
import { useServerData } from '@/hooks/useServerData'
|
||||
import { ToggleGroup } from './ui/ToggleGroup'
|
||||
import { ServerToolConfig } from './ServerToolConfig'
|
||||
|
||||
interface EditGroupFormProps {
|
||||
group: Group
|
||||
@@ -38,18 +38,6 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
|
||||
}))
|
||||
}
|
||||
|
||||
const handleServerToggle = (serverName: string) => {
|
||||
setFormData(prev => {
|
||||
const isSelected = prev.servers.includes(serverName)
|
||||
return {
|
||||
...prev,
|
||||
servers: isSelected
|
||||
? prev.servers.filter(name => name !== serverName)
|
||||
: [...prev.servers, serverName]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
@@ -67,9 +55,9 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
|
||||
description: formData.description,
|
||||
servers: formData.servers
|
||||
})
|
||||
|
||||
if (!result) {
|
||||
setError(t('groups.updateError'))
|
||||
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('groups.updateError'))
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
@@ -83,64 +71,68 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
|
||||
<div className="p-6">
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-2xl w-full max-h-[90vh] flex flex-col">
|
||||
<div className="p-6 flex-shrink-0">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('groups.edit')}</h2>
|
||||
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded-md border border-gray-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="name">
|
||||
{t('groups.name')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
placeholder={t('groups.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ToggleGroup
|
||||
className="mb-6"
|
||||
label={t('groups.servers')}
|
||||
noOptionsText={t('groups.noServerOptions')}
|
||||
values={formData.servers}
|
||||
options={availableServers.map(server => ({
|
||||
value: server.name,
|
||||
label: server.name
|
||||
}))}
|
||||
onChange={(servers) => setFormData(prev => ({ ...prev, servers }))}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? t('common.submitting') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex-1 overflow-y-auto px-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="name">
|
||||
{t('groups.name')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder={t('groups.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2">
|
||||
{t('groups.configureTools')}
|
||||
</label>
|
||||
<ServerToolConfig
|
||||
servers={availableServers}
|
||||
value={formData.servers as IGroupServerConfig[]}
|
||||
onChange={(servers) => setFormData(prev => ({ ...prev, servers }))}
|
||||
className="border border-gray-200 rounded-lg p-4 bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 p-6 pt-4 border-t border-gray-200 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:opacity-50 transition-colors"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? t('common.submitting') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Server } from '@/types'
|
||||
import { getApiUrl } from '../utils/runtime'
|
||||
import { apiPut } from '../utils/fetchInterceptor'
|
||||
import ServerForm from './ServerForm'
|
||||
|
||||
interface EditServerFormProps {
|
||||
@@ -17,26 +17,12 @@ const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => {
|
||||
const handleSubmit = async (payload: any) => {
|
||||
try {
|
||||
setError(null)
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl(`/servers/${server.name}`), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || ''
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const result = await apiPut(`/servers/${server.name}`, payload)
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
if (!result.success) {
|
||||
// Use specific error message from the response if available
|
||||
if (result && result.message) {
|
||||
setError(result.message)
|
||||
} else if (response.status === 404) {
|
||||
setError(t('server.notFound', { serverName: server.name }))
|
||||
} else if (response.status === 400) {
|
||||
setError(t('server.invalidData'))
|
||||
} else {
|
||||
setError(t('server.updateError', { serverName: server.name }))
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Group, Server } from '@/types'
|
||||
import { Edit, Trash, Copy, Check } from '@/components/icons/LucideIcons'
|
||||
import { Group, Server, IGroupServerConfig } from '@/types'
|
||||
import { Edit, Trash, Copy, Check, Link, FileCode, DropdownIcon, Wrench } from '@/components/icons/LucideIcons'
|
||||
import DeleteDialog from '@/components/ui/DeleteDialog'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
import { useSettingsData } from '@/hooks/useSettingsData'
|
||||
|
||||
interface GroupCardProps {
|
||||
group: Group
|
||||
@@ -20,8 +21,26 @@ const GroupCard = ({
|
||||
}: GroupCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { showToast } = useToast()
|
||||
const { installConfig } = useSettingsData()
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [showCopyDropdown, setShowCopyDropdown] = useState(false)
|
||||
const [expandedServer, setExpandedServer] = useState<string | null>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setShowCopyDropdown(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleEdit = () => {
|
||||
onEdit(group)
|
||||
@@ -36,16 +55,18 @@ const GroupCard = ({
|
||||
setShowDeleteDialog(false)
|
||||
}
|
||||
|
||||
const copyToClipboard = () => {
|
||||
const copyToClipboard = (text: string) => {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(group.id).then(() => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true)
|
||||
setShowCopyDropdown(false)
|
||||
showToast(t('common.copySuccess'), 'success')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
} else {
|
||||
// Fallback for HTTP or unsupported clipboard API
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = group.id
|
||||
textArea.value = text
|
||||
// Avoid scrolling to bottom
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-9999px'
|
||||
@@ -55,6 +76,8 @@ const GroupCard = ({
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
setCopied(true)
|
||||
setShowCopyDropdown(false)
|
||||
showToast(t('common.copySuccess'), 'success')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (err) {
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error')
|
||||
@@ -64,24 +87,92 @@ const GroupCard = ({
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyId = () => {
|
||||
copyToClipboard(group.id)
|
||||
}
|
||||
|
||||
const handleCopyUrl = () => {
|
||||
copyToClipboard(`${installConfig.baseUrl}/mcp/${group.id}`)
|
||||
}
|
||||
|
||||
const handleCopyJson = () => {
|
||||
const jsonConfig = {
|
||||
mcpServers: {
|
||||
mcphub: {
|
||||
url: `${installConfig.baseUrl}/mcp/${group.id}`,
|
||||
headers: {
|
||||
Authorization: "Bearer <your-access-token>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
copyToClipboard(JSON.stringify(jsonConfig, null, 2))
|
||||
}
|
||||
|
||||
// Helper function to normalize group servers to get server names
|
||||
const getServerNames = (servers: string[] | IGroupServerConfig[]): string[] => {
|
||||
return servers.map(server => typeof server === 'string' ? server : server.name);
|
||||
};
|
||||
|
||||
// Helper function to get server configuration
|
||||
const getServerConfig = (serverName: string): IGroupServerConfig | undefined => {
|
||||
const server = group.servers.find(s =>
|
||||
typeof s === 'string' ? s === serverName : s.name === serverName
|
||||
);
|
||||
if (typeof server === 'string') {
|
||||
return { name: server, tools: 'all' };
|
||||
}
|
||||
return server;
|
||||
};
|
||||
|
||||
// Get servers that belong to this group
|
||||
const groupServers = servers.filter(server => group.servers.includes(server.name))
|
||||
const serverNames = getServerNames(group.servers);
|
||||
const groupServers = servers.filter(server => serverNames.includes(server.name));
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<div className="bg-white shadow rounded-lg p-6 ">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<h2 className="text-xl font-semibold text-gray-800">{group.name}</h2>
|
||||
<div className="flex items-center ml-3">
|
||||
<span className="text-xs text-gray-500 mr-1">{group.id}</span>
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
title={t('common.copy')}
|
||||
>
|
||||
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
||||
</button>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowCopyDropdown(!showCopyDropdown)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors flex items-center"
|
||||
title={t('common.copy')}
|
||||
>
|
||||
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
||||
<DropdownIcon size={12} className="ml-1" />
|
||||
</button>
|
||||
|
||||
{showCopyDropdown && (
|
||||
<div className="absolute top-full left-0 mt-1 bg-white shadow-lg rounded-md border border-gray-200 py-1 z-10 min-w-[140px]">
|
||||
<button
|
||||
onClick={handleCopyId}
|
||||
className="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center"
|
||||
>
|
||||
<Copy size={12} className="mr-2" />
|
||||
{t('common.copyId')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopyUrl}
|
||||
className="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center"
|
||||
>
|
||||
<Link size={12} className="mr-2" />
|
||||
{t('common.copyUrl')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopyJson}
|
||||
className="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center"
|
||||
>
|
||||
<FileCode size={12} className="mr-2" />
|
||||
{t('common.copyJson')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{group.description && (
|
||||
@@ -89,7 +180,7 @@ const GroupCard = ({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-blue-50 text-blue-700 px-3 py-1 rounded-full text-sm">
|
||||
<div className="bg-blue-50 text-blue-700 px-3 py-1 rounded-full text-sm btn-secondary">
|
||||
{t('groups.serverCount', { count: group.servers.length })}
|
||||
</div>
|
||||
<button
|
||||
@@ -113,18 +204,68 @@ const GroupCard = ({
|
||||
{groupServers.length === 0 ? (
|
||||
<p className="text-gray-500 italic">{t('groups.noServers')}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{groupServers.map(server => (
|
||||
<div
|
||||
key={server.name}
|
||||
className="inline-flex items-center px-3 py-1 bg-gray-50 rounded"
|
||||
>
|
||||
<span className="font-medium text-gray-700 text-sm">{server.name}</span>
|
||||
<span className={`ml-2 inline-block h-2 w-2 rounded-full ${server.status === 'connected' ? 'bg-green-500' :
|
||||
server.status === 'connecting' ? 'bg-yellow-500' : 'bg-red-500'
|
||||
}`}></span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{groupServers.map(server => {
|
||||
const serverConfig = getServerConfig(server.name);
|
||||
const hasToolRestrictions = serverConfig && serverConfig.tools !== 'all' && Array.isArray(serverConfig.tools);
|
||||
const toolCount = hasToolRestrictions && Array.isArray(serverConfig?.tools)
|
||||
? serverConfig.tools.length
|
||||
: (server.tools?.length || 0); // Show total tool count when all tools are selected
|
||||
|
||||
const isExpanded = expandedServer === server.name;
|
||||
|
||||
// Get tools list for display
|
||||
const getToolsList = () => {
|
||||
if (hasToolRestrictions && Array.isArray(serverConfig?.tools)) {
|
||||
return serverConfig.tools;
|
||||
} else if (server.tools && server.tools.length > 0) {
|
||||
return server.tools.map(tool => tool.name);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const handleServerClick = () => {
|
||||
setExpandedServer(isExpanded ? null : server.name);
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={server.name} className="relative">
|
||||
<div
|
||||
className="flex items-center space-x-2 bg-gray-50 rounded-lg px-3 py-2 cursor-pointer hover:bg-gray-100 transition-colors"
|
||||
onClick={handleServerClick}
|
||||
>
|
||||
<span className="font-medium text-gray-700 text-sm">{server.name}</span>
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${server.status === 'connected' ? 'bg-green-500' :
|
||||
server.status === 'connecting' ? 'bg-yellow-500' : 'bg-red-500'
|
||||
}`}></span>
|
||||
{toolCount > 0 && (
|
||||
<span className="text-xs text-blue-600 bg-blue-100 px-2 py-0.5 rounded flex items-center gap-1">
|
||||
<Wrench size={12} />
|
||||
{toolCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="absolute top-full left-0 mt-1 bg-white shadow-lg rounded-md border border-gray-200 p-3 z-10 min-w-[300px] max-w-[400px]">
|
||||
<div className="text-gray-600 text-xs mb-2">
|
||||
{hasToolRestrictions ? t('groups.selectedTools') : t('groups.allTools')}:
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{getToolsList().map((toolName, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-block bg-gray-100 text-gray-700 px-2 py-1 rounded text-xs"
|
||||
>
|
||||
{toolName}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -48,25 +48,26 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
|
||||
// Get badge color based on log type
|
||||
const getLogTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'error': return 'bg-red-400';
|
||||
case 'warn': return 'bg-yellow-400';
|
||||
case 'debug': return 'bg-purple-400';
|
||||
default: return 'bg-blue-400';
|
||||
case 'error': return 'bg-red-400/80 text-white';
|
||||
case 'warn': return 'bg-yellow-400/80 text-gray-900';
|
||||
case 'debug': return 'bg-purple-400/80 text-white';
|
||||
case 'info': return 'bg-blue-400/80 text-white';
|
||||
default: return 'bg-blue-400/80 text-white';
|
||||
}
|
||||
};
|
||||
|
||||
// Get badge color based on log source
|
||||
const getSourceColor = (source: string) => {
|
||||
switch (source) {
|
||||
case 'main': return 'bg-green-400';
|
||||
case 'child': return 'bg-orange-400';
|
||||
default: return 'bg-gray-400';
|
||||
case 'main': return 'bg-green-400/80 text-white';
|
||||
case 'child': return 'bg-orange-400/80 text-white';
|
||||
default: return 'bg-gray-400/80 text-white';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="bg-card p-3 rounded-t-md border-b flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="bg-card p-3 rounded-t-md flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-sm">{t('logs.filters')}:</span>
|
||||
|
||||
@@ -74,14 +75,14 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('logs.search')}
|
||||
className="px-2 py-1 text-sm border rounded"
|
||||
className="shadow appearance-none border border-gray-200 rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Log type filters */}
|
||||
<div className="flex gap-1 items-center">
|
||||
{(['info', 'error', 'warn', 'debug'] as const).map(type => (
|
||||
{(['debug', 'info', 'error', 'warn'] as const).map(type => (
|
||||
<Badge
|
||||
key={type}
|
||||
variant={typeFilter.includes(type) ? 'default' : 'outline'}
|
||||
@@ -134,6 +135,7 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClear}
|
||||
className='btn-secondary'
|
||||
disabled={isLoading || logs.length === 0}
|
||||
>
|
||||
{t('logs.clearLogs')}
|
||||
@@ -164,7 +166,7 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
|
||||
filteredLogs.map((log, index) => (
|
||||
<div
|
||||
key={`${log.timestamp}-${index}`}
|
||||
className={`py-1 border-b border-gray-100 dark:border-gray-800 ${log.type === 'error' ? 'text-red-500' :
|
||||
className={`py-1 ${log.type === 'error' ? 'text-red-500' :
|
||||
log.type === 'warn' ? 'text-yellow-500' : ''
|
||||
}`}
|
||||
>
|
||||
|
||||
92
frontend/src/components/MCPRouterApiKeyError.tsx
Normal file
92
frontend/src/components/MCPRouterApiKeyError.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const MCPRouterApiKeyError: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleConfigureSettings = () => {
|
||||
navigate('/settings');
|
||||
};
|
||||
|
||||
const handleGetApiKey = () => {
|
||||
window.open('https://mcprouter.co', '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<svg
|
||||
className="h-5 w-5 text-amber-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3 flex-1">
|
||||
<h3 className="text-sm font-medium text-amber-800">
|
||||
{t('cloud.apiKeyNotConfigured')}
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-amber-700">
|
||||
<p>{t('cloud.apiKeyNotConfiguredDescription')}</p>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={handleGetApiKey}
|
||||
className="inline-flex items-center px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors duration-200"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-1.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
{t('cloud.getApiKey')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfigureSettings}
|
||||
className="inline-flex items-center px-3 py-2 text-sm font-medium text-amber-800 bg-amber-100 border border-amber-300 rounded-md hover:bg-amber-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-amber-500 transition-colors duration-200"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-1.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
{t('cloud.configureInSettings')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MCPRouterApiKeyError;
|
||||
@@ -10,36 +10,46 @@ interface MarketServerCardProps {
|
||||
const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Get initials for avatar
|
||||
const getAuthorInitials = (name: string) => {
|
||||
return name
|
||||
.split(' ')
|
||||
.map(word => word.charAt(0))
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
// Intelligently calculate how many tags to display to ensure they fit in a single line
|
||||
const getTagsToDisplay = () => {
|
||||
if (!server.tags || server.tags.length === 0) {
|
||||
return { tagsToShow: [], hasMore: false, moreCount: 0 };
|
||||
}
|
||||
|
||||
|
||||
// Estimate available width in the card (in characters)
|
||||
const estimatedAvailableWidth = 28; // Estimated number of characters that can fit in one line
|
||||
|
||||
|
||||
// Calculate the character space needed for tags and plus sign (including # and spacing)
|
||||
const calculateTagWidth = (tag: string) => tag.length + 3; // +3 for # and spacing
|
||||
|
||||
|
||||
// Loop to determine the maximum number of tags that can be displayed
|
||||
let totalWidth = 0;
|
||||
let i = 0;
|
||||
|
||||
|
||||
// First, sort tags by length to prioritize displaying shorter tags
|
||||
const sortedTags = [...server.tags].sort((a, b) => a.length - b.length);
|
||||
|
||||
|
||||
// Calculate how many tags can fit
|
||||
for (i = 0; i < sortedTags.length; i++) {
|
||||
const tagWidth = calculateTagWidth(sortedTags[i]);
|
||||
|
||||
|
||||
// If this tag would make the total width exceed available width, stop adding
|
||||
if (totalWidth + tagWidth > estimatedAvailableWidth) {
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
totalWidth += tagWidth;
|
||||
|
||||
|
||||
// If this is the last tag but there's still space, no need to show "more"
|
||||
if (i === sortedTags.length - 1) {
|
||||
return {
|
||||
@@ -49,16 +59,16 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If there's not enough space to display any tags, show at least one
|
||||
if (i === 0 && sortedTags.length > 0) {
|
||||
i = 1;
|
||||
}
|
||||
|
||||
|
||||
// Calculate space needed for the "more" tag
|
||||
const moreCount = sortedTags.length - i;
|
||||
const moreTagWidth = 3 + String(moreCount).length + t('market.moreTags').length;
|
||||
|
||||
|
||||
// If there's enough remaining space to display the "more" tag
|
||||
if (totalWidth + moreTagWidth <= estimatedAvailableWidth || i < 1) {
|
||||
return {
|
||||
@@ -67,7 +77,7 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
|
||||
moreCount
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// If there's not enough space for even the "more" tag, reduce one tag to make room
|
||||
return {
|
||||
tagsToShow: sortedTags.slice(0, Math.max(1, i - 1)),
|
||||
@@ -79,71 +89,90 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
|
||||
const { tagsToShow, hasMore, moreCount } = getTagsToDisplay();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-md p-5 hover:shadow-lg transition-shadow cursor-pointer flex flex-col h-full"
|
||||
<div
|
||||
className="bg-white border border-gray-200 rounded-xl p-4 hover:shadow-lg hover:border-blue-400 hover:-translate-y-1 transition-all duration-300 cursor-pointer group relative overflow-hidden h-full flex flex-col"
|
||||
onClick={() => onClick(server)}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900 line-clamp-1 mr-2">{server.display_name}</h3>
|
||||
{server.is_official && (
|
||||
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded flex-shrink-0">
|
||||
{t('market.official')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm mb-4 line-clamp-2 min-h-[40px]">{server.description}</p>
|
||||
|
||||
{/* Categories */}
|
||||
<div className="flex flex-wrap gap-1 mb-2 min-h-[28px]">
|
||||
{server.categories?.length > 0 ? (
|
||||
server.categories.map((category, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="bg-gray-100 text-gray-800 text-xs px-2 py-1 rounded whitespace-nowrap"
|
||||
>
|
||||
{category}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 py-1">-</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="relative mb-3 min-h-[28px] overflow-x-auto">
|
||||
{server.tags?.length > 0 ? (
|
||||
<div className="flex gap-1 items-center whitespace-nowrap">
|
||||
{tagsToShow.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="bg-green-50 text-green-700 text-xs px-2 py-1 rounded flex-shrink-0"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
{hasMore && (
|
||||
<span className="bg-gray-100 text-gray-600 text-xs px-1.5 py-1 rounded flex-shrink-0">
|
||||
+{moreCount} {t('market.moreTags')}
|
||||
{/* Background gradient overlay on hover */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-50/0 to-purple-50/0 group-hover:from-blue-50/30 group-hover:to-purple-50/30 transition-all duration-300 pointer-events-none" />
|
||||
|
||||
{/* Server Header */}
|
||||
<div className="relative z-10 flex-1 flex flex-col">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-bold text-gray-900 group-hover:text-blue-600 transition-colors duration-200 mb-1 line-clamp-1 mr-2">
|
||||
{server.display_name}
|
||||
</h3>
|
||||
|
||||
{/* Author Section */}
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<div className="w-6 h-6 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center text-white text-xs font-semibold">
|
||||
{getAuthorInitials(server.author?.name || t('market.unknown'))}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-700">{server.author?.name || t('market.unknown')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server Type Badge */}
|
||||
<div className="flex flex-col items-end space-y-2">
|
||||
{server.is_official && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{t('market.official')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 py-1">-</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mt-auto pt-2 text-xs text-gray-500 border-t border-gray-100">
|
||||
<div className="overflow-hidden">
|
||||
<span className="whitespace-nowrap">{t('market.by')} </span>
|
||||
<span className="font-medium whitespace-nowrap overflow-hidden text-ellipsis max-w-[120px] inline-block align-bottom">
|
||||
{server.author?.name || t('market.unknown')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center flex-shrink-0">
|
||||
<svg className="h-4 w-4 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>{server.tools?.length || 0} {t('market.tools')}</span>
|
||||
|
||||
{/* Description */}
|
||||
<div className="mb-2 flex-1">
|
||||
<p className="text-gray-600 text-sm leading-relaxed line-clamp-2 min-h-[36px]">
|
||||
{server.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<div className="mb-2">
|
||||
<div className="flex flex-wrap gap-1 min-h-[24px]">
|
||||
{server.categories?.length > 0 ? (
|
||||
server.categories.map((category, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="bg-gray-100 text-gray-800 text-xs px-2 py-1 rounded whitespace-nowrap"
|
||||
>
|
||||
{category}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 py-1">-</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="mb-2">
|
||||
<div className="relative min-h-[24px] overflow-x-auto">
|
||||
{server.tags?.length > 0 ? (
|
||||
<div className="flex gap-1 items-center whitespace-nowrap">
|
||||
{tagsToShow.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="bg-green-50 text-green-700 text-xs px-2 py-1 rounded flex-shrink-0"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
{hasMore && (
|
||||
<span className="bg-gray-100 text-gray-600 text-xs px-1.5 py-1 rounded flex-shrink-0">
|
||||
+{moreCount} {t('market.moreTags')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 py-1">-</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MarketServer, MarketServerInstallation } from '@/types';
|
||||
import ServerForm from './ServerForm';
|
||||
import { detectVariables } from '../utils/variableDetection';
|
||||
|
||||
import { ServerConfig } from '@/types';
|
||||
|
||||
@@ -23,6 +24,9 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmationVisible, setConfirmationVisible] = useState(false);
|
||||
const [pendingPayload, setPendingPayload] = useState<any>(null);
|
||||
const [detectedVariables, setDetectedVariables] = useState<string[]>([]);
|
||||
|
||||
// Helper function to determine button state
|
||||
const getButtonProps = () => {
|
||||
@@ -40,7 +44,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
className: "bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white",
|
||||
className: "bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white btn-primary",
|
||||
disabled: false,
|
||||
text: t('market.install')
|
||||
};
|
||||
@@ -50,6 +54,27 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
const toggleModal = () => {
|
||||
setModalVisible(!modalVisible);
|
||||
setError(null); // Clear any previous errors when toggling modal
|
||||
setConfirmationVisible(false);
|
||||
setPendingPayload(null);
|
||||
};
|
||||
|
||||
const handleConfirmInstall = async () => {
|
||||
if (pendingPayload) {
|
||||
await proceedWithInstall(pendingPayload);
|
||||
setConfirmationVisible(false);
|
||||
setPendingPayload(null);
|
||||
}
|
||||
};
|
||||
|
||||
const proceedWithInstall = async (payload: any) => {
|
||||
try {
|
||||
setError(null);
|
||||
onInstall(server, payload.config);
|
||||
setModalVisible(false);
|
||||
} catch (err) {
|
||||
console.error('Error installing server:', err);
|
||||
setError(t('errors.serverInstall'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstall = () => {
|
||||
@@ -72,24 +97,32 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
} else if (server.installations.default) {
|
||||
return server.installations.default;
|
||||
}
|
||||
|
||||
|
||||
// If none of the preferred types are available, get the first available installation type
|
||||
const installTypes = Object.keys(server.installations);
|
||||
if (installTypes.length > 0) {
|
||||
return server.installations[installTypes[0]];
|
||||
}
|
||||
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const handleSubmit = async (payload: any) => {
|
||||
try {
|
||||
setError(null);
|
||||
// Pass the server object and the payload (includes env changes) for installation
|
||||
onInstall(server, payload.config);
|
||||
setModalVisible(false);
|
||||
// Check for variables in the payload
|
||||
const variables = detectVariables(payload);
|
||||
|
||||
if (variables.length > 0) {
|
||||
// Show confirmation dialog
|
||||
setDetectedVariables(variables);
|
||||
setPendingPayload(payload);
|
||||
setConfirmationVisible(true);
|
||||
} else {
|
||||
// Install directly if no variables found
|
||||
await proceedWithInstall(payload);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error installing server:', err);
|
||||
console.error('Error processing server installation:', err);
|
||||
setError(t('errors.serverInstall'));
|
||||
}
|
||||
};
|
||||
@@ -114,15 +147,15 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 flex items-center flex-wrap">
|
||||
{server.display_name}
|
||||
{server.display_name}
|
||||
<span className="text-sm font-normal text-gray-500 ml-2">({server.name})</span>
|
||||
<span className="text-sm font-normal text-gray-600 ml-4">
|
||||
{t('market.author')}: {server.author.name} • {t('market.license')}: {server.license} •
|
||||
{t('market.author')}: {server.author.name} • {t('market.license')}: {server.license} •
|
||||
<a
|
||||
href={server.repository.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline ml-1"
|
||||
className="text-blue-500 hover:underline ml-1"
|
||||
>
|
||||
{t('market.repository')}
|
||||
</a>
|
||||
@@ -132,7 +165,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
|
||||
<div className="flex items-center">
|
||||
{server.is_official && (
|
||||
<span className="bg-blue-100 text-blue-800 text-sm font-medium px-4 py-2 rounded mr-2 flex items-center">
|
||||
<span className="bg-blue-100 text-blue-800 text-sm font-normal px-4 py-2 rounded mr-2 flex items-center label-primary">
|
||||
{t('market.official')}
|
||||
</span>
|
||||
)}
|
||||
@@ -169,7 +202,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
<h3 className="text-lg font-semibold mb-3">{t('market.arguments')}</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
|
||||
{t('market.argumentName')}
|
||||
@@ -198,7 +231,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
{arg.required ? (
|
||||
<span className="text-green-600">✓</span>
|
||||
) : (
|
||||
<span className="text-red-600">✗</span>
|
||||
<span className="text-gray-600">✗</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
@@ -228,7 +261,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
element.classList.toggle('hidden');
|
||||
}
|
||||
}}
|
||||
className="text-sm text-blue-600 hover:underline focus:outline-none ml-2"
|
||||
className="text-sm text-blue-500 font-normal hover:underline focus:outline-none ml-2"
|
||||
>
|
||||
{t('market.viewSchema')}
|
||||
</button>
|
||||
@@ -281,17 +314,71 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
initialData={{
|
||||
name: server.name,
|
||||
status: 'disconnected',
|
||||
config: preferredInstallation
|
||||
config: preferredInstallation
|
||||
? {
|
||||
command: preferredInstallation.command || '',
|
||||
args: preferredInstallation.args || [],
|
||||
env: preferredInstallation.env || {}
|
||||
}
|
||||
command: preferredInstallation.command || '',
|
||||
args: preferredInstallation.args || [],
|
||||
env: preferredInstallation.env || {}
|
||||
}
|
||||
: undefined
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmationVisible && (
|
||||
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
{t('server.confirmVariables')}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{t('server.variablesDetected')}
|
||||
</p>
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 mb-4">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h4 className="text-sm font-medium text-yellow-800">
|
||||
{t('server.detectedVariables')}:
|
||||
</h4>
|
||||
<ul className="mt-1 text-sm text-yellow-700">
|
||||
{detectedVariables.map((variable, index) => (
|
||||
<li key={index} className="font-mono">
|
||||
${`{${variable}}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm mb-6">
|
||||
{t('market.confirmVariablesMessage')}
|
||||
</p>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setConfirmationVisible(false)
|
||||
setPendingPayload(null)
|
||||
}}
|
||||
className="px-4 py-2 text-gray-600 border border-gray-300 rounded hover:bg-gray-50 btn-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmInstall}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 btn-primary"
|
||||
>
|
||||
{t('market.confirmAndInstall')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
95
frontend/src/components/PermissionChecker.tsx
Normal file
95
frontend/src/components/PermissionChecker.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
interface PermissionCheckerProps {
|
||||
permissions: string | string[];
|
||||
fallback?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission checker component for conditional rendering
|
||||
* @param permissions Required permissions, supports single permission string or permission array
|
||||
* @param fallback Content to show when permission is denied, defaults to null
|
||||
* @param children Content to show when permission is granted
|
||||
*/
|
||||
export const PermissionChecker: React.FC<PermissionCheckerProps> = ({
|
||||
permissions,
|
||||
fallback = null,
|
||||
children,
|
||||
}) => {
|
||||
const hasPermission = usePermissionCheck(permissions);
|
||||
|
||||
return hasPermission ? <>{children}</> : <>{fallback}</>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Permission check hook
|
||||
* @param requiredPermissions Permissions to check
|
||||
* @returns Whether user has permission
|
||||
*/
|
||||
export const usePermissionCheck = (requiredPermissions: string | string[]): boolean => {
|
||||
const { auth } = useAuth();
|
||||
|
||||
if (!auth.isAuthenticated || !auth.user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const userPermissions = auth.user.permissions || [];
|
||||
|
||||
if (requiredPermissions === 'x' && !userPermissions.includes('x')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If user has '*' permission, they have all permissions
|
||||
if (userPermissions.includes('*')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If user is admin, they have all permissions by default
|
||||
if (auth.user.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Normalize required permissions to array
|
||||
const permissionsToCheck = Array.isArray(requiredPermissions)
|
||||
? requiredPermissions
|
||||
: [requiredPermissions];
|
||||
|
||||
// Check if user has any of the required permissions
|
||||
return permissionsToCheck.some(permission =>
|
||||
userPermissions.includes(permission)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Permission check hook - requires all permissions
|
||||
* @param requiredPermissions Array of permissions to check
|
||||
* @returns Whether user has all permissions
|
||||
*/
|
||||
export const usePermissionCheckAll = (requiredPermissions: string[]): boolean => {
|
||||
const { auth } = useAuth();
|
||||
|
||||
if (!auth.isAuthenticated || !auth.user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const userPermissions = auth.user.permissions || [];
|
||||
|
||||
// If user has '*' permission, they have all permissions
|
||||
if (userPermissions.includes('*')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If user is admin, they have all permissions by default
|
||||
if (auth.user.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if user has all required permissions
|
||||
return requiredPermissions.every(permission =>
|
||||
userPermissions.includes(permission)
|
||||
);
|
||||
};
|
||||
|
||||
export default PermissionChecker;
|
||||
@@ -7,20 +7,20 @@ interface ProtectedRouteProps {
|
||||
redirectPath?: string;
|
||||
}
|
||||
|
||||
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||
redirectPath = '/login'
|
||||
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||
redirectPath = '/login'
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { auth } = useAuth();
|
||||
|
||||
|
||||
if (auth.loading) {
|
||||
return <div className="flex items-center justify-center h-screen">{t('app.loading')}</div>;
|
||||
}
|
||||
|
||||
|
||||
if (!auth.isAuthenticated) {
|
||||
return <Navigate to={redirectPath} replace />;
|
||||
}
|
||||
|
||||
|
||||
return <Outlet />;
|
||||
};
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`bg-white shadow rounded-lg p-6 mb-6 ${server.enabled === false ? 'opacity-60' : ''}`}>
|
||||
<div className={`bg-white shadow rounded-lg p-6 mb-6 page-card transition-all duration-200 ${server.enabled === false ? 'opacity-60' : ''}`}>
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
@@ -138,7 +138,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
<StatusBadge status={server.status} />
|
||||
|
||||
{/* Tool count display */}
|
||||
<div className="flex items-center px-2 py-1 bg-blue-50 text-blue-700 rounded-full text-sm">
|
||||
<div className="flex items-center px-2 py-1 bg-blue-50 text-blue-700 rounded-full text-sm btn-primary">
|
||||
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
|
||||
</svg>
|
||||
@@ -174,7 +174,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
<h4 className="text-sm font-medium text-red-600">{t('server.errorDetails')}</h4>
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors btn-secondary"
|
||||
title={t('common.copy')}
|
||||
>
|
||||
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
||||
@@ -201,7 +201,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className="px-3 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm"
|
||||
className="px-3 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary"
|
||||
>
|
||||
{t('server.edit')}
|
||||
</button>
|
||||
@@ -211,8 +211,8 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
className={`px-3 py-1 text-sm rounded transition-colors ${isToggling
|
||||
? 'bg-gray-200 text-gray-500'
|
||||
: server.enabled !== false
|
||||
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
||||
? 'bg-green-100 text-green-800 hover:bg-green-200 btn-secondary'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-primary'
|
||||
}`}
|
||||
disabled={isToggling}
|
||||
>
|
||||
@@ -226,11 +226,11 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
className="px-3 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm"
|
||||
className="px-3 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm btn-danger"
|
||||
>
|
||||
{t('server.delete')}
|
||||
</button>
|
||||
<button className="text-gray-400 hover:text-gray-600">
|
||||
<button className="text-gray-400 hover:text-gray-600 btn-secondary">
|
||||
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -286,7 +286,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={handleInputChange}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
placeholder="e.g.: time-mcp"
|
||||
required
|
||||
disabled={isEdit}
|
||||
@@ -403,7 +403,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
...prev,
|
||||
openapi: { ...prev.openapi!, url: e.target.value }
|
||||
}))}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
placeholder="e.g.: https://api.example.com/openapi.json"
|
||||
required={serverType === 'openapi' && formData.openapi?.inputMode === 'url'}
|
||||
/>
|
||||
@@ -462,7 +462,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
url: prev.openapi?.url || ''
|
||||
}
|
||||
}))}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
>
|
||||
<option value="none">{t('server.openapi.securityNone')}</option>
|
||||
<option value="apiKey">{t('server.openapi.securityApiKey')}</option>
|
||||
@@ -474,7 +474,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
|
||||
{/* API Key Configuration */}
|
||||
{formData.openapi?.securityType === 'apiKey' && (
|
||||
<div className="mb-4 p-4 border rounded bg-gray-50">
|
||||
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.apiKeyConfig')}</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
@@ -486,7 +486,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
...prev,
|
||||
openapi: { ...prev.openapi, apiKeyName: e.target.value, url: prev.openapi?.url || '' }
|
||||
}))}
|
||||
className="w-full border rounded px-2 py-1 text-sm"
|
||||
className="w-full border rounded px-2 py-1 text-sm form-input focus:outline-none"
|
||||
placeholder="Authorization"
|
||||
/>
|
||||
</div>
|
||||
@@ -498,7 +498,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
...prev,
|
||||
openapi: { ...prev.openapi, apiKeyIn: e.target.value as any, url: prev.openapi?.url || '' }
|
||||
}))}
|
||||
className="w-full border rounded px-2 py-1 text-sm"
|
||||
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
|
||||
>
|
||||
<option value="header">Header</option>
|
||||
<option value="query">Query</option>
|
||||
@@ -514,7 +514,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
...prev,
|
||||
openapi: { ...prev.openapi, apiKeyValue: e.target.value, url: prev.openapi?.url || '' }
|
||||
}))}
|
||||
className="w-full border rounded px-2 py-1 text-sm"
|
||||
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
|
||||
placeholder="your-api-key"
|
||||
/>
|
||||
</div>
|
||||
@@ -524,7 +524,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
|
||||
{/* HTTP Authentication Configuration */}
|
||||
{formData.openapi?.securityType === 'http' && (
|
||||
<div className="mb-4 p-4 border rounded bg-gray-50">
|
||||
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.httpAuthConfig')}</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
@@ -535,7 +535,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
...prev,
|
||||
openapi: { ...prev.openapi, httpScheme: e.target.value as any, url: prev.openapi?.url || '' }
|
||||
}))}
|
||||
className="w-full border rounded px-2 py-1 text-sm"
|
||||
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
|
||||
>
|
||||
<option value="basic">Basic</option>
|
||||
<option value="bearer">Bearer</option>
|
||||
@@ -551,7 +551,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
...prev,
|
||||
openapi: { ...prev.openapi, httpCredentials: e.target.value, url: prev.openapi?.url || '' }
|
||||
}))}
|
||||
className="w-full border rounded px-2 py-1 text-sm"
|
||||
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
|
||||
placeholder={formData.openapi?.httpScheme === 'basic' ? 'base64-encoded-credentials' : 'bearer-token'}
|
||||
/>
|
||||
</div>
|
||||
@@ -561,7 +561,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
|
||||
{/* OAuth2 Configuration */}
|
||||
{formData.openapi?.securityType === 'oauth2' && (
|
||||
<div className="mb-4 p-4 border rounded bg-gray-50">
|
||||
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.oauth2Config')}</h4>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
@@ -573,7 +573,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
...prev,
|
||||
openapi: { ...prev.openapi, oauth2Token: e.target.value, url: prev.openapi?.url || '' }
|
||||
}))}
|
||||
className="w-full border rounded px-2 py-1 text-sm"
|
||||
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
|
||||
placeholder="access-token"
|
||||
/>
|
||||
</div>
|
||||
@@ -583,7 +583,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
|
||||
{/* OpenID Connect Configuration */}
|
||||
{formData.openapi?.securityType === 'openIdConnect' && (
|
||||
<div className="mb-4 p-4 border rounded bg-gray-50">
|
||||
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.openIdConnectConfig')}</h4>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
@@ -595,7 +595,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
...prev,
|
||||
openapi: { ...prev.openapi, openIdConnectUrl: e.target.value, url: prev.openapi?.url || '' }
|
||||
}))}
|
||||
className="w-full border rounded px-2 py-1 text-sm"
|
||||
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
|
||||
placeholder="https://example.com/.well-known/openid_configuration"
|
||||
/>
|
||||
</div>
|
||||
@@ -608,13 +608,56 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
...prev,
|
||||
openapi: { ...prev.openapi, openIdConnectToken: e.target.value, url: prev.openapi?.url || '' }
|
||||
}))}
|
||||
className="w-full border rounded px-2 py-1 text-sm"
|
||||
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
|
||||
placeholder="id-token"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<label className="block text-gray-700 text-sm font-bold">
|
||||
{t('server.headers')}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addHeaderVar}
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] btn-primary"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{headerVars.map((headerVar, index) => (
|
||||
<div key={index} className="flex items-center mb-2">
|
||||
<div className="flex items-center space-x-2 flex-grow">
|
||||
<input
|
||||
type="text"
|
||||
value={headerVar.key}
|
||||
onChange={(e) => handleHeaderVarChange(index, 'key', e.target.value)}
|
||||
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
|
||||
placeholder="Authorization"
|
||||
/>
|
||||
<span className="flex items-center">:</span>
|
||||
<input
|
||||
type="text"
|
||||
value={headerVar.value}
|
||||
onChange={(e) => handleHeaderVarChange(index, 'value', e.target.value)}
|
||||
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
|
||||
placeholder="Bearer token..."
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeHeaderVar(index)}
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] ml-2 btn-danger"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : serverType === 'sse' || serverType === 'streamable-http' ? (
|
||||
<>
|
||||
@@ -628,7 +671,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
id="url"
|
||||
value={formData.url}
|
||||
onChange={handleInputChange}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
placeholder={serverType === 'streamable-http' ? "e.g.: http://localhost:3000/mcp" : "e.g.: http://localhost:3000/sse"}
|
||||
required={serverType === 'sse' || serverType === 'streamable-http'}
|
||||
/>
|
||||
@@ -642,9 +685,9 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
<button
|
||||
type="button"
|
||||
onClick={addHeaderVar}
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center"
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] btn-primary"
|
||||
>
|
||||
+ {t('server.add')}
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{headerVars.map((headerVar, index) => (
|
||||
@@ -654,7 +697,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
type="text"
|
||||
value={headerVar.key}
|
||||
onChange={(e) => handleHeaderVarChange(index, 'key', e.target.value)}
|
||||
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2"
|
||||
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
|
||||
placeholder="Authorization"
|
||||
/>
|
||||
<span className="flex items-center">:</span>
|
||||
@@ -662,16 +705,16 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
type="text"
|
||||
value={headerVar.value}
|
||||
onChange={(e) => handleHeaderVarChange(index, 'value', e.target.value)}
|
||||
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2"
|
||||
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
|
||||
placeholder="Bearer token..."
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeHeaderVar(index)}
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[56px] ml-2"
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] ml-2 btn-danger"
|
||||
>
|
||||
- {t('server.remove')}
|
||||
-
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -689,7 +732,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
id="command"
|
||||
value={formData.command}
|
||||
onChange={handleInputChange}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
placeholder="e.g.: npx"
|
||||
required={serverType === 'stdio'}
|
||||
/>
|
||||
@@ -704,7 +747,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
id="arguments"
|
||||
value={formData.arguments}
|
||||
onChange={(e) => handleArgsChange(e.target.value)}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
placeholder="e.g.: -y time-mcp"
|
||||
required={serverType === 'stdio'}
|
||||
/>
|
||||
@@ -718,9 +761,9 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
<button
|
||||
type="button"
|
||||
onClick={addEnvVar}
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center"
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] btn-primary"
|
||||
>
|
||||
+ {t('server.add')}
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{envVars.map((envVar, index) => (
|
||||
@@ -730,7 +773,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
type="text"
|
||||
value={envVar.key}
|
||||
onChange={(e) => handleEnvVarChange(index, 'key', e.target.value)}
|
||||
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2"
|
||||
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
|
||||
placeholder={t('server.key')}
|
||||
/>
|
||||
<span className="flex items-center">:</span>
|
||||
@@ -738,16 +781,16 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
type="text"
|
||||
value={envVar.value}
|
||||
onChange={(e) => handleEnvVarChange(index, 'value', e.target.value)}
|
||||
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2"
|
||||
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
|
||||
placeholder={t('server.value')}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeEnvVar(index)}
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[56px] ml-2"
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] ml-2 btn-danger"
|
||||
>
|
||||
- {t('server.remove')}
|
||||
-
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -759,7 +802,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
{serverType !== 'openapi' && (
|
||||
<div className="mb-4">
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer bg-gray-50 hover:bg-gray-100 p-3 rounded border"
|
||||
className="flex items-center justify-between cursor-pointer bg-gray-50 hover:bg-gray-100 p-3 rounded border border-gray-200"
|
||||
onClick={() => setIsRequestOptionsExpanded(!isRequestOptionsExpanded)}
|
||||
>
|
||||
<label className="text-gray-700 text-sm font-bold">
|
||||
@@ -771,7 +814,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
</div>
|
||||
|
||||
{isRequestOptionsExpanded && (
|
||||
<div className="border rounded-b p-4 bg-gray-50 border-t-0">
|
||||
<div className="border border-gray-200 rounded-b p-4 bg-gray-50 border-t-0">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-gray-600 text-sm font-medium mb-1" htmlFor="timeout">
|
||||
@@ -782,7 +825,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
id="timeout"
|
||||
value={formData.options?.timeout || 60000}
|
||||
onChange={(e) => handleOptionsChange('timeout', parseInt(e.target.value) || 60000)}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
placeholder="30000"
|
||||
min="1000"
|
||||
max="300000"
|
||||
@@ -799,7 +842,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
id="maxTotalTimeout"
|
||||
value={formData.options?.maxTotalTimeout || ''}
|
||||
onChange={(e) => handleOptionsChange('maxTotalTimeout', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
placeholder="Optional"
|
||||
min="1000"
|
||||
/>
|
||||
@@ -830,13 +873,13 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="bg-gray-300 hover:bg-gray-400 text-gray-800 font-medium py-2 px-4 rounded mr-2"
|
||||
className="bg-gray-300 hover:bg-gray-400 text-gray-800 font-medium py-2 px-4 rounded mr-2 btn-secondary"
|
||||
>
|
||||
{t('server.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded"
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded btn-primary"
|
||||
>
|
||||
{isEdit ? t('server.save') : t('server.add')}
|
||||
</button>
|
||||
|
||||
317
frontend/src/components/ServerToolConfig.tsx
Normal file
317
frontend/src/components/ServerToolConfig.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IGroupServerConfig, Server, Tool } from '@/types';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
interface ServerToolConfigProps {
|
||||
servers: Server[];
|
||||
value: string[] | IGroupServerConfig[];
|
||||
onChange: (value: IGroupServerConfig[]) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ServerToolConfig: React.FC<ServerToolConfigProps> = ({
|
||||
servers,
|
||||
value,
|
||||
onChange,
|
||||
className
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
|
||||
|
||||
// Normalize current value to IGroupServerConfig[] format
|
||||
const normalizedValue: IGroupServerConfig[] = React.useMemo(() => {
|
||||
return value.map(item => {
|
||||
if (typeof item === 'string') {
|
||||
return { name: item, tools: 'all' as const };
|
||||
}
|
||||
return { ...item, tools: item.tools || 'all' as const };
|
||||
});
|
||||
}, [value]);
|
||||
|
||||
// Get available servers (enabled only)
|
||||
const availableServers = React.useMemo(() =>
|
||||
servers.filter(server => server.enabled !== false),
|
||||
[servers]
|
||||
);
|
||||
|
||||
// Clean up expanded servers when servers are removed from configuration
|
||||
// But keep servers that were explicitly expanded even if they have no configuration
|
||||
React.useEffect(() => {
|
||||
const configuredServerNames = new Set(normalizedValue.map(config => config.name));
|
||||
const availableServerNames = new Set(availableServers.map(server => server.name));
|
||||
|
||||
setExpandedServers(prev => {
|
||||
const newSet = new Set<string>();
|
||||
prev.forEach(serverName => {
|
||||
// Keep expanded if server is configured OR if server exists and user manually expanded it
|
||||
if (configuredServerNames.has(serverName) || availableServerNames.has(serverName)) {
|
||||
newSet.add(serverName);
|
||||
}
|
||||
});
|
||||
return newSet;
|
||||
});
|
||||
}, [normalizedValue, availableServers]);
|
||||
|
||||
const toggleServer = (serverName: string) => {
|
||||
const existingIndex = normalizedValue.findIndex(config => config.name === serverName);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Remove server - this will also remove all its tools
|
||||
const newValue = normalizedValue.filter(config => config.name !== serverName);
|
||||
onChange(newValue);
|
||||
// Don't auto-collapse the server when it's unchecked - let user control expansion manually
|
||||
} else {
|
||||
// Add server with all tools by default
|
||||
const newValue = [...normalizedValue, { name: serverName, tools: 'all' as const }];
|
||||
onChange(newValue);
|
||||
// Don't auto-expand the server when it's checked - let user control expansion manually
|
||||
}
|
||||
};
|
||||
|
||||
const toggleServerExpanded = (serverName: string) => {
|
||||
setExpandedServers(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(serverName)) {
|
||||
newSet.delete(serverName);
|
||||
} else {
|
||||
newSet.add(serverName);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const updateServerTools = (serverName: string, tools: string[] | 'all', keepExpanded = false) => {
|
||||
if (Array.isArray(tools) && tools.length === 0) {
|
||||
// If no tools are selected, remove the server entirely
|
||||
const newValue = normalizedValue.filter(config => config.name !== serverName);
|
||||
onChange(newValue);
|
||||
// Only collapse the server if not explicitly asked to keep it expanded
|
||||
if (!keepExpanded) {
|
||||
setExpandedServers(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(serverName);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Update server tools or add server if it doesn't exist
|
||||
const existingServerIndex = normalizedValue.findIndex(config => config.name === serverName);
|
||||
|
||||
if (existingServerIndex >= 0) {
|
||||
// Update existing server
|
||||
const newValue = normalizedValue.map(config =>
|
||||
config.name === serverName ? { ...config, tools } : config
|
||||
);
|
||||
onChange(newValue);
|
||||
} else {
|
||||
// Add new server with specified tools
|
||||
const newValue = [...normalizedValue, { name: serverName, tools }];
|
||||
onChange(newValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTool = (serverName: string, toolName: string) => {
|
||||
const server = availableServers.find(s => s.name === serverName);
|
||||
if (!server) return;
|
||||
|
||||
const allToolNames = server.tools?.map(tool => tool.name.replace(`${serverName}-`, '')) || [];
|
||||
const serverConfig = normalizedValue.find(config => config.name === serverName);
|
||||
|
||||
if (!serverConfig) {
|
||||
// Server not selected yet, add it with only this tool
|
||||
const newValue = [...normalizedValue, { name: serverName, tools: [toolName] }];
|
||||
onChange(newValue);
|
||||
// Don't auto-expand - let user control expansion manually
|
||||
return;
|
||||
}
|
||||
|
||||
if (serverConfig.tools === 'all') {
|
||||
// Switch from 'all' to specific tools, excluding the toggled tool
|
||||
const newTools = allToolNames.filter(name => name !== toolName);
|
||||
updateServerTools(serverName, newTools);
|
||||
// If all tools are deselected, the server will be removed and collapsed in updateServerTools
|
||||
} else if (Array.isArray(serverConfig.tools)) {
|
||||
const currentTools = serverConfig.tools;
|
||||
if (currentTools.includes(toolName)) {
|
||||
// Remove tool
|
||||
const newTools = currentTools.filter(name => name !== toolName);
|
||||
updateServerTools(serverName, newTools);
|
||||
// If all tools are deselected, the server will be removed and collapsed in updateServerTools
|
||||
} else {
|
||||
// Add tool
|
||||
const newTools = [...currentTools, toolName];
|
||||
|
||||
// If all tools are selected, switch to 'all'
|
||||
if (newTools.length === allToolNames.length) {
|
||||
updateServerTools(serverName, 'all');
|
||||
} else {
|
||||
updateServerTools(serverName, newTools);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isServerSelected = (serverName: string) => {
|
||||
const serverConfig = normalizedValue.find(config => config.name === serverName);
|
||||
if (!serverConfig) return false;
|
||||
|
||||
// Server is considered "fully selected" if tools is 'all'
|
||||
return serverConfig.tools === 'all';
|
||||
};
|
||||
|
||||
const isServerPartiallySelected = (serverName: string) => {
|
||||
const serverConfig = normalizedValue.find(config => config.name === serverName);
|
||||
if (!serverConfig) return false;
|
||||
|
||||
// Server is partially selected if it has specific tools selected (not 'all' and not empty)
|
||||
return Array.isArray(serverConfig.tools) && serverConfig.tools.length > 0;
|
||||
};
|
||||
|
||||
const isToolSelected = (serverName: string, toolName: string) => {
|
||||
const serverConfig = normalizedValue.find(config => config.name === serverName);
|
||||
if (!serverConfig) return false;
|
||||
|
||||
if (serverConfig.tools === 'all') return true;
|
||||
if (Array.isArray(serverConfig.tools)) {
|
||||
return serverConfig.tools.includes(toolName);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const getServerTools = (serverName: string): Tool[] => {
|
||||
const server = availableServers.find(s => s.name === serverName);
|
||||
return server?.tools || [];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
<div className="space-y-3">
|
||||
{availableServers.map(server => {
|
||||
const isSelected = isServerSelected(server.name);
|
||||
const isPartiallySelected = isServerPartiallySelected(server.name);
|
||||
const isExpanded = expandedServers.has(server.name);
|
||||
const serverTools = getServerTools(server.name);
|
||||
const serverConfig = normalizedValue.find(config => config.name === server.name);
|
||||
|
||||
return (
|
||||
<div key={server.name} className="border border-gray-200 rounded-lg hover:border-gray-300 hover:bg-gray-50 transition-colors">
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer rounded-lg transition-colors"
|
||||
onClick={() => toggleServerExpanded(server.name)}
|
||||
>
|
||||
<div
|
||||
className="flex items-center space-x-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleServer(server.name);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected || isPartiallySelected}
|
||||
onChange={() => toggleServer(server.name)}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="font-medium text-gray-900 cursor-pointer select-none">
|
||||
{server.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
{serverConfig && serverConfig.tools !== 'all' && Array.isArray(serverConfig.tools) && (
|
||||
<span className="text-sm text-green-600">
|
||||
({t('groups.toolsSelected')} {serverConfig.tools.length}/{serverTools.length})
|
||||
</span>
|
||||
)}
|
||||
{serverConfig && serverConfig.tools === 'all' && (
|
||||
<span className="text-sm text-green-600">
|
||||
({t('groups.allTools')} {serverTools.length}/{serverTools.length})
|
||||
</span>
|
||||
)}
|
||||
|
||||
{serverTools.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<svg
|
||||
className={cn("w-5 h-5 transition-transform", isExpanded && "rotate-180")}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && serverTools.length > 0 && (
|
||||
<div className="border-t border-gray-200 bg-gray-50 p-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{t('groups.toolSelection')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const isAllSelected = serverConfig?.tools === 'all';
|
||||
if (isAllSelected || (Array.isArray(serverConfig?.tools) && serverConfig.tools.length === serverTools.length)) {
|
||||
// If all tools are selected, deselect all (remove server) but keep expanded
|
||||
updateServerTools(server.name, [], true);
|
||||
} else {
|
||||
// Select all tools (add server if not present)
|
||||
updateServerTools(server.name, 'all');
|
||||
// Don't auto-expand - let user control expansion manually
|
||||
}
|
||||
}}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 transition-colors"
|
||||
>
|
||||
{(serverConfig?.tools === 'all' ||
|
||||
(Array.isArray(serverConfig?.tools) && serverConfig.tools.length === serverTools.length))
|
||||
? t('groups.selectNone')
|
||||
: t('groups.selectAll')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 max-h-32 overflow-y-auto">
|
||||
{serverTools.map(tool => {
|
||||
const toolName = tool.name.replace(`${server.name}-`, '');
|
||||
const isToolChecked = isToolSelected(server.name, toolName);
|
||||
|
||||
return (
|
||||
<label key={tool.name} className="flex items-center space-x-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isToolChecked}
|
||||
onChange={() => toggleTool(server.name, toolName)}
|
||||
className="w-3 h-3 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-gray-700">
|
||||
{toolName}
|
||||
</span>
|
||||
{tool.description && (
|
||||
<span className="text-gray-400 text-xs truncate">
|
||||
{tool.description}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{availableServers.length === 0 && (
|
||||
<p className="text-gray-500 text-sm">{t('groups.noServerOptions')}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
27
frontend/src/components/icons/LanguageIcon.tsx
Normal file
27
frontend/src/components/icons/LanguageIcon.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const LanguageIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
{...props}
|
||||
>
|
||||
<title>{t('common.language')}</title>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2 12h20" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageIcon;
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
Loader,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle
|
||||
AlertCircle,
|
||||
Link,
|
||||
FileCode,
|
||||
ChevronDown as DropdownIcon,
|
||||
Wrench
|
||||
} from 'lucide-react'
|
||||
|
||||
export {
|
||||
@@ -31,7 +35,11 @@ export {
|
||||
Loader,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle
|
||||
AlertCircle,
|
||||
Link,
|
||||
FileCode,
|
||||
DropdownIcon,
|
||||
Wrench
|
||||
}
|
||||
|
||||
const LucideIcons = {
|
||||
@@ -49,7 +57,10 @@ const LucideIcons = {
|
||||
Loader,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle
|
||||
AlertCircle,
|
||||
Link,
|
||||
FileCode,
|
||||
DropdownIcon
|
||||
}
|
||||
|
||||
export default LucideIcons
|
||||
42
frontend/src/components/index.ts
Normal file
42
frontend/src/components/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// Permission components unified export
|
||||
export { PermissionChecker, usePermissionCheck, usePermissionCheckAll } from './PermissionChecker';
|
||||
export { PERMISSIONS } from '../constants/permissions';
|
||||
|
||||
// Convenient permission check Hook
|
||||
export { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
// Permission utility functions
|
||||
export const hasPermission = (
|
||||
userPermissions: string[] = [],
|
||||
requiredPermissions: string | string[],
|
||||
): boolean => {
|
||||
if (requiredPermissions === 'x' && !userPermissions.includes('x')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If user has '*' permission, it means they have all permissions
|
||||
if (userPermissions.includes('*')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Normalize required permissions to array
|
||||
const permissionsToCheck = Array.isArray(requiredPermissions)
|
||||
? requiredPermissions
|
||||
: [requiredPermissions];
|
||||
|
||||
// Check if user has any of the required permissions
|
||||
return permissionsToCheck.some((permission) => userPermissions.includes(permission));
|
||||
};
|
||||
|
||||
export const hasAllPermissions = (
|
||||
userPermissions: string[] = [],
|
||||
requiredPermissions: string[],
|
||||
): boolean => {
|
||||
// If user has '*' permission, it means they have all permissions
|
||||
if (userPermissions.includes('*')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if user has all required permissions
|
||||
return requiredPermissions.every((permission) => userPermissions.includes(permission));
|
||||
};
|
||||
@@ -1,27 +1,19 @@
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import ThemeSwitch from '@/components/ui/ThemeSwitch';
|
||||
import LanguageSwitch from '@/components/ui/LanguageSwitch';
|
||||
import GitHubIcon from '@/components/icons/GitHubIcon';
|
||||
import SponsorIcon from '@/components/icons/SponsorIcon';
|
||||
import WeChatIcon from '@/components/icons/WeChatIcon';
|
||||
import DiscordIcon from '@/components/icons/DiscordIcon';
|
||||
import SponsorDialog from '@/components/ui/SponsorDialog';
|
||||
import WeChatDialog from '@/components/ui/WeChatDialog';
|
||||
|
||||
interface HeaderProps {
|
||||
onToggleSidebar: () => void;
|
||||
}
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { auth } = useAuth();
|
||||
const [sponsorDialogOpen, setSponsorDialogOpen] = useState(false);
|
||||
const [wechatDialogOpen, setWechatDialogOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<header className="bg-white dark:bg-gray-800 shadow-sm z-10">
|
||||
<div className="flex justify-between items-center px-4 py-3">
|
||||
<div className="flex justify-between items-center px-3 py-3">
|
||||
<div className="flex items-center">
|
||||
{/* 侧边栏切换按钮 */}
|
||||
<button
|
||||
@@ -38,53 +30,27 @@ const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
|
||||
<h1 className="ml-4 text-xl font-bold text-gray-900 dark:text-white">{t('app.title')}</h1>
|
||||
</div>
|
||||
|
||||
{/* Theme Switch and Version */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{/* Theme Switch and Language Switcher and Version */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 mr-2">
|
||||
{import.meta.env.PACKAGE_VERSION === 'dev'
|
||||
? import.meta.env.PACKAGE_VERSION
|
||||
: `v${import.meta.env.PACKAGE_VERSION}`}
|
||||
</span>
|
||||
|
||||
<a
|
||||
href="https://github.com/samanhappy/mcphub"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
className="p-2 rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
aria-label="GitHub Repository"
|
||||
>
|
||||
<GitHubIcon className="h-5 w-5" />
|
||||
</a>
|
||||
{i18n.language === 'zh' ? (
|
||||
<button
|
||||
onClick={() => setWechatDialogOpen(true)}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 focus:outline-none"
|
||||
aria-label={t('wechat.label')}
|
||||
>
|
||||
<WeChatIcon className="h-5 w-5" />
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href="https://discord.gg/qMKNsn5Q"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
aria-label={t('discord.label')}
|
||||
>
|
||||
<DiscordIcon className="h-5 w-5" />
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSponsorDialogOpen(true)}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 focus:outline-none"
|
||||
aria-label={t('sponsor.label')}
|
||||
>
|
||||
<SponsorIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<ThemeSwitch />
|
||||
<LanguageSwitch />
|
||||
</div>
|
||||
</div>
|
||||
<SponsorDialog open={sponsorDialogOpen} onOpenChange={setSponsorDialogOpen} />
|
||||
<WeChatDialog open={wechatDialogOpen} onOpenChange={setWechatDialogOpen} />
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { usePermissionCheck } from '../PermissionChecker';
|
||||
import UserProfileMenu from '@/components/ui/UserProfileMenu';
|
||||
|
||||
interface SidebarProps {
|
||||
@@ -15,11 +17,11 @@ interface MenuItem {
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
const { auth } = useAuth();
|
||||
|
||||
// Application version from package.json (accessed via Vite environment variables)
|
||||
const appVersion = import.meta.env.PACKAGE_VERSION as string;
|
||||
|
||||
|
||||
// Menu item configuration
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
@@ -50,6 +52,15 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
...(auth.user?.isAdmin && usePermissionCheck('x') ? [{
|
||||
path: '/users',
|
||||
label: t('nav.users'),
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
|
||||
</svg>
|
||||
),
|
||||
}] : []),
|
||||
{
|
||||
path: '/market',
|
||||
label: t('nav.market'),
|
||||
@@ -71,10 +82,9 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
];
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`bg-white dark:bg-gray-800 shadow-sm transition-all duration-300 ease-in-out flex flex-col h-full relative ${
|
||||
collapsed ? 'w-16' : 'w-64'
|
||||
}`}
|
||||
<aside
|
||||
className={`bg-white dark:bg-gray-800 shadow-sm transition-all duration-300 ease-in-out flex flex-col h-full relative ${collapsed ? 'w-16' : 'w-64'
|
||||
}`}
|
||||
>
|
||||
{/* Scrollable navigation area */}
|
||||
<div className="overflow-y-auto flex-grow">
|
||||
@@ -83,12 +93,11 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center px-3 py-2 rounded-md transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`
|
||||
className={({ isActive }) =>
|
||||
`flex items-center px-2.5 py-2 rounded-lg transition-colors duration-200
|
||||
${isActive
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-100'}`
|
||||
}
|
||||
end={item.path === '/'}
|
||||
>
|
||||
@@ -98,7 +107,7 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
|
||||
{/* User profile menu fixed at the bottom */}
|
||||
<div className="p-3 bg-white dark:bg-gray-800">
|
||||
<UserProfileMenu collapsed={collapsed} version={appVersion} />
|
||||
|
||||
@@ -93,7 +93,7 @@ const AboutDialog: React.FC<AboutDialogProps> = ({ isOpen, onClose, version }) =
|
||||
<button
|
||||
onClick={checkForUpdates}
|
||||
disabled={isChecking}
|
||||
className={`mt-4 inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium
|
||||
className={`mt-4 inline-flex items-center px-4 py-2 border border-gray-200 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium btn-secondary
|
||||
${isChecking
|
||||
? 'text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-800'
|
||||
: 'text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ServerStatus } from '@/types';
|
||||
import { cn } from '../../utils/cn';
|
||||
|
||||
type BadgeVariant = 'default' | 'secondary' | 'outline' | 'destructive';
|
||||
@@ -19,11 +18,11 @@ const badgeVariants = {
|
||||
destructive: 'bg-red-500 text-white hover:bg-red-600',
|
||||
};
|
||||
|
||||
export function Badge({
|
||||
children,
|
||||
variant = 'default',
|
||||
className,
|
||||
onClick
|
||||
export function Badge({
|
||||
children,
|
||||
variant = 'default',
|
||||
className,
|
||||
onClick
|
||||
}: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
@@ -43,11 +42,11 @@ export function Badge({
|
||||
// For backward compatibility with existing code
|
||||
export const StatusBadge = ({ status }: { status: 'connected' | 'disconnected' | 'connecting' }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
const colors = {
|
||||
connecting: 'bg-yellow-100 text-yellow-800',
|
||||
connected: 'bg-green-100 text-green-800',
|
||||
disconnected: 'bg-red-100 text-red-800',
|
||||
connecting: 'status-badge-connecting',
|
||||
connected: 'status-badge-online',
|
||||
disconnected: 'status-badge-offline',
|
||||
};
|
||||
|
||||
// Map status to translation keys
|
||||
|
||||
142
frontend/src/components/ui/ConfirmDialog.tsx
Normal file
142
frontend/src/components/ui/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title?: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: 'danger' | 'warning' | 'info';
|
||||
}
|
||||
|
||||
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText,
|
||||
cancelText,
|
||||
variant = 'warning'
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getVariantStyles = () => {
|
||||
switch (variant) {
|
||||
case 'danger':
|
||||
return {
|
||||
icon: (
|
||||
<svg className="w-6 h-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
),
|
||||
confirmClass: 'bg-red-600 hover:bg-red-700 text-white',
|
||||
};
|
||||
case 'warning':
|
||||
return {
|
||||
icon: (
|
||||
<svg className="w-6 h-6 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
),
|
||||
confirmClass: 'bg-yellow-600 hover:bg-yellow-700 text-white',
|
||||
};
|
||||
case 'info':
|
||||
return {
|
||||
icon: (
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
confirmClass: 'bg-blue-600 hover:bg-blue-700 text-white',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: null,
|
||||
confirmClass: 'bg-blue-600 hover:bg-blue-700 text-white',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const { icon, confirmClass } = getVariantStyles();
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
} else if (e.key === 'Enter') {
|
||||
onConfirm();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-[100] flex items-center justify-center p-4"
|
||||
onClick={handleBackdropClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-xl max-w-md w-full transform transition-all duration-200 ease-out"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
aria-describedby="confirm-dialog-message"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
{icon && (
|
||||
<div className="flex-shrink-0">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
{title && (
|
||||
<h3
|
||||
id="confirm-dialog-title"
|
||||
className="text-lg font-medium text-gray-900 mb-2"
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
<p
|
||||
id="confirm-dialog-message"
|
||||
className="text-gray-600 leading-relaxed"
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-md transition-colors duration-150 btn-secondary"
|
||||
autoFocus
|
||||
>
|
||||
{cancelText || t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`px-4 py-2 rounded-md transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 ${confirmClass} ${variant === 'danger' ? 'btn-danger' : variant === 'warning' ? 'btn-warning' : 'btn-primary'}`}
|
||||
>
|
||||
{confirmText || t('common.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmDialog;
|
||||
@@ -6,9 +6,10 @@ interface DeleteDialogProps {
|
||||
onConfirm: () => void
|
||||
serverName: string
|
||||
isGroup?: boolean
|
||||
isUser?: boolean
|
||||
}
|
||||
|
||||
const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName, isGroup = false }: DeleteDialogProps) => {
|
||||
const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName, isGroup = false, isUser = false }: DeleteDialogProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!isOpen) return null
|
||||
@@ -18,23 +19,29 @@ const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName, isGroup = false
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-3">
|
||||
{isGroup ? t('groups.confirmDelete') : t('server.confirmDelete')}
|
||||
{isUser
|
||||
? t('users.confirmDelete')
|
||||
: isGroup
|
||||
? t('groups.confirmDelete')
|
||||
: t('server.confirmDelete')}
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-6">
|
||||
{isGroup
|
||||
? t('groups.deleteWarning', { name: serverName })
|
||||
: t('server.deleteWarning', { name: serverName })}
|
||||
{isUser
|
||||
? t('users.deleteWarning', { username: serverName })
|
||||
: isGroup
|
||||
? t('groups.deleteWarning', { name: serverName })
|
||||
: t('server.deleteWarning', { name: serverName })}
|
||||
</p>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800 btn-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
||||
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 btn-danger"
|
||||
>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
|
||||
@@ -18,9 +18,10 @@ interface DynamicFormProps {
|
||||
onCancel: () => void;
|
||||
loading?: boolean;
|
||||
storageKey?: string; // Optional key for localStorage persistence
|
||||
title?: string; // Optional title to display instead of default parameters title
|
||||
}
|
||||
|
||||
const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, loading = false, storageKey }) => {
|
||||
const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, loading = false, storageKey, title }) => {
|
||||
const { t } = useTranslation();
|
||||
const [formValues, setFormValues] = useState<Record<string, any>>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
@@ -284,7 +285,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
type="text"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500 form-input"
|
||||
placeholder={schema.description || t('tool.enterKey', { key })}
|
||||
/>
|
||||
);
|
||||
@@ -296,12 +297,12 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<input
|
||||
type="number"
|
||||
step={schema.type === 'integer' ? '1' : 'any'}
|
||||
value={value || ''}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value === '' ? '' : schema.type === 'integer' ? parseInt(e.target.value) : parseFloat(e.target.value);
|
||||
onChange(val);
|
||||
}}
|
||||
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500 form-input"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -323,7 +324,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
type="text"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500 form-input"
|
||||
placeholder={schema.description || t('tool.enterKey', { key })}
|
||||
/>
|
||||
);
|
||||
@@ -340,7 +341,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<div key={fullPath} className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
|
||||
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
|
||||
@@ -358,7 +359,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
newArray.splice(index, 1);
|
||||
handleInputChange(fullPath, newArray);
|
||||
}}
|
||||
className="text-red-500 hover:text-red-700 text-sm"
|
||||
className="text-status-red hover:text-red-700 text-sm"
|
||||
>
|
||||
{t('common.remove')}
|
||||
</button>
|
||||
@@ -387,7 +388,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<div key={objKey}>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
||||
{objKey}
|
||||
{propSchema.items?.required?.includes(objKey) && <span className="text-red-500 ml-1">*</span>}
|
||||
{propSchema.items?.required?.includes(objKey) && <span className="text-status-red ml-1">*</span>}
|
||||
</label>
|
||||
{renderObjectField(objKey, objSchema as JsonSchema, item, (newValue) => {
|
||||
const newArray = [...arrayValue];
|
||||
@@ -406,7 +407,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
newArray[index] = e.target.value;
|
||||
handleInputChange(fullPath, newArray);
|
||||
}}
|
||||
className="w-full border rounded-md px-3 py-2 border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full border rounded-md px-3 py-2 border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
|
||||
placeholder={t('tool.enterValue', { type: propSchema.items?.type || 'value' })}
|
||||
/>
|
||||
)}
|
||||
@@ -425,7 +426,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
} // Handle object type
|
||||
@@ -436,7 +437,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<div key={fullPath} className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
|
||||
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
|
||||
@@ -448,7 +449,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
@@ -457,7 +458,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<div key={fullPath} className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
|
||||
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
<span className="text-xs text-gray-500 ml-1">(JSON object)</span>
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
@@ -478,7 +479,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
className={`w-full border rounded-md px-3 py-2 font-mono text-sm ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
rows={4}
|
||||
/>
|
||||
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -488,7 +489,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<div key={fullPath} className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
|
||||
@@ -505,7 +506,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
@@ -513,7 +514,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<div key={fullPath} className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
|
||||
@@ -522,9 +523,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
type="text"
|
||||
value={value || ''}
|
||||
onChange={(e) => handleInputChange(fullPath, e.target.value)}
|
||||
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red' : 'border-gray-200'} focus:outline-none form-input`}
|
||||
/>
|
||||
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -533,7 +534,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<div key={fullPath} className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
|
||||
@@ -541,14 +542,14 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<input
|
||||
type="number"
|
||||
step={propSchema.type === 'integer' ? '1' : 'any'}
|
||||
value={value || ''}
|
||||
value={value !== undefined && value !== null ? value : ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value === '' ? '' : propSchema.type === 'integer' ? parseInt(e.target.value) : parseFloat(e.target.value);
|
||||
handleInputChange(fullPath, val);
|
||||
}}
|
||||
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
className={`w-full border rounded-md px-3 py-2 form-input ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
/>
|
||||
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -565,13 +566,13 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
/>
|
||||
<label className="ml-2 block text-sm text-gray-700">
|
||||
{key}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
</label>
|
||||
</div>
|
||||
{propSchema.description && (
|
||||
<p className="text-xs text-gray-500 mt-1">{propSchema.description}</p>
|
||||
)}
|
||||
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
} // For other types, show as text input with description
|
||||
@@ -579,7 +580,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<div key={fullPath} className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
<span className="text-xs text-gray-500 ml-1">({propSchema.type})</span>
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
@@ -590,9 +591,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
value={value || ''}
|
||||
onChange={(e) => handleInputChange(fullPath, e.target.value)}
|
||||
placeholder={t('tool.enterValue', { type: propSchema.type })}
|
||||
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500 form-input`}
|
||||
/>
|
||||
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -624,15 +625,15 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Mode Toggle */}
|
||||
<div className="flex justify-between items-center border-b pb-3">
|
||||
<h3 className="text-lg font-medium text-gray-900">{t('tool.parameters')}</h3>
|
||||
<div className="flex justify-between items-center pb-3">
|
||||
<h6 className="text-md font-medium text-gray-900">{title}</h6>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={switchToFormMode}
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${!isJsonMode
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
? 'bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary'
|
||||
: 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary'
|
||||
}`}
|
||||
>
|
||||
{t('tool.formMode')}
|
||||
@@ -641,8 +642,8 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
type="button"
|
||||
onClick={switchToJsonMode}
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${isJsonMode
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
? 'px-4 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary'
|
||||
: 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary'
|
||||
}`}
|
||||
>
|
||||
{t('tool.jsonMode')}
|
||||
@@ -661,17 +662,17 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
value={jsonText}
|
||||
onChange={(e) => handleJsonTextChange(e.target.value)}
|
||||
placeholder={`{\n "key": "value"\n}`}
|
||||
className={`w-full h-64 border rounded-md px-3 py-2 font-mono text-sm resize-y ${jsonError ? 'border-red-500' : 'border-gray-300'
|
||||
className={`w-full h-64 border rounded-md px-3 py-2 font-mono text-sm resize-y form-input ${jsonError ? 'border-red-500' : 'border-gray-300'
|
||||
} focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
/>
|
||||
{jsonError && <p className="text-red-500 text-xs mt-1">{jsonError}</p>}
|
||||
{jsonError && <p className="text-status-red text-xs mt-1">{jsonError}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200"
|
||||
className="px-4 py-1 text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary"
|
||||
>
|
||||
{t('tool.cancel')}
|
||||
</button>
|
||||
@@ -685,7 +686,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
}
|
||||
}}
|
||||
disabled={loading || !!jsonError}
|
||||
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
className="px-4 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary"
|
||||
>
|
||||
{loading ? t('tool.running') : t('tool.runTool')}
|
||||
</button>
|
||||
@@ -702,14 +703,14 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200"
|
||||
className="px-4 py-1 text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary"
|
||||
>
|
||||
{t('tool.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
className="px-4 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary"
|
||||
>
|
||||
{loading ? t('tool.running') : t('tool.runTool')}
|
||||
</button>
|
||||
|
||||
83
frontend/src/components/ui/LanguageSwitch.tsx
Normal file
83
frontend/src/components/ui/LanguageSwitch.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LanguageIcon from '@/components/icons/LanguageIcon';
|
||||
|
||||
const LanguageSwitch: React.FC = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false);
|
||||
const [currentLanguage, setCurrentLanguage] = useState(i18n.language);
|
||||
|
||||
// Available languages
|
||||
const availableLanguages = [
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'zh', label: '中文' }
|
||||
];
|
||||
|
||||
// Update current language when it changes
|
||||
useEffect(() => {
|
||||
setCurrentLanguage(i18n.language);
|
||||
}, [i18n.language]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest('.language-dropdown')) {
|
||||
setLanguageDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (languageDropdownOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [languageDropdownOpen]);
|
||||
|
||||
const handleLanguageChange = (lang: string) => {
|
||||
localStorage.setItem('i18nextLng', lang);
|
||||
setLanguageDropdownOpen(false);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
// Always show dropdown for language selection
|
||||
const handleLanguageToggle = () => {
|
||||
setLanguageDropdownOpen(!languageDropdownOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative language-dropdown">
|
||||
<button
|
||||
onClick={handleLanguageToggle}
|
||||
className="p-2 rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none"
|
||||
aria-label="Language Switcher"
|
||||
>
|
||||
<LanguageIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* Show dropdown when opened */}
|
||||
{languageDropdownOpen && (
|
||||
<div className="absolute right-0 mt-2 w-24 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-50">
|
||||
<div>
|
||||
{availableLanguages.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => handleLanguageChange(lang.code)}
|
||||
className={`flex items-center w-full px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${currentLanguage.startsWith(lang.code)
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{lang.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSwitch;
|
||||
@@ -6,34 +6,33 @@ interface PaginationProps {
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
const Pagination: React.FC<PaginationProps> = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange
|
||||
const Pagination: React.FC<PaginationProps> = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange
|
||||
}) => {
|
||||
// Generate page buttons
|
||||
const getPageButtons = () => {
|
||||
const buttons = [];
|
||||
const maxDisplayedPages = 5; // Maximum number of page buttons to display
|
||||
|
||||
|
||||
// Always display first page
|
||||
buttons.push(
|
||||
<button
|
||||
key="first"
|
||||
onClick={() => onPageChange(1)}
|
||||
className={`px-3 py-1 mx-1 rounded ${
|
||||
currentPage === 1
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
|
||||
}`}
|
||||
className={`px-3 py-1 mx-1 rounded ${currentPage === 1
|
||||
? 'bg-blue-500 text-white btn-primary'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
|
||||
}`}
|
||||
>
|
||||
1
|
||||
</button>
|
||||
);
|
||||
|
||||
|
||||
// Start range
|
||||
let startPage = Math.max(2, currentPage - Math.floor(maxDisplayedPages / 2));
|
||||
|
||||
const startPage = Math.max(2, currentPage - Math.floor(maxDisplayedPages / 2));
|
||||
|
||||
// If we're showing ellipsis after first page
|
||||
if (startPage > 2) {
|
||||
buttons.push(
|
||||
@@ -42,24 +41,23 @@ const Pagination: React.FC<PaginationProps> = ({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Middle pages
|
||||
for (let i = startPage; i <= Math.min(totalPages - 1, startPage + maxDisplayedPages - 3); i++) {
|
||||
buttons.push(
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onPageChange(i)}
|
||||
className={`px-3 py-1 mx-1 rounded ${
|
||||
currentPage === i
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
|
||||
}`}
|
||||
className={`px-3 py-1 mx-1 rounded ${currentPage === i
|
||||
? 'bg-blue-500 text-white btn-primary'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
|
||||
}`}
|
||||
>
|
||||
{i}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// If we're showing ellipsis before last page
|
||||
if (startPage + maxDisplayedPages - 3 < totalPages - 1) {
|
||||
buttons.push(
|
||||
@@ -68,24 +66,23 @@ const Pagination: React.FC<PaginationProps> = ({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Always display last page if there's more than one page
|
||||
if (totalPages > 1) {
|
||||
buttons.push(
|
||||
<button
|
||||
key="last"
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
className={`px-3 py-1 mx-1 rounded ${
|
||||
currentPage === totalPages
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
|
||||
}`}
|
||||
className={`px-3 py-1 mx-1 rounded ${currentPage === totalPages
|
||||
? 'bg-blue-500 text-white btn-primary'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
|
||||
}`}
|
||||
>
|
||||
{totalPages}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return buttons;
|
||||
};
|
||||
|
||||
@@ -99,25 +96,23 @@ const Pagination: React.FC<PaginationProps> = ({
|
||||
<button
|
||||
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className={`px-3 py-1 rounded mr-2 ${
|
||||
currentPage === 1
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
|
||||
}`}
|
||||
className={`px-3 py-1 rounded mr-2 ${currentPage === 1
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
|
||||
}`}
|
||||
>
|
||||
« Prev
|
||||
</button>
|
||||
|
||||
|
||||
<div className="flex">{getPageButtons()}</div>
|
||||
|
||||
|
||||
<button
|
||||
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className={`px-3 py-1 rounded ml-2 ${
|
||||
currentPage === totalPages
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
|
||||
}`}
|
||||
className={`px-3 py-1 rounded ml-2 ${currentPage === totalPages
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
|
||||
}`}
|
||||
>
|
||||
Next »
|
||||
</button>
|
||||
|
||||
@@ -1,50 +1,25 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { Sun, Moon, Monitor } from 'lucide-react';
|
||||
import { Sun, Moon } from 'lucide-react';
|
||||
|
||||
const ThemeSwitch: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme === 'light' ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex bg-gray-200 dark:bg-gray-700 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setTheme('light')}
|
||||
className={`flex items-center justify-center rounded-md p-1.5 ${theme === 'light'
|
||||
? 'bg-white text-yellow-600 shadow'
|
||||
: 'text-black dark:text-gray-300 hover:text-yellow-600 dark:hover:text-yellow-500'
|
||||
}`}
|
||||
title={t('theme.light')}
|
||||
aria-label={t('theme.light')}
|
||||
>
|
||||
<Sun size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme('dark')}
|
||||
className={`flex items-center justify-center rounded-md p-1.5 ${theme === 'dark'
|
||||
? 'bg-gray-800 text-blue-400 shadow'
|
||||
: 'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400'
|
||||
}`}
|
||||
title={t('theme.dark')}
|
||||
aria-label={t('theme.dark')}
|
||||
>
|
||||
<Moon size={18} />
|
||||
</button>
|
||||
{/* <button
|
||||
onClick={() => setTheme('system')}
|
||||
className={`flex items-center justify-center rounded-md p-1.5 ${theme === 'system'
|
||||
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow'
|
||||
: 'text-black dark:text-gray-300 hover:text-green-600 dark:hover:text-green-400'
|
||||
}`}
|
||||
title={t('theme.system')}
|
||||
aria-label={t('theme.system')}
|
||||
>
|
||||
<Monitor size={18} />
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none"
|
||||
title={theme === 'light' ? t('theme.dark') : t('theme.light')}
|
||||
aria-label={theme === 'light' ? t('theme.dark') : t('theme.light')}
|
||||
>
|
||||
{theme === 'light' ? <Moon className="h-5 w-5" /> : <Sun className="h-5 w-5" />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ interface ToggleGroupItemProps {
|
||||
}
|
||||
|
||||
export const ToggleGroupItem: React.FC<ToggleGroupItemProps> = ({
|
||||
value,
|
||||
isSelected,
|
||||
onClick,
|
||||
children
|
||||
@@ -21,8 +20,8 @@ export const ToggleGroupItem: React.FC<ToggleGroupItemProps> = ({
|
||||
aria-checked={isSelected}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between p-2 rounded transition-colors cursor-pointer",
|
||||
isSelected
|
||||
? "bg-blue-50 text-blue-700 hover:bg-blue-100 border-l-4 border-blue-500"
|
||||
isSelected
|
||||
? "bg-blue-50 text-blue-700 hover:bg-blue-100 border-l-4 border-blue-500"
|
||||
: "hover:bg-gray-50 text-gray-700"
|
||||
)}
|
||||
onClick={onClick}
|
||||
@@ -72,7 +71,7 @@ export const ToggleGroup: React.FC<ToggleGroupProps> = ({
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2">
|
||||
{label}
|
||||
</label>
|
||||
<div className="border rounded shadow max-h-60 overflow-y-auto">
|
||||
<div className="border border-gray-200 rounded shadow max-h-60 overflow-y-auto">
|
||||
{options.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm p-3">{noOptionsText}</p>
|
||||
) : (
|
||||
@@ -118,7 +117,7 @@ export const Switch: React.FC<SwitchProps> = ({
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500",
|
||||
checked ? "bg-blue-600" : "bg-gray-200",
|
||||
checked ? "bg-blue-200" : "bg-gray-100",
|
||||
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
|
||||
)}
|
||||
onClick={() => !disabled && onCheckedChange(!checked)}
|
||||
|
||||
@@ -130,7 +130,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-300 shadow rounded-lg p-4 mb-4">
|
||||
<div className="bg-white border border-gray-200 shadow rounded-lg p-4 mb-4">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
@@ -144,7 +144,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
|
||||
<input
|
||||
ref={descriptionInputRef}
|
||||
type="text"
|
||||
className="px-2 py-1 border border-blue-300 rounded bg-white text-sm"
|
||||
className="px-2 py-1 border border-blue-300 rounded bg-white text-sm focus:outline-none form-input"
|
||||
value={customDescription}
|
||||
onChange={handleDescriptionChange}
|
||||
onKeyDown={handleDescriptionKeyDown}
|
||||
@@ -155,7 +155,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="ml-2 p-1 text-green-600 hover:text-green-800"
|
||||
className="ml-2 p-1 text-green-600 hover:text-green-800 cursor-pointer transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDescriptionSave()
|
||||
@@ -168,7 +168,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
|
||||
<>
|
||||
<span ref={descriptionTextRef}>{customDescription || t('tool.noDescription')}</span>
|
||||
<button
|
||||
className="ml-2 p-1 text-gray-500 hover:text-blue-600 transition-colors"
|
||||
className="ml-2 p-1 text-gray-500 hover:text-blue-600 cursor-pointer transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDescriptionEdit()
|
||||
@@ -198,7 +198,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
|
||||
setIsExpanded(true) // Ensure card is expanded when showing run form
|
||||
setShowRunForm(true)
|
||||
}}
|
||||
className="flex items-center space-x-1 px-3 py-1 text-sm text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-md transition-colors"
|
||||
className="flex items-center space-x-1 px-3 py-1 text-sm text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-md transition-colors btn-primary"
|
||||
disabled={isRunning || !tool.enabled}
|
||||
>
|
||||
{isRunning ? (
|
||||
@@ -228,14 +228,14 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
|
||||
|
||||
{/* Run Form */}
|
||||
{showRunForm && (
|
||||
<div className="border border-gray-300 rounded-lg p-4 bg-blue-50">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-3">{t('tool.runToolWithName', { name: tool.name.replace(server + '-', '') })}</h4>
|
||||
<div className="border border-gray-300 rounded-lg p-4">
|
||||
<DynamicForm
|
||||
schema={tool.inputSchema || { type: 'object' }}
|
||||
onSubmit={handleRunTool}
|
||||
onCancel={handleCancelRun}
|
||||
loading={isRunning}
|
||||
storageKey={getStorageKey()}
|
||||
title={t('tool.runToolWithName', { name: tool.name.replace(server + '-', '') })}
|
||||
/>
|
||||
{/* Tool Result */}
|
||||
{result && (
|
||||
|
||||
@@ -65,7 +65,6 @@ const ToolResult: React.FC<ToolResultProps> = ({ result, onClose }) => {
|
||||
|
||||
// For other structured content, try to parse as JSON
|
||||
try {
|
||||
const jsonString = typeof item === 'string' ? item : JSON.stringify(item, null, 2);
|
||||
const parsed = typeof item === 'string' ? JSON.parse(item) : item;
|
||||
|
||||
return (
|
||||
@@ -97,9 +96,9 @@ const ToolResult: React.FC<ToolResultProps> = ({ result, onClose }) => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{result.success ? (
|
||||
<CheckCircle size={20} className="text-green-500" />
|
||||
<CheckCircle size={20} className="text-status-green" />
|
||||
) : (
|
||||
<XCircle size={20} className="text-red-500" />
|
||||
<XCircle size={20} className="text-status-red" />
|
||||
)}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900">
|
||||
|
||||
@@ -4,6 +4,11 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { User, Settings, LogOut, Info } from 'lucide-react';
|
||||
import AboutDialog from './AboutDialog';
|
||||
import SponsorDialog from './SponsorDialog';
|
||||
import WeChatDialog from './WeChatDialog';
|
||||
import WeChatIcon from '@/components/icons/WeChatIcon';
|
||||
import DiscordIcon from '@/components/icons/DiscordIcon';
|
||||
import SponsorIcon from '@/components/icons/SponsorIcon';
|
||||
import { checkLatestVersion, compareVersions } from '@/utils/version';
|
||||
|
||||
interface UserProfileMenuProps {
|
||||
@@ -12,12 +17,14 @@ interface UserProfileMenuProps {
|
||||
}
|
||||
|
||||
const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version }) => {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { auth, logout } = useAuth();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showNewVersionInfo, setShowNewVersionInfo] = useState(false);
|
||||
const [showAboutDialog, setShowAboutDialog] = useState(false);
|
||||
const [sponsorDialogOpen, setSponsorDialogOpen] = useState(false);
|
||||
const [wechatDialogOpen, setWechatDialogOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Check for new version on login and component mount
|
||||
@@ -65,6 +72,16 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleSponsorClick = () => {
|
||||
setSponsorDialogOpen(true);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleWeChatClick = () => {
|
||||
setWechatDialogOpen(true);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="relative">
|
||||
<button
|
||||
@@ -73,7 +90,7 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
|
||||
}`}
|
||||
>
|
||||
<div className="flex-shrink-0 relative">
|
||||
<div className="w-7 h-7 flex items-center justify-center rounded-full border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700">
|
||||
<div className="w-5 h-5 flex items-center justify-center rounded-full border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700">
|
||||
<User className="h-4 w-4 text-gray-700 dark:text-gray-300" />
|
||||
</div>
|
||||
{showNewVersionInfo && (
|
||||
@@ -90,7 +107,35 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-0 transform -translate-y-full left-0 w-48 bg-white dark:bg-gray-800 shadow-lg rounded-md py-1 z-50">
|
||||
<div className="absolute top-0 transform -translate-y-full left-0 w-full min-w-max bg-white border border-gray-200 dark:bg-gray-800 z-50">
|
||||
<button
|
||||
onClick={handleSponsorClick}
|
||||
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<SponsorIcon className="h-4 w-4 mr-2" />
|
||||
{t('sponsor.label')}
|
||||
</button>
|
||||
|
||||
{i18n.language === 'zh' ? (
|
||||
<button
|
||||
onClick={handleWeChatClick}
|
||||
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<WeChatIcon className="h-4 w-4 mr-2" />
|
||||
{t('wechat.label')}
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href="https://discord.gg/qMKNsn5Q"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<DiscordIcon className="h-4 w-4 mr-2" />
|
||||
{t('discord.label')}
|
||||
</a>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSettingsClick}
|
||||
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
@@ -108,6 +153,9 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
|
||||
<span className="absolute top-2 right-4 block w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-600"></div>
|
||||
|
||||
<button
|
||||
onClick={handleLogoutClick}
|
||||
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
@@ -124,6 +172,12 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
|
||||
onClose={() => setShowAboutDialog(false)}
|
||||
version={version}
|
||||
/>
|
||||
|
||||
{/* Sponsor dialog */}
|
||||
<SponsorDialog open={sponsorDialogOpen} onOpenChange={setSponsorDialogOpen} />
|
||||
|
||||
{/* WeChat dialog */}
|
||||
<WeChatDialog open={wechatDialogOpen} onOpenChange={setWechatDialogOpen} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
9
frontend/src/constants/permissions.ts
Normal file
9
frontend/src/constants/permissions.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// Predefined permission constants
|
||||
export const PERMISSIONS = {
|
||||
// Settings page permissions
|
||||
SETTINGS_SMART_ROUTING: 'settings:smart_routing',
|
||||
SETTINGS_SKIP_AUTH: 'settings:skip_auth',
|
||||
SETTINGS_INSTALL_CONFIG: 'settings:install_config',
|
||||
} as const;
|
||||
|
||||
export default PERMISSIONS;
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { AuthState, IUser } from '../types';
|
||||
import { AuthState } from '../types';
|
||||
import * as authService from '../services/authService';
|
||||
import { getPublicConfig } from '../services/configService';
|
||||
|
||||
// Initial auth state
|
||||
const initialState: AuthState = {
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
loading: true,
|
||||
user: null,
|
||||
@@ -21,7 +21,7 @@ const AuthContext = createContext<{
|
||||
auth: initialState,
|
||||
login: async () => false,
|
||||
register: async () => false,
|
||||
logout: () => {},
|
||||
logout: () => { },
|
||||
});
|
||||
|
||||
// Auth provider component
|
||||
@@ -31,8 +31,27 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
// Load user if token exists
|
||||
useEffect(() => {
|
||||
const loadUser = async () => {
|
||||
// First check if authentication should be skipped
|
||||
const { skipAuth, permissions } = await getPublicConfig();
|
||||
|
||||
if (skipAuth) {
|
||||
// If authentication is disabled, set user as authenticated with a dummy user
|
||||
setAuth({
|
||||
isAuthenticated: true,
|
||||
loading: false,
|
||||
user: {
|
||||
username: 'guest',
|
||||
isAdmin: true,
|
||||
permissions,
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal authentication flow
|
||||
const token = authService.getToken();
|
||||
|
||||
|
||||
if (!token) {
|
||||
setAuth({
|
||||
...initialState,
|
||||
@@ -40,13 +59,12 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const response = await authService.getCurrentUser();
|
||||
|
||||
|
||||
if (response.success && response.user) {
|
||||
setAuth({
|
||||
token,
|
||||
isAuthenticated: true,
|
||||
loading: false,
|
||||
user: response.user,
|
||||
@@ -67,7 +85,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
loadUser();
|
||||
}, []);
|
||||
|
||||
@@ -75,10 +93,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
const login = async (username: string, password: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await authService.login({ username, password });
|
||||
|
||||
|
||||
if (response.success && response.token && response.user) {
|
||||
setAuth({
|
||||
token: response.token,
|
||||
isAuthenticated: true,
|
||||
loading: false,
|
||||
user: response.user,
|
||||
@@ -105,16 +122,15 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
|
||||
// Register function
|
||||
const register = async (
|
||||
username: string,
|
||||
password: string,
|
||||
username: string,
|
||||
password: string,
|
||||
isAdmin = false
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const response = await authService.register({ username, password, isAdmin });
|
||||
|
||||
|
||||
if (response.success && response.token && response.user) {
|
||||
setAuth({
|
||||
token: response.token,
|
||||
isAuthenticated: true,
|
||||
loading: false,
|
||||
user: response.user,
|
||||
|
||||
350
frontend/src/hooks/useCloudData.ts
Normal file
350
frontend/src/hooks/useCloudData.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CloudServer, ApiResponse, CloudServerTool } from '@/types';
|
||||
import { apiGet, apiPost } from '../utils/fetchInterceptor';
|
||||
|
||||
export const useCloudData = () => {
|
||||
const { t } = useTranslation();
|
||||
const [servers, setServers] = useState<CloudServer[]>([]);
|
||||
const [allServers, setAllServers] = useState<CloudServer[]>([]);
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('');
|
||||
const [selectedTag, setSelectedTag] = useState<string>('');
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentServer, setCurrentServer] = useState<CloudServer | null>(null);
|
||||
|
||||
// Pagination states
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [serversPerPage, setServersPerPage] = useState(9);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
// Fetch all cloud market servers
|
||||
const fetchCloudServers = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data: ApiResponse<CloudServer[]> = await apiGet('/cloud/servers');
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setAllServers(data.data);
|
||||
// Apply pagination to the fetched data
|
||||
applyPagination(data.data, currentPage);
|
||||
} else {
|
||||
console.error('Invalid cloud market servers data format:', data);
|
||||
setError(t('cloud.fetchError'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching cloud market servers:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
// Keep the original error message for API key errors
|
||||
if (
|
||||
errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' ||
|
||||
errorMessage.toLowerCase().includes('mcprouter api key not configured')
|
||||
) {
|
||||
setError(errorMessage);
|
||||
} else {
|
||||
setError(errorMessage);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
// Apply pagination to data
|
||||
const applyPagination = useCallback(
|
||||
(data: CloudServer[], page: number, itemsPerPage = serversPerPage) => {
|
||||
const totalItems = data.length;
|
||||
const calculatedTotalPages = Math.ceil(totalItems / itemsPerPage);
|
||||
setTotalPages(calculatedTotalPages);
|
||||
|
||||
// Ensure current page is valid
|
||||
const validPage = Math.max(1, Math.min(page, calculatedTotalPages));
|
||||
if (validPage !== page) {
|
||||
setCurrentPage(validPage);
|
||||
}
|
||||
|
||||
const startIndex = (validPage - 1) * itemsPerPage;
|
||||
const paginatedServers = data.slice(startIndex, startIndex + itemsPerPage);
|
||||
setServers(paginatedServers);
|
||||
},
|
||||
[serversPerPage],
|
||||
);
|
||||
|
||||
// Change page
|
||||
const changePage = useCallback(
|
||||
(page: number) => {
|
||||
setCurrentPage(page);
|
||||
applyPagination(allServers, page, serversPerPage);
|
||||
},
|
||||
[allServers, applyPagination, serversPerPage],
|
||||
);
|
||||
|
||||
// Fetch all categories
|
||||
const fetchCategories = useCallback(async () => {
|
||||
try {
|
||||
const data: ApiResponse<string[]> = await apiGet('/cloud/categories');
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setCategories(data.data);
|
||||
} else {
|
||||
console.error('Invalid cloud market categories data format:', data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching cloud market categories:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch all tags
|
||||
const fetchTags = useCallback(async () => {
|
||||
try {
|
||||
const data: ApiResponse<string[]> = await apiGet('/cloud/tags');
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setTags(data.data);
|
||||
} else {
|
||||
console.error('Invalid cloud market tags data format:', data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching cloud market tags:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch server by name
|
||||
const fetchServerByName = useCallback(
|
||||
async (name: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data: ApiResponse<CloudServer> = await apiGet(`/cloud/servers/${name}`);
|
||||
|
||||
if (data && data.success && data.data) {
|
||||
setCurrentServer(data.data);
|
||||
return data.data;
|
||||
} else {
|
||||
console.error('Invalid cloud server data format:', data);
|
||||
setError(t('cloud.serverNotFound'));
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error fetching cloud server ${name}:`, err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
// Keep the original error message for API key errors
|
||||
if (
|
||||
errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' ||
|
||||
errorMessage.toLowerCase().includes('mcprouter api key not configured')
|
||||
) {
|
||||
setError(errorMessage);
|
||||
} else {
|
||||
setError(errorMessage);
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// Search servers by query
|
||||
const searchServers = useCallback(
|
||||
async (query: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setSearchQuery(query);
|
||||
|
||||
if (!query.trim()) {
|
||||
// Fetch fresh data from server instead of just applying pagination
|
||||
fetchCloudServers();
|
||||
return;
|
||||
}
|
||||
|
||||
const data: ApiResponse<CloudServer[]> = await apiGet(
|
||||
`/cloud/servers/search?query=${encodeURIComponent(query)}`,
|
||||
);
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setAllServers(data.data);
|
||||
setCurrentPage(1);
|
||||
applyPagination(data.data, 1);
|
||||
} else {
|
||||
console.error('Invalid cloud search results format:', data);
|
||||
setError(t('cloud.searchError'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error searching cloud servers:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[t, allServers, applyPagination, fetchCloudServers],
|
||||
);
|
||||
|
||||
// Filter servers by category
|
||||
const filterByCategory = useCallback(
|
||||
async (category: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setSelectedCategory(category);
|
||||
setSelectedTag(''); // Reset tag filter when filtering by category
|
||||
|
||||
if (!category) {
|
||||
fetchCloudServers();
|
||||
return;
|
||||
}
|
||||
|
||||
const data: ApiResponse<CloudServer[]> = await apiGet(
|
||||
`/cloud/categories/${encodeURIComponent(category)}`,
|
||||
);
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setAllServers(data.data);
|
||||
setCurrentPage(1);
|
||||
applyPagination(data.data, 1);
|
||||
} else {
|
||||
console.error('Invalid cloud category filter results format:', data);
|
||||
setError(t('cloud.filterError'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error filtering cloud servers by category:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[t, fetchCloudServers, applyPagination],
|
||||
);
|
||||
|
||||
// Filter servers by tag
|
||||
const filterByTag = useCallback(
|
||||
async (tag: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setSelectedTag(tag);
|
||||
setSelectedCategory(''); // Reset category filter when filtering by tag
|
||||
|
||||
if (!tag) {
|
||||
fetchCloudServers();
|
||||
return;
|
||||
}
|
||||
|
||||
const data: ApiResponse<CloudServer[]> = await apiGet(
|
||||
`/cloud/tags/${encodeURIComponent(tag)}`,
|
||||
);
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setAllServers(data.data);
|
||||
setCurrentPage(1);
|
||||
applyPagination(data.data, 1);
|
||||
} else {
|
||||
console.error('Invalid cloud tag filter results format:', data);
|
||||
setError(t('cloud.tagFilterError'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error filtering cloud servers by tag:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[t, fetchCloudServers, applyPagination],
|
||||
);
|
||||
|
||||
// Fetch tools for a specific server
|
||||
const fetchServerTools = useCallback(async (serverName: string) => {
|
||||
try {
|
||||
const data: ApiResponse<CloudServerTool[]> = await apiGet(
|
||||
`/cloud/servers/${serverName}/tools`,
|
||||
);
|
||||
|
||||
if (!data.success) {
|
||||
console.error('Failed to fetch cloud server tools:', data);
|
||||
throw new Error(data.message || 'Failed to fetch cloud server tools');
|
||||
}
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
return data.data;
|
||||
} else {
|
||||
console.error('Invalid cloud server tools data format:', data);
|
||||
return [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error fetching tools for cloud server ${serverName}:`, err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
// Re-throw API key errors so they can be handled by the component
|
||||
if (
|
||||
errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' ||
|
||||
errorMessage.toLowerCase().includes('mcprouter api key not configured')
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Call a tool on a cloud server
|
||||
const callServerTool = useCallback(
|
||||
async (serverName: string, toolName: string, args: Record<string, any>) => {
|
||||
try {
|
||||
const data = await apiPost(`/cloud/servers/${serverName}/tools/${toolName}/call`, {
|
||||
arguments: args,
|
||||
});
|
||||
|
||||
if (data && data.success) {
|
||||
return data.data;
|
||||
} else {
|
||||
throw new Error(data.message || 'Failed to call tool');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error calling tool ${toolName} on cloud server ${serverName}:`, err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Change servers per page
|
||||
const changeServersPerPage = useCallback(
|
||||
(perPage: number) => {
|
||||
setServersPerPage(perPage);
|
||||
setCurrentPage(1);
|
||||
applyPagination(allServers, 1, perPage);
|
||||
},
|
||||
[allServers, applyPagination],
|
||||
);
|
||||
|
||||
// Load initial data
|
||||
useEffect(() => {
|
||||
fetchCloudServers();
|
||||
fetchCategories();
|
||||
fetchTags();
|
||||
}, [fetchCloudServers, fetchCategories, fetchTags]);
|
||||
|
||||
return {
|
||||
servers,
|
||||
allServers,
|
||||
categories,
|
||||
tags,
|
||||
selectedCategory,
|
||||
selectedTag,
|
||||
searchQuery,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
currentServer,
|
||||
fetchCloudServers: fetchCloudServers,
|
||||
fetchServerByName,
|
||||
searchServers,
|
||||
filterByCategory,
|
||||
filterByTag,
|
||||
fetchServerTools,
|
||||
callServerTool,
|
||||
// Pagination properties and methods
|
||||
currentPage,
|
||||
totalPages,
|
||||
serversPerPage,
|
||||
changePage,
|
||||
changeServersPerPage,
|
||||
};
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Group, ApiResponse } from '@/types';
|
||||
import { getApiUrl } from '../utils/runtime';
|
||||
import { Group, ApiResponse, IGroupServerConfig } from '@/types';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../utils/fetchInterceptor';
|
||||
|
||||
export const useGroupData = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -13,18 +13,7 @@ export const useGroupData = () => {
|
||||
const fetchGroups = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/groups'), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<Group[]> = await response.json();
|
||||
const data: ApiResponse<Group[]> = await apiGet('/groups');
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setGroups(data.data);
|
||||
@@ -49,27 +38,22 @@ export const useGroupData = () => {
|
||||
}, []);
|
||||
|
||||
// Create a new group with server associations
|
||||
const createGroup = async (name: string, description?: string, servers: string[] = []) => {
|
||||
const createGroup = async (
|
||||
name: string,
|
||||
description?: string,
|
||||
servers: string[] | IGroupServerConfig[] = [],
|
||||
) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/groups'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: JSON.stringify({ name, description, servers }),
|
||||
});
|
||||
const result: ApiResponse<Group> = await apiPost('/groups', { name, description, servers });
|
||||
console.log('Group created successfully:', result);
|
||||
|
||||
const result: ApiResponse<Group> = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(result.message || t('groups.createError'));
|
||||
return null;
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('groups.createError'));
|
||||
return result;
|
||||
}
|
||||
|
||||
triggerRefresh();
|
||||
return result.data || null;
|
||||
return result || null;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create group');
|
||||
return null;
|
||||
@@ -79,28 +63,17 @@ export const useGroupData = () => {
|
||||
// Update an existing group with server associations
|
||||
const updateGroup = async (
|
||||
id: string,
|
||||
data: { name?: string; description?: string; servers?: string[] },
|
||||
data: { name?: string; description?: string; servers?: string[] | IGroupServerConfig[] },
|
||||
) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl(`/groups/${id}`), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const result: ApiResponse<Group> = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(result.message || t('groups.updateError'));
|
||||
return null;
|
||||
const result: ApiResponse<Group> = await apiPut(`/groups/${id}`, data);
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('groups.updateError'));
|
||||
return result;
|
||||
}
|
||||
|
||||
triggerRefresh();
|
||||
return result.data || null;
|
||||
return result || null;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update group');
|
||||
return null;
|
||||
@@ -108,22 +81,14 @@ export const useGroupData = () => {
|
||||
};
|
||||
|
||||
// Update servers in a group (for batch updates)
|
||||
const updateGroupServers = async (groupId: string, servers: string[]) => {
|
||||
const updateGroupServers = async (groupId: string, servers: string[] | IGroupServerConfig[]) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl(`/groups/${groupId}/servers/batch`), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: JSON.stringify({ servers }),
|
||||
const result: ApiResponse<Group> = await apiPut(`/groups/${groupId}/servers/batch`, {
|
||||
servers,
|
||||
});
|
||||
|
||||
const result: ApiResponse<Group> = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(result.message || t('groups.updateError'));
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('groups.updateError'));
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -138,46 +103,29 @@ export const useGroupData = () => {
|
||||
// Delete a group
|
||||
const deleteGroup = async (id: string) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl(`/groups/${id}`), {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(result.message || t('groups.deleteError'));
|
||||
return false;
|
||||
const result = await apiDelete(`/groups/${id}`);
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('groups.deleteError'));
|
||||
return result;
|
||||
}
|
||||
|
||||
triggerRefresh();
|
||||
return true;
|
||||
return result;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete group');
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Add server to a group
|
||||
const addServerToGroup = async (groupId: string, serverName: string) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl(`/groups/${groupId}/servers`), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: JSON.stringify({ serverName }),
|
||||
const result: ApiResponse<Group> = await apiPost(`/groups/${groupId}/servers`, {
|
||||
serverName,
|
||||
});
|
||||
|
||||
const result: ApiResponse<Group> = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(result.message || t('groups.serverAddError'));
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('groups.serverAddError'));
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -192,18 +140,12 @@ export const useGroupData = () => {
|
||||
// Remove server from group
|
||||
const removeServerFromGroup = async (groupId: string, serverName: string) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl(`/groups/${groupId}/servers/${serverName}`), {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
const result: ApiResponse<Group> = await apiDelete(
|
||||
`/groups/${groupId}/servers/${serverName}`,
|
||||
);
|
||||
|
||||
const result: ApiResponse<Group> = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(result.message || t('groups.serverRemoveError'));
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('groups.serverRemoveError'));
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MarketServer, ApiResponse, ServerConfig } from '@/types';
|
||||
import { getApiUrl } from '../utils/runtime';
|
||||
import { apiGet, apiPost } from '../utils/fetchInterceptor';
|
||||
|
||||
export const useMarketData = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -26,18 +26,7 @@ export const useMarketData = () => {
|
||||
const fetchMarketServers = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/market/servers'), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<MarketServer[]> = await response.json();
|
||||
const data: ApiResponse<MarketServer[]> = await apiGet('/market/servers');
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setAllServers(data.data);
|
||||
@@ -87,18 +76,7 @@ export const useMarketData = () => {
|
||||
// Fetch all categories
|
||||
const fetchCategories = useCallback(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/market/categories'), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<string[]> = await response.json();
|
||||
const data: ApiResponse<string[]> = await apiGet('/market/categories');
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setCategories(data.data);
|
||||
@@ -113,18 +91,7 @@ export const useMarketData = () => {
|
||||
// Fetch all tags
|
||||
const fetchTags = useCallback(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/market/tags'), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<string[]> = await response.json();
|
||||
const data: ApiResponse<string[]> = await apiGet('/market/tags');
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setTags(data.data);
|
||||
@@ -141,18 +108,7 @@ export const useMarketData = () => {
|
||||
async (name: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl(`/market/servers/${name}`), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<MarketServer> = await response.json();
|
||||
const data: ApiResponse<MarketServer> = await apiGet(`/market/servers/${name}`);
|
||||
|
||||
if (data && data.success && data.data) {
|
||||
setCurrentServer(data.data);
|
||||
@@ -186,22 +142,10 @@ export const useMarketData = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(
|
||||
getApiUrl(`/market/servers/search?query=${encodeURIComponent(query)}`),
|
||||
{
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
},
|
||||
const data: ApiResponse<MarketServer[]> = await apiGet(
|
||||
`/market/servers/search?query=${encodeURIComponent(query)}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<MarketServer[]> = await response.json();
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setAllServers(data.data);
|
||||
setCurrentPage(1);
|
||||
@@ -233,22 +177,10 @@ export const useMarketData = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(
|
||||
getApiUrl(`/market/categories/${encodeURIComponent(category)}`),
|
||||
{
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
},
|
||||
const data: ApiResponse<MarketServer[]> = await apiGet(
|
||||
`/market/categories/${encodeURIComponent(category)}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<MarketServer[]> = await response.json();
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setAllServers(data.data);
|
||||
setCurrentPage(1);
|
||||
@@ -280,18 +212,9 @@ export const useMarketData = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl(`/market/tags/${encodeURIComponent(tag)}`), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<MarketServer[]> = await response.json();
|
||||
const data: ApiResponse<MarketServer[]> = await apiGet(
|
||||
`/market/tags/${encodeURIComponent(tag)}`,
|
||||
);
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setAllServers(data.data);
|
||||
@@ -314,18 +237,7 @@ export const useMarketData = () => {
|
||||
// Fetch installed servers
|
||||
const fetchInstalledServers = useCallback(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/servers'), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const data = await apiGet<{ success: boolean; data: any[] }>('/servers');
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
// Extract server names
|
||||
@@ -365,27 +277,24 @@ export const useMarketData = () => {
|
||||
// Prepare server configuration, merging with customConfig
|
||||
const serverConfig = {
|
||||
name: server.name,
|
||||
config: customConfig.type === 'stdio' ? {
|
||||
command: customConfig.command || installation.command || '',
|
||||
args: customConfig.args || installation.args || [],
|
||||
env: { ...installation.env, ...customConfig.env },
|
||||
} : customConfig
|
||||
config:
|
||||
customConfig.type === 'stdio'
|
||||
? {
|
||||
command: customConfig.command || installation.command || '',
|
||||
args: customConfig.args || installation.args || [],
|
||||
env: { ...installation.env, ...customConfig.env },
|
||||
}
|
||||
: customConfig,
|
||||
};
|
||||
|
||||
// Call the createServer API
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/servers'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: JSON.stringify(serverConfig),
|
||||
});
|
||||
const result = await apiPost<{ success: boolean; message?: string }>(
|
||||
'/servers',
|
||||
serverConfig,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || `Status: ${response.status}`);
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || 'Failed to install server');
|
||||
}
|
||||
|
||||
// Update installed servers list after successful installation
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server, ApiResponse } from '@/types';
|
||||
import { getApiUrl } from '../utils/runtime';
|
||||
import { apiGet, apiPost, apiDelete } from '../utils/fetchInterceptor';
|
||||
|
||||
// Configuration options
|
||||
const CONFIG = {
|
||||
@@ -44,13 +44,7 @@ export const useServerData = () => {
|
||||
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/servers'), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
const data = await apiGet('/servers');
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
@@ -97,13 +91,7 @@ export const useServerData = () => {
|
||||
// Initialization phase request function
|
||||
const fetchInitialData = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/servers'), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
const data = await apiGet('/servers');
|
||||
|
||||
// Handle API response wrapper object, extract data field
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
@@ -203,14 +191,8 @@ export const useServerData = () => {
|
||||
const handleServerEdit = async (server: Server) => {
|
||||
try {
|
||||
// Fetch settings to get the full server config before editing
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/settings'), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> = await response.json();
|
||||
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> =
|
||||
await apiGet('/settings');
|
||||
|
||||
if (
|
||||
settingsData &&
|
||||
@@ -240,17 +222,10 @@ export const useServerData = () => {
|
||||
|
||||
const handleServerRemove = async (serverName: string) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl(`/servers/${serverName}`), {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
const result = await response.json();
|
||||
const result = await apiDelete(`/servers/${serverName}`);
|
||||
|
||||
if (!response.ok) {
|
||||
setError(result.message || t('server.deleteError', { serverName }));
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('server.deleteError', { serverName }));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -264,21 +239,11 @@ export const useServerData = () => {
|
||||
|
||||
const handleServerToggle = async (server: Server, enabled: boolean) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl(`/servers/${server.name}/toggle`), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
const result = await apiPost(`/servers/${server.name}/toggle`, { enabled });
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
if (!result || !result.success) {
|
||||
console.error('Failed to toggle server:', result);
|
||||
setError(t('server.toggleError', { serverName: server.name }));
|
||||
setError(result?.message || t('server.toggleError', { serverName: server.name }));
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ApiResponse } from '@/types';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import { getApiUrl } from '../utils/runtime';
|
||||
import { apiGet, apiPut } from '../utils/fetchInterceptor';
|
||||
|
||||
// Define types for the settings data
|
||||
interface RoutingConfig {
|
||||
@@ -10,11 +10,13 @@ interface RoutingConfig {
|
||||
enableGroupNameRoute: boolean;
|
||||
enableBearerAuth: boolean;
|
||||
bearerAuthKey: string;
|
||||
skipAuth: boolean;
|
||||
}
|
||||
|
||||
interface InstallConfig {
|
||||
pythonIndexUrl: string;
|
||||
npmRegistry: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
interface SmartRoutingConfig {
|
||||
@@ -25,11 +27,19 @@ interface SmartRoutingConfig {
|
||||
openaiApiEmbeddingModel: string;
|
||||
}
|
||||
|
||||
interface MCPRouterConfig {
|
||||
apiKey: string;
|
||||
referer: string;
|
||||
title: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
interface SystemSettings {
|
||||
systemConfig?: {
|
||||
routing?: RoutingConfig;
|
||||
install?: InstallConfig;
|
||||
smartRouting?: SmartRoutingConfig;
|
||||
mcpRouter?: MCPRouterConfig;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,6 +56,7 @@ export const useSettingsData = () => {
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
skipAuth: false,
|
||||
});
|
||||
|
||||
const [tempRoutingConfig, setTempRoutingConfig] = useState<TempRoutingConfig>({
|
||||
@@ -55,6 +66,7 @@ export const useSettingsData = () => {
|
||||
const [installConfig, setInstallConfig] = useState<InstallConfig>({
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
});
|
||||
|
||||
const [smartRoutingConfig, setSmartRoutingConfig] = useState<SmartRoutingConfig>({
|
||||
@@ -65,6 +77,13 @@ export const useSettingsData = () => {
|
||||
openaiApiEmbeddingModel: '',
|
||||
});
|
||||
|
||||
const [mcpRouterConfig, setMCPRouterConfig] = useState<MCPRouterConfig>({
|
||||
apiKey: '',
|
||||
referer: 'https://mcphub.app',
|
||||
title: 'MCPHub',
|
||||
baseUrl: 'https://api.mcprouter.to/v1',
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
@@ -80,18 +99,7 @@ export const useSettingsData = () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/settings'), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<SystemSettings> = await response.json();
|
||||
const data: ApiResponse<SystemSettings> = await apiGet('/settings');
|
||||
|
||||
if (data.success && data.data?.systemConfig?.routing) {
|
||||
setRoutingConfig({
|
||||
@@ -99,12 +107,14 @@ export const useSettingsData = () => {
|
||||
enableGroupNameRoute: data.data.systemConfig.routing.enableGroupNameRoute ?? true,
|
||||
enableBearerAuth: data.data.systemConfig.routing.enableBearerAuth ?? false,
|
||||
bearerAuthKey: data.data.systemConfig.routing.bearerAuthKey || '',
|
||||
skipAuth: data.data.systemConfig.routing.skipAuth ?? false,
|
||||
});
|
||||
}
|
||||
if (data.success && data.data?.systemConfig?.install) {
|
||||
setInstallConfig({
|
||||
pythonIndexUrl: data.data.systemConfig.install.pythonIndexUrl || '',
|
||||
npmRegistry: data.data.systemConfig.install.npmRegistry || '',
|
||||
baseUrl: data.data.systemConfig.install.baseUrl || 'http://localhost:3000',
|
||||
});
|
||||
}
|
||||
if (data.success && data.data?.systemConfig?.smartRouting) {
|
||||
@@ -117,6 +127,14 @@ export const useSettingsData = () => {
|
||||
data.data.systemConfig.smartRouting.openaiApiEmbeddingModel || '',
|
||||
});
|
||||
}
|
||||
if (data.success && data.data?.systemConfig?.mcpRouter) {
|
||||
setMCPRouterConfig({
|
||||
apiKey: data.data.systemConfig.mcpRouter.apiKey || '',
|
||||
referer: data.data.systemConfig.mcpRouter.referer || 'https://mcphub.app',
|
||||
title: data.data.systemConfig.mcpRouter.title || 'MCPHub',
|
||||
baseUrl: data.data.systemConfig.mcpRouter.baseUrl || 'https://api.mcprouter.to/v1',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to fetch settings');
|
||||
@@ -128,34 +146,17 @@ export const useSettingsData = () => {
|
||||
}, [t]); // 移除 showToast 依赖
|
||||
|
||||
// Update routing configuration
|
||||
const updateRoutingConfig = async <T extends keyof RoutingConfig>(
|
||||
key: T,
|
||||
value: RoutingConfig[T],
|
||||
) => {
|
||||
const updateRoutingConfig = async (key: keyof RoutingConfig, value: any) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/system-config'), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
const data = await apiPut('/system-config', {
|
||||
routing: {
|
||||
[key]: value,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
routing: {
|
||||
[key]: value,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setRoutingConfig({
|
||||
...routingConfig,
|
||||
@@ -164,7 +165,7 @@ export const useSettingsData = () => {
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
showToast(t('errors.failedToUpdateRouteConfig'));
|
||||
showToast(data.message || t('errors.failedToUpdateRouteConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -183,26 +184,12 @@ export const useSettingsData = () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/system-config'), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
const data = await apiPut('/system-config', {
|
||||
install: {
|
||||
[key]: value,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
install: {
|
||||
[key]: value,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setInstallConfig({
|
||||
...installConfig,
|
||||
@@ -211,7 +198,7 @@ export const useSettingsData = () => {
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
showToast(t('errors.failedToUpdateSystemConfig'));
|
||||
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -233,27 +220,12 @@ export const useSettingsData = () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/system-config'), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
const data = await apiPut('/system-config', {
|
||||
smartRouting: {
|
||||
[key]: value,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
smartRouting: {
|
||||
[key]: value,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || `HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setSmartRoutingConfig({
|
||||
...smartRoutingConfig,
|
||||
@@ -283,25 +255,10 @@ export const useSettingsData = () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/system-config'), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
smartRouting: updates,
|
||||
}),
|
||||
const data = await apiPut('/system-config', {
|
||||
smartRouting: updates,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || `HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setSmartRoutingConfig({
|
||||
...smartRoutingConfig,
|
||||
@@ -331,24 +288,10 @@ export const useSettingsData = () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/system-config'), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
routing: updates,
|
||||
}),
|
||||
const data = await apiPut('/system-config', {
|
||||
routing: updates,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setRoutingConfig({
|
||||
...routingConfig,
|
||||
@@ -357,7 +300,7 @@ export const useSettingsData = () => {
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
showToast(t('errors.failedToUpdateRouteConfig'));
|
||||
showToast(data.message || t('errors.failedToUpdateRouteConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -370,6 +313,77 @@ export const useSettingsData = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Update MCPRouter configuration
|
||||
const updateMCPRouterConfig = async <T extends keyof MCPRouterConfig>(
|
||||
key: T,
|
||||
value: MCPRouterConfig[T],
|
||||
) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await apiPut('/system-config', {
|
||||
mcpRouter: {
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setMCPRouterConfig({
|
||||
...mcpRouterConfig,
|
||||
[key]: value,
|
||||
});
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update MCPRouter config:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to update MCPRouter config';
|
||||
setError(errorMessage);
|
||||
showToast(errorMessage);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update multiple MCPRouter configuration fields at once
|
||||
const updateMCPRouterConfigBatch = async (updates: Partial<MCPRouterConfig>) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await apiPut('/system-config', {
|
||||
mcpRouter: updates,
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setMCPRouterConfig({
|
||||
...mcpRouterConfig,
|
||||
...updates,
|
||||
});
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update MCPRouter config:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to update MCPRouter config';
|
||||
setError(errorMessage);
|
||||
showToast(errorMessage);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch settings when the component mounts or refreshKey changes
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
@@ -389,6 +403,7 @@ export const useSettingsData = () => {
|
||||
setTempRoutingConfig,
|
||||
installConfig,
|
||||
smartRoutingConfig,
|
||||
mcpRouterConfig,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
@@ -399,5 +414,7 @@ export const useSettingsData = () => {
|
||||
updateSmartRoutingConfig,
|
||||
updateSmartRoutingConfigBatch,
|
||||
updateRoutingConfigBatch,
|
||||
updateMCPRouterConfig,
|
||||
updateMCPRouterConfigBatch,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,9 +2,9 @@ import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
// Import translations
|
||||
import enTranslation from './locales/en.json';
|
||||
import zhTranslation from './locales/zh.json';
|
||||
// Import shared translations from root locales directory
|
||||
import enTranslation from '../../locales/en.json';
|
||||
import zhTranslation from '../../locales/zh.json';
|
||||
|
||||
i18n
|
||||
// Detect user language
|
||||
@@ -15,18 +15,18 @@ i18n
|
||||
.init({
|
||||
resources: {
|
||||
en: {
|
||||
translation: enTranslation
|
||||
translation: enTranslation,
|
||||
},
|
||||
zh: {
|
||||
translation: zhTranslation
|
||||
}
|
||||
translation: zhTranslation,
|
||||
},
|
||||
},
|
||||
fallbackLng: 'en',
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
|
||||
|
||||
// Common namespace used for all translations
|
||||
defaultNS: 'translation',
|
||||
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false, // React already safe from XSS
|
||||
},
|
||||
@@ -36,7 +36,7 @@ i18n
|
||||
order: ['localStorage', 'cookie', 'htmlTag', 'navigator'],
|
||||
// Cache the language in localStorage
|
||||
caches: ['localStorage', 'cookie'],
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
export default i18n;
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
/* Use project's custom Tailwind import */
|
||||
@import "tailwindcss";
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* Add some custom styles to verify CSS is working correctly */
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
font-family:
|
||||
'Inter',
|
||||
'PingFang SC',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
'Roboto',
|
||||
'Oxygen',
|
||||
'Ubuntu',
|
||||
'Cantarell',
|
||||
'Fira Sans',
|
||||
'Droid Sans',
|
||||
'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
@@ -13,7 +24,7 @@ body {
|
||||
|
||||
/* Dark mode override styles - these will apply when dark class is on html element */
|
||||
.dark body {
|
||||
background-color: #111827;
|
||||
background-color: #1f2a37;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
@@ -37,30 +48,459 @@ body {
|
||||
color: #d1d5db !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-500 {
|
||||
/* .dark .text-gray-500 {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
} */
|
||||
|
||||
.dark .border-gray-300 {
|
||||
border-color: #4b5563 !important;
|
||||
border-color: #2f3b4c !important;
|
||||
}
|
||||
|
||||
.dark .border-gray-200 {
|
||||
border-color: #2f3b4c !important;
|
||||
}
|
||||
|
||||
.dark .divide-gray-200 > :not([hidden]) ~ :not([hidden]) {
|
||||
border-color: #2f3b4c !important;
|
||||
}
|
||||
|
||||
.dark .bg-gray-100 {
|
||||
background-color: #374151 !important;
|
||||
}
|
||||
|
||||
/* Specific hover effects for dark mode */
|
||||
.dark .hover\:bg-gray-100:hover {
|
||||
background-color: rgba(110, 127, 156, 0.15) !important;
|
||||
}
|
||||
|
||||
.dark .hover\:text-gray-900:hover {
|
||||
color: rgb(190, 188, 185) !important;
|
||||
}
|
||||
|
||||
.dark .bg-gray-50 {
|
||||
background-color: #1f2937 !important;
|
||||
}
|
||||
|
||||
.dark .text-blue-700 {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.dark .bg-blue-50 {
|
||||
background-color: #4b5563 !important;
|
||||
}
|
||||
|
||||
.dark .bg-blue-200 {
|
||||
background-color: #576476 !important;
|
||||
}
|
||||
|
||||
.dark .shadow {
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.4), 0 1px 2px 0 rgba(0, 0, 0, 0.24) !important;
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.15),
|
||||
0 2px 6px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.bg-custom-blue {
|
||||
background-color: #4a90e2;
|
||||
background-color: #4a90e2;
|
||||
}
|
||||
|
||||
.text-custom-white {
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.status-badge-online {
|
||||
background-color: white !important;
|
||||
color: rgba(129, 199, 132, 0.9) !important;
|
||||
border: 1px solid #a6d7b7;
|
||||
}
|
||||
|
||||
/* Enhanced status badge styles for dark theme */
|
||||
.dark .status-badge-online {
|
||||
background-color: rgba(76, 175, 80, 0.15) !important;
|
||||
color: rgba(129, 199, 132, 0.9) !important;
|
||||
border: 1px solid rgba(76, 175, 80, 0.3);
|
||||
}
|
||||
|
||||
.status-badge-offline {
|
||||
background-color: white !important;
|
||||
color: rgba(107, 114, 128, 0.9) !important;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.dark .status-badge-offline {
|
||||
background-color: rgba(107, 114, 128, 0.15) !important;
|
||||
color: rgba(156, 163, 175, 0.9) !important;
|
||||
border: 1px solid rgba(107, 114, 128, 0.3);
|
||||
}
|
||||
|
||||
.status-badge-connecting {
|
||||
background-color: white !important;
|
||||
color: rgba(255, 213, 79, 0.9) !important;
|
||||
border: 1px solid #ffd57f;
|
||||
}
|
||||
|
||||
.dark .status-badge-connecting {
|
||||
background-color: rgba(255, 193, 7, 0.15) !important;
|
||||
color: rgba(255, 213, 79, 0.9) !important;
|
||||
border: 1px solid rgba(255, 193, 7, 0.3);
|
||||
}
|
||||
|
||||
/* Enhanced status icons for dark theme */
|
||||
.dark .status-icon-blue {
|
||||
background-color: rgba(59, 130, 246, 0.15) !important;
|
||||
color: rgba(96, 165, 250, 0.9) !important;
|
||||
}
|
||||
|
||||
.dark .status-icon-green {
|
||||
background-color: rgba(76, 175, 80, 0.15) !important;
|
||||
color: rgba(129, 199, 132, 0.9) !important;
|
||||
}
|
||||
|
||||
.dark .status-icon-red {
|
||||
background-color: rgba(244, 67, 54, 0.15) !important;
|
||||
color: rgba(239, 154, 154, 0.9) !important;
|
||||
}
|
||||
|
||||
.dark .status-icon-yellow {
|
||||
background-color: rgba(255, 193, 7, 0.15) !important;
|
||||
color: rgba(255, 213, 79, 0.9) !important;
|
||||
}
|
||||
|
||||
/* Enhanced card hover effects */
|
||||
.dashboard-card {
|
||||
transition: all 0.3s ease;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.dashboard-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 8px 25px rgba(0, 0, 0, 0.2),
|
||||
0 4px 12px rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
/* Icon container hover effects */
|
||||
.icon-container {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.icon-container:hover {
|
||||
transform: scale(1.05);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* Progress bar enhancements */
|
||||
.progress-bar-online {
|
||||
background: linear-gradient(90deg, rgba(76, 175, 80, 0.8), rgba(129, 199, 132, 0.6));
|
||||
}
|
||||
|
||||
.progress-bar-offline {
|
||||
background: linear-gradient(90deg, rgba(244, 67, 54, 0.8), rgba(239, 154, 154, 0.6));
|
||||
}
|
||||
|
||||
.progress-bar-connecting {
|
||||
background: linear-gradient(90deg, rgba(255, 193, 7, 0.8), rgba(255, 213, 79, 0.6));
|
||||
}
|
||||
|
||||
/* Table enhancements for dark theme */
|
||||
.dark .table-container {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.dark thead {
|
||||
background-color: #252d3a !important;
|
||||
}
|
||||
|
||||
.dark tbody tr {
|
||||
border-bottom: 1px solid #2f3b4c;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background-color: var(--color-gray-100) !important;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.dark tbody tr:hover {
|
||||
background-color: rgba(55, 65, 81, 0.5) !important;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Error box enhancements for dark theme */
|
||||
.dark .error-box {
|
||||
background-color: rgba(244, 67, 54, 0.1) !important;
|
||||
border-color: rgba(244, 67, 54, 0.3) !important;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.1);
|
||||
}
|
||||
|
||||
.dark .error-box h3 {
|
||||
color: rgba(239, 154, 154, 0.9) !important;
|
||||
}
|
||||
|
||||
.dark .error-box p {
|
||||
color: #d1d5db !important;
|
||||
}
|
||||
|
||||
/* Loading container enhancements */
|
||||
.loading-container {
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.dark .loading-container {
|
||||
background-color: rgba(31, 41, 55, 0.8) !important;
|
||||
border: 1px solid #2f3b4c;
|
||||
}
|
||||
|
||||
.label-primary {
|
||||
background-color: var(--color-blue-50) !important;
|
||||
color: var(--color-blue-500) !important;
|
||||
}
|
||||
|
||||
.dark .label-primary {
|
||||
background-color: rgba(59, 130, 246, 0.15) !important;
|
||||
color: rgba(96, 165, 250, 0.9) !important;
|
||||
}
|
||||
|
||||
.label-secondary {
|
||||
background-color: var(--color-green-50) !important;
|
||||
color: var(--color-green-500) !important;
|
||||
}
|
||||
|
||||
.dark .label-secondary {
|
||||
background-color: rgba(76, 175, 80, 0.15) !important;
|
||||
color: rgba(129, 199, 132, 0.9) !important;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #60a5fa !important;
|
||||
color: #ffffff !important;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 4px rgba(96, 165, 250, 0.2);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #3b82f6 !important;
|
||||
color: #ffffff !important;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
/* Enhanced button styles for dark theme */
|
||||
.dark .btn-primary {
|
||||
background-color: rgba(59, 130, 246, 0.15) !important;
|
||||
color: rgba(96, 165, 250, 0.9) !important;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.dark .btn-primary:hover {
|
||||
background-color: rgba(59, 130, 246, 0.25) !important;
|
||||
color: rgba(96, 165, 250, 1) !important;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #f9fafb !important;
|
||||
color: #374151 !important;
|
||||
border: 1px solid #d1d5db !important;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #e5e7eb !important;
|
||||
color: #374151 !important;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dark .btn-secondary {
|
||||
background-color: rgba(107, 114, 128, 0.15) !important;
|
||||
color: rgba(156, 163, 175, 0.9) !important;
|
||||
border: 1px solid rgba(107, 114, 128, 0.3) !important;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.dark .btn-secondary:hover {
|
||||
background-color: rgba(107, 114, 128, 0.25) !important;
|
||||
color: rgba(156, 163, 175, 1) !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background-color: var(--color-yellow-100) !important;
|
||||
color: var(--color-yellow-800) !important;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background-color: var(--color-yellow-200) !important;
|
||||
color: var(--color-yellow-800) !important;
|
||||
}
|
||||
|
||||
.dark .btn-warning {
|
||||
background-color: rgba(234, 179, 8, 0.15) !important;
|
||||
color: rgba(250, 204, 21, 0.9) !important;
|
||||
border: 1px solid rgba(234, 179, 8, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.dark .btn-warning:hover {
|
||||
background-color: rgba(234, 179, 8, 0.25) !important;
|
||||
color: rgba(250, 204, 21, 1) !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--color-red-100) !important;
|
||||
color: var(--color-red-800) !important;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: var(--color-red-200) !important;
|
||||
color: var(--color-red-800) !important;
|
||||
}
|
||||
|
||||
.dark .btn-danger {
|
||||
background-color: rgba(244, 67, 54, 0.15) !important;
|
||||
color: rgba(239, 154, 154, 0.9) !important;
|
||||
border: 1px solid rgba(244, 67, 54, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.dark .btn-danger:hover {
|
||||
background-color: rgba(244, 67, 54, 0.25) !important;
|
||||
color: rgba(239, 154, 154, 1) !important;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.2);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
background-color: #f9fafb !important;
|
||||
border-color: #d1d5db !important;
|
||||
color: #374151 !important;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: rgba(184, 193, 207, 0.5);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* Form input enhancements for dark theme */
|
||||
.dark .form-input {
|
||||
background-color: #1f2937 !important;
|
||||
border-color: #2f3b4c !important;
|
||||
color: #e5e7eb !important;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.dark .form-input:focus {
|
||||
border-color: rgba(59, 130, 246, 0.5) !important;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1) !important;
|
||||
}
|
||||
|
||||
.dark .form-input::placeholder {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
/* Card spacing and layout improvements */
|
||||
.page-card {
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.page-card:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.dark .page-card {
|
||||
background-color: #1f2937 !important;
|
||||
border: 1px solid #2f3b4c;
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.15),
|
||||
0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Custom text color to match status-icon-red */
|
||||
.text-status-red {
|
||||
color: #991b1b; /* Tailwind red-800 for light mode */
|
||||
}
|
||||
|
||||
.dark .text-status-red {
|
||||
color: rgba(239, 154, 154, 0.9) !important;
|
||||
}
|
||||
|
||||
/* External link styles */
|
||||
.external-link {
|
||||
color: #2563eb !important; /* Blue-600 for light mode */
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: all 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.external-link:hover {
|
||||
color: #1d4ed8 !important; /* Blue-700 for light mode */
|
||||
border-bottom-color: #1d4ed8;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.dark .external-link {
|
||||
color: #60a5fa !important; /* Blue-400 for dark mode */
|
||||
}
|
||||
|
||||
.dark .external-link:hover {
|
||||
color: #93c5fd !important; /* Blue-300 for dark mode */
|
||||
border-bottom-color: #93c5fd;
|
||||
}
|
||||
|
||||
.border-red {
|
||||
border-color: #937d7d; /* Tailwind red-800 for light mode */
|
||||
}
|
||||
|
||||
.dark .border-red {
|
||||
border-color: rgba(188, 161, 161, 0.9) !important;
|
||||
}
|
||||
|
||||
.dark .text-status-green {
|
||||
color: rgba(129, 199, 132, 0.9) !important;
|
||||
}
|
||||
|
||||
/* Empty state styling */
|
||||
.dark .empty-state {
|
||||
background-color: #1f2937 !important;
|
||||
border: 1px solid #2f3b4c;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
|
||||
.dark .empty-state p {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
/* Login page enhancements for dark theme */
|
||||
.dark .login-container {
|
||||
background-color: #1f2a37 !important;
|
||||
}
|
||||
|
||||
.dark .login-card {
|
||||
background-color: #1f2937 !important;
|
||||
border: 1px solid #2f3b4c;
|
||||
box-shadow:
|
||||
0 8px 25px rgba(0, 0, 0, 0.2),
|
||||
0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import App from './App';
|
||||
import './index.css';
|
||||
// Import the i18n configuration
|
||||
import './i18n';
|
||||
// Setup fetch interceptors
|
||||
import './utils/setupInterceptors';
|
||||
import { loadRuntimeConfig } from './utils/runtime';
|
||||
|
||||
// Load runtime configuration before starting the app
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useServerData } from '@/hooks/useServerData';
|
||||
import { ServerStatus } from '@/types';
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -22,26 +21,20 @@ const DashboardPage: React.FC = () => {
|
||||
connecting: 'status.connecting'
|
||||
}
|
||||
|
||||
// Calculate percentage for each status (for dashboard display)
|
||||
const getStatusPercentage = (status: ServerStatus) => {
|
||||
if (servers.length === 0) return 0;
|
||||
return Math.round((servers.filter(server => server.status === status).length / servers.length) * 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-8">{t('pages.dashboard.title')}</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm">
|
||||
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm error-box">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-red-600 text-lg font-medium">{t('app.error')}</h3>
|
||||
<h3 className="text-status-red text-lg font-medium">{t('app.error')}</h3>
|
||||
<p className="text-gray-600 mt-1">{error}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="ml-4 text-gray-500 hover:text-gray-700"
|
||||
className="ml-4 text-gray-500 hover:text-gray-700 transition-colors duration-200"
|
||||
aria-label={t('app.closeButton')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
@@ -52,8 +45,8 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center">
|
||||
{isLoading && (
|
||||
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center loading-container">
|
||||
<div className="flex flex-col items-center">
|
||||
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
@@ -62,12 +55,14 @@ const DashboardPage: React.FC = () => {
|
||||
<p className="text-gray-600">{t('app.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
)}
|
||||
|
||||
{!isLoading && (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Total servers */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-blue-100 text-blue-800">
|
||||
<div className="p-3 rounded-full bg-blue-100 text-blue-800 icon-container status-icon-blue">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
@@ -80,9 +75,9 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Online servers */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-green-100 text-green-800">
|
||||
<div className="p-3 rounded-full bg-green-100 text-green-800 icon-container status-icon-green">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
@@ -92,18 +87,12 @@ const DashboardPage: React.FC = () => {
|
||||
<p className="text-3xl font-bold text-gray-900">{serverStats.online}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 h-2 bg-gray-200 rounded-full">
|
||||
<div
|
||||
className="h-full bg-green-500 rounded-full"
|
||||
style={{ width: `${getStatusPercentage('connected')}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Offline servers */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-red-100 text-red-800">
|
||||
<div className="p-3 rounded-full bg-red-100 text-red-800 icon-container status-icon-red">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
@@ -113,18 +102,12 @@ const DashboardPage: React.FC = () => {
|
||||
<p className="text-3xl font-bold text-gray-900">{serverStats.offline}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 h-2 bg-gray-200 rounded-full">
|
||||
<div
|
||||
className="h-full bg-red-500 rounded-full"
|
||||
style={{ width: `${getStatusPercentage('disconnected')}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connecting servers */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-yellow-100 text-yellow-800">
|
||||
<div className="p-3 rounded-full bg-yellow-100 text-yellow-800 icon-container status-icon-yellow">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
@@ -134,12 +117,7 @@ const DashboardPage: React.FC = () => {
|
||||
<p className="text-3xl font-bold text-gray-900">{serverStats.connecting}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 h-2 bg-gray-200 rounded-full">
|
||||
<div
|
||||
className="h-full bg-yellow-500 rounded-full"
|
||||
style={{ width: `${getStatusPercentage('connecting')}%` }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -148,20 +126,20 @@ const DashboardPage: React.FC = () => {
|
||||
{servers.length > 0 && !isLoading && (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">{t('pages.dashboard.recentServers')}</h2>
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden table-container">
|
||||
<table className="min-w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('server.name')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('server.status')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('server.tools')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('server.enabled')}
|
||||
</th>
|
||||
</tr>
|
||||
@@ -173,11 +151,11 @@ const DashboardPage: React.FC = () => {
|
||||
{server.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${server.status === 'connected'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: server.status === 'disconnected'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${server.status === 'connected'
|
||||
? 'status-badge-online'
|
||||
: server.status === 'disconnected'
|
||||
? 'status-badge-offline'
|
||||
: 'status-badge-connecting'
|
||||
}`}>
|
||||
{t(statusTranslations[server.status] || server.status)}
|
||||
</span>
|
||||
@@ -189,7 +167,7 @@ const DashboardPage: React.FC = () => {
|
||||
{server.enabled !== false ? (
|
||||
<span className="text-green-600">✓</span>
|
||||
) : (
|
||||
<span className="text-red-600">✗</span>
|
||||
<span className="text-status-red">✗</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -9,16 +9,16 @@ import GroupCard from '@/components/GroupCard';
|
||||
|
||||
const GroupsPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
groups,
|
||||
loading: groupsLoading,
|
||||
error: groupError,
|
||||
const {
|
||||
groups,
|
||||
loading: groupsLoading,
|
||||
error: groupError,
|
||||
setError: setGroupError,
|
||||
deleteGroup,
|
||||
triggerRefresh
|
||||
} = useGroupData();
|
||||
const { servers } = useServerData();
|
||||
|
||||
|
||||
const [editingGroup, setEditingGroup] = useState<Group | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
|
||||
@@ -32,9 +32,9 @@ const GroupsPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleDeleteGroup = async (groupId: string) => {
|
||||
const success = await deleteGroup(groupId);
|
||||
if (!success) {
|
||||
setGroupError(t('groups.deleteError'));
|
||||
const result = await deleteGroup(groupId);
|
||||
if (!result || !result.success) {
|
||||
setGroupError(result?.message || t('groups.deleteError'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -54,7 +54,7 @@ const GroupsPage: React.FC = () => {
|
||||
<div className="flex space-x-4">
|
||||
<button
|
||||
onClick={handleAddGroup}
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center"
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 3a1 1 0 00-1 1v5H4a1 1 0 100 2h5v5a1 1 0 102 0v-5h5a1 1 0 100-2h-5V4a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
@@ -65,13 +65,13 @@ const GroupsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{groupError && (
|
||||
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6">
|
||||
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6 error-box rounded-lg">
|
||||
<p>{groupError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupsLoading ? (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<div className="bg-white shadow rounded-lg p-6 loading-container">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
@@ -81,7 +81,7 @@ const GroupsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<div className="bg-white shadow rounded-lg p-6 empty-state">
|
||||
<p className="text-gray-600">{t('groups.noGroups')}</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ThemeSwitch from '@/components/ui/ThemeSwitch';
|
||||
import LanguageSwitch from '@/components/ui/LanguageSwitch';
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -26,7 +27,7 @@ const LoginPage: React.FC = () => {
|
||||
}
|
||||
|
||||
const success = await login(username, password);
|
||||
|
||||
|
||||
if (success) {
|
||||
navigate('/');
|
||||
} else {
|
||||
@@ -40,18 +41,19 @@ const LoginPage: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="absolute top-4 right-4">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8 login-container">
|
||||
<div className="absolute top-4 right-4 w-full max-w-xs flex justify-end">
|
||||
<ThemeSwitch />
|
||||
<LanguageSwitch />
|
||||
</div>
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="max-w-md w-full space-y-8 login-card p-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
{t('auth.loginTitle')}
|
||||
</h2>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="sr-only">
|
||||
{t('auth.username')}
|
||||
@@ -62,7 +64,7 @@ const LoginPage: React.FC = () => {
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
className="appearance-none relative block w-full px-3 py-3 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm transition-all duration-200 form-input shadow-sm"
|
||||
placeholder={t('auth.username')}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
@@ -78,7 +80,7 @@ const LoginPage: React.FC = () => {
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
className="appearance-none relative block w-full px-3 py-3 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm login-input transition-all duration-200 form-input shadow-sm"
|
||||
placeholder={t('auth.password')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
@@ -87,14 +89,14 @@ const LoginPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-500 dark:text-red-400 text-sm text-center">{error}</div>
|
||||
<div className="text-red-500 dark:text-red-400 text-sm text-center error-box p-2 rounded">{error}</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 login-button transition-all duration-200 btn-primary"
|
||||
>
|
||||
{loading ? t('auth.loggingIn') : t('auth.login')}
|
||||
</button>
|
||||
|
||||
@@ -11,9 +11,9 @@ const LogsPage: React.FC = () => {
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="text-2xl font-bold">{t('pages.logs.title')}</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{t('pages.logs.title')}</h1>
|
||||
</div>
|
||||
<div className="bg-card rounded-md shadow-sm">
|
||||
<div className="bg-card rounded-md shadow-sm border border-gray-200 page-card">
|
||||
<LogViewer
|
||||
logs={logs}
|
||||
isLoading={loading}
|
||||
|
||||
@@ -1,102 +1,158 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
||||
import { MarketServer, ServerConfig } from '@/types';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { MarketServer, CloudServer, ServerConfig } from '@/types';
|
||||
import { useMarketData } from '@/hooks/useMarketData';
|
||||
import { useCloudData } from '@/hooks/useCloudData';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import { apiPost } from '@/utils/fetchInterceptor';
|
||||
import MarketServerCard from '@/components/MarketServerCard';
|
||||
import MarketServerDetail from '@/components/MarketServerDetail';
|
||||
import CloudServerCard from '@/components/CloudServerCard';
|
||||
import CloudServerDetail from '@/components/CloudServerDetail';
|
||||
import MCPRouterApiKeyError from '@/components/MCPRouterApiKeyError';
|
||||
import Pagination from '@/components/ui/Pagination';
|
||||
|
||||
const MarketPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { serverName } = useParams<{ serverName?: string }>();
|
||||
const { showToast } = useToast();
|
||||
|
||||
|
||||
// Get tab from URL search params, default to cloud market
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const currentTab = searchParams.get('tab') || 'cloud';
|
||||
|
||||
// Local market data
|
||||
const {
|
||||
servers,
|
||||
allServers,
|
||||
categories,
|
||||
tags,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
searchServers,
|
||||
filterByCategory,
|
||||
filterByTag,
|
||||
selectedCategory,
|
||||
selectedTag,
|
||||
installServer,
|
||||
fetchServerByName,
|
||||
servers: localServers,
|
||||
allServers: allLocalServers,
|
||||
categories: localCategories,
|
||||
loading: localLoading,
|
||||
error: localError,
|
||||
setError: setLocalError,
|
||||
searchServers: searchLocalServers,
|
||||
filterByCategory: filterLocalByCategory,
|
||||
filterByTag: filterLocalByTag,
|
||||
selectedCategory: selectedLocalCategory,
|
||||
selectedTag: selectedLocalTag,
|
||||
installServer: installLocalServer,
|
||||
fetchServerByName: fetchLocalServerByName,
|
||||
isServerInstalled,
|
||||
// Pagination
|
||||
currentPage,
|
||||
totalPages,
|
||||
changePage,
|
||||
serversPerPage,
|
||||
changeServersPerPage
|
||||
currentPage: localCurrentPage,
|
||||
totalPages: localTotalPages,
|
||||
changePage: changeLocalPage,
|
||||
serversPerPage: localServersPerPage,
|
||||
changeServersPerPage: changeLocalServersPerPage
|
||||
} = useMarketData();
|
||||
|
||||
// Cloud market data
|
||||
const {
|
||||
servers: cloudServers,
|
||||
allServers: allCloudServers,
|
||||
loading: cloudLoading,
|
||||
error: cloudError,
|
||||
setError: setCloudError,
|
||||
fetchServerTools,
|
||||
callServerTool,
|
||||
// Pagination
|
||||
currentPage: cloudCurrentPage,
|
||||
totalPages: cloudTotalPages,
|
||||
changePage: changeCloudPage,
|
||||
serversPerPage: cloudServersPerPage,
|
||||
changeServersPerPage: changeCloudServersPerPage
|
||||
} = useCloudData();
|
||||
|
||||
const [selectedServer, setSelectedServer] = useState<MarketServer | null>(null);
|
||||
const [selectedCloudServer, setSelectedCloudServer] = useState<CloudServer | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [showTags, setShowTags] = useState(false);
|
||||
const [installedCloudServers, setInstalledCloudServers] = useState<Set<string>>(new Set());
|
||||
|
||||
// Load server details if a server name is in the URL
|
||||
useEffect(() => {
|
||||
const loadServerDetails = async () => {
|
||||
if (serverName) {
|
||||
const server = await fetchServerByName(serverName);
|
||||
if (server) {
|
||||
setSelectedServer(server);
|
||||
// Determine if it's a cloud or local server based on the current tab
|
||||
if (currentTab === 'cloud') {
|
||||
// Try to find the server in cloud servers
|
||||
const server = cloudServers.find(s => s.name === serverName);
|
||||
if (server) {
|
||||
setSelectedCloudServer(server);
|
||||
} else {
|
||||
// If server not found, navigate back to market page
|
||||
navigate('/market?tab=cloud');
|
||||
}
|
||||
} else {
|
||||
// If server not found, navigate back to market page
|
||||
navigate('/market');
|
||||
// Local market
|
||||
const server = await fetchLocalServerByName(serverName);
|
||||
if (server) {
|
||||
setSelectedServer(server);
|
||||
} else {
|
||||
// If server not found, navigate back to market page
|
||||
navigate('/market?tab=local');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setSelectedServer(null);
|
||||
setSelectedCloudServer(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
loadServerDetails();
|
||||
}, [serverName, fetchServerByName, navigate]);
|
||||
}, [serverName, currentTab, cloudServers, fetchLocalServerByName, navigate]);
|
||||
|
||||
// Tab switching handler
|
||||
const switchTab = (tab: 'local' | 'cloud') => {
|
||||
const newSearchParams = new URLSearchParams(searchParams);
|
||||
newSearchParams.set('tab', tab);
|
||||
setSearchParams(newSearchParams);
|
||||
// Clear any selected server when switching tabs
|
||||
if (serverName) {
|
||||
navigate('/market?' + newSearchParams.toString());
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
searchServers(searchQuery);
|
||||
if (currentTab === 'local') {
|
||||
searchLocalServers(searchQuery);
|
||||
}
|
||||
// Cloud search is not implemented in the original cloud page
|
||||
};
|
||||
|
||||
const handleCategoryClick = (category: string) => {
|
||||
filterByCategory(category);
|
||||
};
|
||||
|
||||
const handleTagClick = (tag: string) => {
|
||||
filterByTag(tag);
|
||||
if (currentTab === 'local') {
|
||||
filterLocalByCategory(category);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSearchQuery('');
|
||||
filterByCategory('');
|
||||
filterByTag('');
|
||||
if (currentTab === 'local') {
|
||||
filterLocalByCategory('');
|
||||
filterLocalByTag('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerClick = (server: MarketServer) => {
|
||||
navigate(`/market/${server.name}`);
|
||||
const handleServerClick = (server: MarketServer | CloudServer) => {
|
||||
if (currentTab === 'cloud') {
|
||||
navigate(`/market/${server.name}?tab=cloud`);
|
||||
} else {
|
||||
navigate(`/market/${server.name}?tab=local`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackToList = () => {
|
||||
navigate('/market');
|
||||
navigate(`/market?tab=${currentTab}`);
|
||||
};
|
||||
|
||||
const handleInstall = async (server: MarketServer, config: ServerConfig) => {
|
||||
const handleLocalInstall = async (server: MarketServer, config: ServerConfig) => {
|
||||
try {
|
||||
setInstalling(true);
|
||||
// Pass the server object and the config to the installServer function
|
||||
const success = await installServer(server, config);
|
||||
const success = await installLocalServer(server, config);
|
||||
if (success) {
|
||||
// Show success message using toast instead of alert
|
||||
showToast(t('market.installSuccess', { serverName: server.display_name }), 'success');
|
||||
}
|
||||
} finally {
|
||||
@@ -104,19 +160,75 @@ const MarketPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle cloud server installation
|
||||
const handleCloudInstall = async (server: CloudServer, config: ServerConfig) => {
|
||||
try {
|
||||
setInstalling(true);
|
||||
|
||||
const payload = {
|
||||
name: server.name,
|
||||
config: config
|
||||
};
|
||||
|
||||
const result = await apiPost('/servers', payload);
|
||||
|
||||
if (!result.success) {
|
||||
const errorMessage = result?.message || t('server.addError');
|
||||
showToast(errorMessage, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update installed servers set
|
||||
setInstalledCloudServers(prev => new Set(prev).add(server.name));
|
||||
showToast(t('cloud.installSuccess', { name: server.title || server.name }), 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error installing cloud server:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
showToast(t('cloud.installError', { error: errorMessage }), 'error');
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCallTool = async (serverName: string, toolName: string, args: Record<string, any>) => {
|
||||
try {
|
||||
const result = await callServerTool(serverName, toolName, args);
|
||||
showToast(t('cloud.toolCallSuccess', { toolName }), 'success');
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
// Don't show toast for API key errors, let the component handle it
|
||||
if (!isMCPRouterApiKeyError(errorMessage)) {
|
||||
showToast(t('cloud.toolCallError', { toolName, error: errorMessage }), 'error');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to check if error is MCPRouter API key not configured
|
||||
const isMCPRouterApiKeyError = (errorMessage: string) => {
|
||||
return errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' ||
|
||||
errorMessage.toLowerCase().includes('mcprouter api key not configured');
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
changePage(page);
|
||||
if (currentTab === 'local') {
|
||||
changeLocalPage(page);
|
||||
} else {
|
||||
changeCloudPage(page);
|
||||
}
|
||||
// Scroll to top of page when changing pages
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleChangeItemsPerPage = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newValue = parseInt(e.target.value, 10);
|
||||
changeServersPerPage(newValue);
|
||||
};
|
||||
|
||||
const toggleTagsVisibility = () => {
|
||||
setShowTags(!showTags);
|
||||
if (currentTab === 'local') {
|
||||
changeLocalServersPerPage(newValue);
|
||||
} else {
|
||||
changeCloudServersPerPage(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
// Render detailed view if a server is selected
|
||||
@@ -125,164 +237,201 @@ const MarketPage: React.FC = () => {
|
||||
<MarketServerDetail
|
||||
server={selectedServer}
|
||||
onBack={handleBackToList}
|
||||
onInstall={handleInstall}
|
||||
onInstall={handleLocalInstall}
|
||||
installing={installing}
|
||||
isInstalled={isServerInstalled(selectedServer.name)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Render cloud server detail if selected
|
||||
if (selectedCloudServer) {
|
||||
return (
|
||||
<CloudServerDetail
|
||||
serverName={selectedCloudServer.name}
|
||||
onBack={handleBackToList}
|
||||
onCallTool={handleCallTool}
|
||||
fetchServerTools={fetchServerTools}
|
||||
onInstall={handleCloudInstall}
|
||||
installing={installing}
|
||||
isInstalled={installedCloudServers.has(selectedCloudServer.name)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Get current data based on active tab
|
||||
const isLocalTab = currentTab === 'local';
|
||||
const servers = isLocalTab ? localServers : cloudServers;
|
||||
const allServers = isLocalTab ? allLocalServers : allCloudServers;
|
||||
const categories = isLocalTab ? localCategories : [];
|
||||
const loading = isLocalTab ? localLoading : cloudLoading;
|
||||
const error = isLocalTab ? localError : cloudError;
|
||||
const setError = isLocalTab ? setLocalError : setCloudError;
|
||||
const selectedCategory = isLocalTab ? selectedLocalCategory : '';
|
||||
const selectedTag = isLocalTab ? selectedLocalTag : '';
|
||||
const currentPage = isLocalTab ? localCurrentPage : cloudCurrentPage;
|
||||
const totalPages = isLocalTab ? localTotalPages : cloudTotalPages;
|
||||
const serversPerPage = isLocalTab ? localServersPerPage : cloudServersPerPage;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center">
|
||||
{t('market.title')}
|
||||
<span className="text-sm text-gray-500 font-normal ml-2">{t('pages.market.title').split(' - ')[1]}</span>
|
||||
</h1>
|
||||
{/* Tab Navigation */}
|
||||
<div className="mb-6">
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-3">
|
||||
<button
|
||||
onClick={() => switchTab('cloud')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${!isLocalTab
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t('cloud.title')}
|
||||
<span className="text-xs text-gray-400 font-normal ml-1">(
|
||||
<a
|
||||
href="https://mcprouter.co"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="external-link"
|
||||
>
|
||||
MCPRouter
|
||||
</a>
|
||||
)
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => switchTab('local')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${isLocalTab
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t('market.title')}
|
||||
<span className="text-xs text-gray-400 font-normal ml-1">(
|
||||
<a
|
||||
href="https://mcpm.sh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="external-link"
|
||||
>
|
||||
MCPM
|
||||
</a>
|
||||
)
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p>{error}</p>
|
||||
<>
|
||||
{!isLocalTab && isMCPRouterApiKeyError(error) ? (
|
||||
<MCPRouterApiKeyError />
|
||||
) : (
|
||||
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6 error-box rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<p>{error}</p>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="text-red-700 hover:text-red-900 transition-colors duration-200"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 01.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Search bar for local market only */}
|
||||
{isLocalTab && (
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6 page-card">
|
||||
<form onSubmit={handleSearch} className="flex space-x-4 mb-0">
|
||||
<div className="flex-grow">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('market.searchPlaceholder')}
|
||||
className="shadow appearance-none border border-gray-200 rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="text-red-700 hover:text-red-900"
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 01.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{t('market.search')}
|
||||
</button>
|
||||
</div>
|
||||
{(searchQuery || selectedCategory || selectedTag) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearFilters}
|
||||
className="border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded hover:bg-gray-50 btn-secondary transition-all duration-200"
|
||||
>
|
||||
{t('market.clearFilters')}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search bar at the top */}
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<form onSubmit={handleSearch} className="flex space-x-4 mb-0">
|
||||
<div className="flex-grow">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('market.searchPlaceholder')}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded"
|
||||
>
|
||||
{t('market.search')}
|
||||
</button>
|
||||
{(searchQuery || selectedCategory || selectedTag) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearFilters}
|
||||
className="border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded hover:bg-gray-50"
|
||||
>
|
||||
{t('market.clearFilters')}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
{/* Left sidebar for filters (without search) */}
|
||||
<div className="md:w-48 flex-shrink-0">
|
||||
<div className="bg-white shadow rounded-lg p-4 mb-6 sticky top-4">
|
||||
{/* Categories */}
|
||||
{categories.length > 0 ? (
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
|
||||
{selectedCategory && (
|
||||
<span className="text-xs text-blue-600 cursor-pointer hover:underline" onClick={() => filterByCategory('')}>
|
||||
{t('market.clearCategoryFilter')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => handleCategoryClick(category)}
|
||||
className={`px-3 py-2 rounded text-sm text-left ${selectedCategory === category
|
||||
? 'bg-blue-100 text-blue-800 font-medium'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="mb-6">
|
||||
<div className="mb-3">
|
||||
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 items-center py-4">
|
||||
<svg className="animate-spin h-6 w-6 text-blue-500 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p className="text-sm text-gray-600">{t('app.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-6">
|
||||
<div className="mb-3">
|
||||
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 py-2">{t('market.noCategories')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{/* {tags.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<div className="flex items-center">
|
||||
<h3 className="font-medium text-gray-900">{t('market.tags')}</h3>
|
||||
<button
|
||||
onClick={toggleTagsVisibility}
|
||||
className="ml-2 p-1 text-gray-600 hover:text-blue-600 hover:bg-gray-100 rounded-full"
|
||||
aria-label={showTags ? t('market.hideTags') : t('market.showTags')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className={`h-5 w-5 transition-transform ${showTags ? 'rotate-180' : ''}`} viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M5.293 7.293a1 1 011.414 0L10 10.586l3.293-3.293a1 1 011.414 1.414l-4 4a1 1 01-1.414 0l-4-4a1 1 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Left sidebar for filters (local market only) */}
|
||||
{isLocalTab && (
|
||||
<div className="md:w-48 flex-shrink-0">
|
||||
<div className="bg-white shadow rounded-lg p-4 mb-6 sticky top-4 page-card">
|
||||
{/* Categories */}
|
||||
{categories.length > 0 ? (
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
|
||||
{selectedCategory && (
|
||||
<span className="text-xs text-blue-600 cursor-pointer hover:underline transition-colors duration-200" onClick={() => filterLocalByCategory('')}>
|
||||
{t('market.clearCategoryFilter')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedTag && (
|
||||
<span className="text-xs text-blue-600 cursor-pointer hover:underline" onClick={() => filterByTag('')}>
|
||||
{t('market.clearTagFilter')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showTags && (
|
||||
<div className="flex flex-wrap gap-2 max-h-48 overflow-y-auto pr-2">
|
||||
{tags.map((tag) => (
|
||||
<div className="flex flex-col gap-2">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => handleTagClick(tag)}
|
||||
className={`px-2 py-1 rounded text-xs ${selectedTag === tag
|
||||
? 'bg-green-100 text-green-800 font-medium'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
key={category}
|
||||
onClick={() => handleCategoryClick(category)}
|
||||
className={`px-3 py-2 rounded text-sm text-left transition-all duration-200 ${selectedCategory === category
|
||||
? 'bg-blue-100 text-blue-800 font-medium btn-primary'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-secondary'
|
||||
}`}
|
||||
>
|
||||
#{tag}
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="mb-6">
|
||||
<div className="mb-3">
|
||||
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 items-center py-4 loading-container">
|
||||
<svg className="animate-spin h-6 w-6 text-blue-500 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p className="text-sm text-gray-600">{t('app.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-6">
|
||||
<div className="mb-3">
|
||||
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 py-2">{t('market.noCategories')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex-grow">
|
||||
@@ -298,27 +447,43 @@ const MarketPage: React.FC = () => {
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<p className="text-gray-600">{t('market.noServers')}</p>
|
||||
<p className="text-gray-600">{isLocalTab ? t('market.noServers') : t('cloud.noServers')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{servers.map((server, index) => (
|
||||
<MarketServerCard
|
||||
key={index}
|
||||
server={server}
|
||||
onClick={handleServerClick}
|
||||
/>
|
||||
isLocalTab ? (
|
||||
<MarketServerCard
|
||||
key={index}
|
||||
server={server as MarketServer}
|
||||
onClick={handleServerClick}
|
||||
/>
|
||||
) : (
|
||||
<CloudServerCard
|
||||
key={index}
|
||||
server={server as CloudServer}
|
||||
onClick={handleServerClick}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="text-sm text-gray-500">
|
||||
{t('market.showing', {
|
||||
from: (currentPage - 1) * serversPerPage + 1,
|
||||
to: Math.min(currentPage * serversPerPage, allServers.length),
|
||||
total: allServers.length
|
||||
})}
|
||||
{isLocalTab ? (
|
||||
t('market.showing', {
|
||||
from: (currentPage - 1) * serversPerPage + 1,
|
||||
to: Math.min(currentPage * serversPerPage, allServers.length),
|
||||
total: allServers.length
|
||||
})
|
||||
) : (
|
||||
t('cloud.showing', {
|
||||
from: (currentPage - 1) * serversPerPage + 1,
|
||||
to: Math.min(currentPage * serversPerPage, allServers.length),
|
||||
total: allServers.length
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
@@ -327,13 +492,13 @@ const MarketPage: React.FC = () => {
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<label htmlFor="perPage" className="text-sm text-gray-600">
|
||||
{t('market.perPage')}:
|
||||
{isLocalTab ? t('market.perPage') : t('cloud.perPage')}:
|
||||
</label>
|
||||
<select
|
||||
id="perPage"
|
||||
value={serversPerPage}
|
||||
onChange={handleChangeItemsPerPage}
|
||||
className="border rounded p-1 text-sm"
|
||||
className="border rounded p-1 text-sm btn-secondary outline-none"
|
||||
>
|
||||
<option value="6">6</option>
|
||||
<option value="9">9</option>
|
||||
@@ -344,7 +509,6 @@ const MarketPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import ServerCard from '@/components/ServerCard';
|
||||
import AddServerForm from '@/components/AddServerForm';
|
||||
import EditServerForm from '@/components/EditServerForm';
|
||||
import { useServerData } from '@/hooks/useServerData';
|
||||
import DxtUploadForm from '@/components/DxtUploadForm';
|
||||
|
||||
const ServersPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -23,6 +24,7 @@ const ServersPage: React.FC = () => {
|
||||
} = useServerData();
|
||||
const [editingServer, setEditingServer] = useState<Server | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [showDxtUpload, setShowDxtUpload] = useState(false);
|
||||
|
||||
const handleEditClick = async (server: Server) => {
|
||||
const fullServerData = await handleServerEdit(server);
|
||||
@@ -47,6 +49,12 @@ const ServersPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDxtUploadSuccess = (_serverConfig: any) => {
|
||||
// Close upload dialog and refresh servers
|
||||
setShowDxtUpload(false);
|
||||
triggerRefresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
@@ -54,7 +62,7 @@ const ServersPage: React.FC = () => {
|
||||
<div className="flex space-x-4">
|
||||
<button
|
||||
onClick={() => navigate('/market')}
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center"
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M3 1a1 1 0 000 2h1.22l.305 1.222a.997.997 0 00.01.042l1.358 5.43-.893.892C3.74 11.846 4.632 14 6.414 14H15a1 1 0 000-2H6.414l1-1H14a1 1 0 00.894-.553l3-6A1 1 0 0017 3H6.28l-.31-1.243A1 1 0 005 1H3z" />
|
||||
@@ -62,10 +70,19 @@ const ServersPage: React.FC = () => {
|
||||
{t('nav.market')}
|
||||
</button>
|
||||
<AddServerForm onAdd={handleServerAdd} />
|
||||
<button
|
||||
onClick={() => setShowDxtUpload(true)}
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M5.5 13a3.5 3.5 0 01-.369-6.98 4 4 0 117.753-1.977A4.5 4.5 0 1113.5 13H11V9.413l1.293 1.293a1 1 0 001.414-1.414l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 001.414 1.414L9 9.413V13H5.5z" />
|
||||
</svg>
|
||||
{t('dxt.upload')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className={`px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center ${isRefreshing ? 'opacity-70 cursor-not-allowed' : ''}`}
|
||||
className={`px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200 ${isRefreshing ? 'opacity-70 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<svg className="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
@@ -83,15 +100,14 @@ const ServersPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm">
|
||||
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm error-box">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-red-600 text-lg font-medium">{t('app.error')}</h3>
|
||||
<p className="text-gray-600 mt-1">{error}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="ml-4 text-gray-500 hover:text-gray-700"
|
||||
className="ml-4 text-gray-500 hover:text-gray-700 transition-colors duration-200 btn-secondary"
|
||||
aria-label={t('app.closeButton')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
@@ -103,7 +119,7 @@ const ServersPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center">
|
||||
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center loading-container">
|
||||
<div className="flex flex-col items-center">
|
||||
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
@@ -113,7 +129,7 @@ const ServersPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<div className="bg-white shadow rounded-lg p-6 empty-state">
|
||||
<p className="text-gray-600">{t('app.noServers')}</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -138,6 +154,13 @@ const ServersPage: React.FC = () => {
|
||||
onCancel={() => setEditingServer(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDxtUpload && (
|
||||
<DxtUploadForm
|
||||
onSuccess={handleDxtUploadSuccess}
|
||||
onCancel={() => setShowDxtUpload(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,24 +6,22 @@ import { Switch } from '@/components/ui/ToggleGroup';
|
||||
import { useSettingsData } from '@/hooks/useSettingsData';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import { generateRandomKey } from '@/utils/key';
|
||||
import { PermissionChecker } from '@/components/PermissionChecker';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const [currentLanguage, setCurrentLanguage] = useState(i18n.language);
|
||||
|
||||
// Update current language when it changes
|
||||
useEffect(() => {
|
||||
setCurrentLanguage(i18n.language);
|
||||
}, [i18n.language]);
|
||||
|
||||
const [installConfig, setInstallConfig] = useState<{
|
||||
pythonIndexUrl: string;
|
||||
npmRegistry: string;
|
||||
baseUrl: string;
|
||||
}>({
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
});
|
||||
|
||||
const [tempSmartRoutingConfig, setTempSmartRoutingConfig] = useState<{
|
||||
@@ -38,18 +36,32 @@ const SettingsPage: React.FC = () => {
|
||||
openaiApiEmbeddingModel: '',
|
||||
});
|
||||
|
||||
const [tempMCPRouterConfig, setTempMCPRouterConfig] = useState<{
|
||||
apiKey: string;
|
||||
referer: string;
|
||||
title: string;
|
||||
baseUrl: string;
|
||||
}>({
|
||||
apiKey: '',
|
||||
referer: 'https://mcphub.app',
|
||||
title: 'MCPHub',
|
||||
baseUrl: 'https://api.mcprouter.to/v1',
|
||||
});
|
||||
|
||||
const {
|
||||
routingConfig,
|
||||
tempRoutingConfig,
|
||||
setTempRoutingConfig,
|
||||
installConfig: savedInstallConfig,
|
||||
smartRoutingConfig,
|
||||
mcpRouterConfig,
|
||||
loading,
|
||||
updateRoutingConfig,
|
||||
updateRoutingConfigBatch,
|
||||
updateInstallConfig,
|
||||
updateSmartRoutingConfig,
|
||||
updateSmartRoutingConfigBatch
|
||||
updateSmartRoutingConfigBatch,
|
||||
updateMCPRouterConfig
|
||||
} = useSettingsData();
|
||||
|
||||
// Update local installConfig when savedInstallConfig changes
|
||||
@@ -71,21 +83,34 @@ const SettingsPage: React.FC = () => {
|
||||
}
|
||||
}, [smartRoutingConfig]);
|
||||
|
||||
// Update local tempMCPRouterConfig when mcpRouterConfig changes
|
||||
useEffect(() => {
|
||||
if (mcpRouterConfig) {
|
||||
setTempMCPRouterConfig({
|
||||
apiKey: mcpRouterConfig.apiKey || '',
|
||||
referer: mcpRouterConfig.referer || 'https://mcphub.app',
|
||||
title: mcpRouterConfig.title || 'MCPHub',
|
||||
baseUrl: mcpRouterConfig.baseUrl || 'https://api.mcprouter.to/v1',
|
||||
});
|
||||
}
|
||||
}, [mcpRouterConfig]);
|
||||
|
||||
const [sectionsVisible, setSectionsVisible] = useState({
|
||||
routingConfig: false,
|
||||
installConfig: false,
|
||||
smartRoutingConfig: false,
|
||||
mcpRouterConfig: false,
|
||||
password: false
|
||||
});
|
||||
|
||||
const toggleSection = (section: 'routingConfig' | 'installConfig' | 'smartRoutingConfig' | 'password') => {
|
||||
const toggleSection = (section: 'routingConfig' | 'installConfig' | 'smartRoutingConfig' | 'mcpRouterConfig' | 'password') => {
|
||||
setSectionsVisible(prev => ({
|
||||
...prev,
|
||||
[section]: !prev[section]
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRoutingConfigChange = async (key: 'enableGlobalRoute' | 'enableGroupNameRoute' | 'enableBearerAuth' | 'bearerAuthKey', value: boolean | string) => {
|
||||
const handleRoutingConfigChange = async (key: 'enableGlobalRoute' | 'enableGroupNameRoute' | 'enableBearerAuth' | 'bearerAuthKey' | 'skipAuth', value: boolean | string) => {
|
||||
// If enableBearerAuth is turned on and there's no key, generate one first
|
||||
if (key === 'enableBearerAuth' && value === true) {
|
||||
if (!tempRoutingConfig.bearerAuthKey && !routingConfig.bearerAuthKey) {
|
||||
@@ -123,14 +148,14 @@ const SettingsPage: React.FC = () => {
|
||||
await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey);
|
||||
};
|
||||
|
||||
const handleInstallConfigChange = (key: 'pythonIndexUrl' | 'npmRegistry', value: string) => {
|
||||
const handleInstallConfigChange = (key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl', value: string) => {
|
||||
setInstallConfig({
|
||||
...installConfig,
|
||||
[key]: value
|
||||
});
|
||||
};
|
||||
|
||||
const saveInstallConfig = async (key: 'pythonIndexUrl' | 'npmRegistry') => {
|
||||
const saveInstallConfig = async (key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl') => {
|
||||
await updateInstallConfig(key, installConfig[key]);
|
||||
};
|
||||
|
||||
@@ -145,6 +170,17 @@ const SettingsPage: React.FC = () => {
|
||||
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key]);
|
||||
};
|
||||
|
||||
const handleMCPRouterConfigChange = (key: 'apiKey' | 'referer' | 'title' | 'baseUrl', value: string) => {
|
||||
setTempMCPRouterConfig({
|
||||
...tempMCPRouterConfig,
|
||||
[key]: value
|
||||
});
|
||||
};
|
||||
|
||||
const saveMCPRouterConfig = async (key: 'apiKey' | 'referer' | 'title' | 'baseUrl') => {
|
||||
await updateMCPRouterConfig(key, tempMCPRouterConfig[key]);
|
||||
};
|
||||
|
||||
const handleSmartRoutingEnabledChange = async (value: boolean) => {
|
||||
// If enabling Smart Routing, validate required fields and save any unsaved changes
|
||||
if (value) {
|
||||
@@ -193,169 +229,254 @@ const SettingsPage: React.FC = () => {
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleLanguageChange = (lang: string) => {
|
||||
localStorage.setItem('i18nextLng', lang);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-8">{t('pages.settings.title')}</h1>
|
||||
|
||||
{/* Language Settings */}
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold text-gray-800">{t('pages.settings.language')}</h2>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
className={`px-3 py-1.5 rounded-md transition-colors text-sm ${currentLanguage.startsWith('en')
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-blue-100 text-blue-800 hover:bg-blue-200'
|
||||
}`}
|
||||
onClick={() => handleLanguageChange('en')}
|
||||
>
|
||||
English
|
||||
</button>
|
||||
<button
|
||||
className={`px-3 py-1.5 rounded-md transition-colors text-sm ${currentLanguage.startsWith('zh')
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-blue-100 text-blue-800 hover:bg-blue-200'
|
||||
}`}
|
||||
onClick={() => handleLanguageChange('zh')}
|
||||
>
|
||||
中文
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Smart Routing Configuration Settings */}
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('smartRoutingConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('pages.settings.smartRouting')}</h2>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.smartRoutingConfig ? '▼' : '►'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.smartRoutingConfig && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableSmartRouting')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.enableSmartRoutingDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={smartRoutingConfig.enabled}
|
||||
onCheckedChange={(checked) => handleSmartRoutingEnabledChange(checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">
|
||||
<span className="text-red-500 px-1">*</span>{t('settings.dbUrl')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempSmartRoutingConfig.dbUrl}
|
||||
onChange={(e) => handleSmartRoutingConfigChange('dbUrl', e.target.value)}
|
||||
placeholder={t('settings.dbUrlPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveSmartRoutingConfig('dbUrl')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">
|
||||
<span className="text-red-500 px-1">*</span>{t('settings.openaiApiKey')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="password"
|
||||
value={tempSmartRoutingConfig.openaiApiKey}
|
||||
onChange={(e) => handleSmartRoutingConfigChange('openaiApiKey', e.target.value)}
|
||||
placeholder={t('settings.openaiApiKeyPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveSmartRoutingConfig('openaiApiKey')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.openaiApiBaseUrl')}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempSmartRoutingConfig.openaiApiBaseUrl}
|
||||
onChange={(e) => handleSmartRoutingConfigChange('openaiApiBaseUrl', e.target.value)}
|
||||
placeholder={t('settings.openaiApiBaseUrlPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveSmartRoutingConfig('openaiApiBaseUrl')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.openaiApiEmbeddingModel')}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempSmartRoutingConfig.openaiApiEmbeddingModel}
|
||||
onChange={(e) => handleSmartRoutingConfigChange('openaiApiEmbeddingModel', e.target.value)}
|
||||
placeholder={t('settings.openaiApiEmbeddingModelPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveSmartRoutingConfig('openaiApiEmbeddingModel')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<PermissionChecker permissions={PERMISSIONS.SETTINGS_SMART_ROUTING}>
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 page-card dashboard-card">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer transition-colors duration-200 hover:text-blue-600"
|
||||
onClick={() => toggleSection('smartRoutingConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('pages.settings.smartRouting')}</h2>
|
||||
<span className="text-gray-500 transition-transform duration-200">
|
||||
{sectionsVisible.smartRoutingConfig ? '▼' : '►'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sectionsVisible.smartRoutingConfig && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableSmartRouting')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.enableSmartRoutingDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={smartRoutingConfig.enabled}
|
||||
onCheckedChange={(checked) => handleSmartRoutingEnabledChange(checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">
|
||||
<span className="text-red-500 px-1">*</span>{t('settings.dbUrl')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempSmartRoutingConfig.dbUrl}
|
||||
onChange={(e) => handleSmartRoutingConfigChange('dbUrl', e.target.value)}
|
||||
placeholder={t('settings.dbUrlPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300 form-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveSmartRoutingConfig('dbUrl')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">
|
||||
<span className="text-red-500 px-1">*</span>{t('settings.openaiApiKey')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="password"
|
||||
value={tempSmartRoutingConfig.openaiApiKey}
|
||||
onChange={(e) => handleSmartRoutingConfigChange('openaiApiKey', e.target.value)}
|
||||
placeholder={t('settings.openaiApiKeyPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveSmartRoutingConfig('openaiApiKey')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.openaiApiBaseUrl')}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempSmartRoutingConfig.openaiApiBaseUrl}
|
||||
onChange={(e) => handleSmartRoutingConfigChange('openaiApiBaseUrl', e.target.value)}
|
||||
placeholder={t('settings.openaiApiBaseUrlPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveSmartRoutingConfig('openaiApiBaseUrl')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.openaiApiEmbeddingModel')}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempSmartRoutingConfig.openaiApiEmbeddingModel}
|
||||
onChange={(e) => handleSmartRoutingConfigChange('openaiApiEmbeddingModel', e.target.value)}
|
||||
placeholder={t('settings.openaiApiEmbeddingModelPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveSmartRoutingConfig('openaiApiEmbeddingModel')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
|
||||
{/* MCPRouter Configuration Settings */}
|
||||
<PermissionChecker permissions={PERMISSIONS.SETTINGS_INSTALL_CONFIG}>
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 page-card dashboard-card">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer transition-colors duration-200 hover:text-blue-600"
|
||||
onClick={() => toggleSection('mcpRouterConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('settings.mcpRouterConfig')}</h2>
|
||||
<span className="text-gray-500 transition-transform duration-200">
|
||||
{sectionsVisible.mcpRouterConfig ? '▼' : '►'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.mcpRouterConfig && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.mcpRouterApiKey')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.mcpRouterApiKeyDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="password"
|
||||
value={tempMCPRouterConfig.apiKey}
|
||||
onChange={(e) => handleMCPRouterConfigChange('apiKey', e.target.value)}
|
||||
placeholder={t('settings.mcpRouterApiKeyPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300 form-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveMCPRouterConfig('apiKey')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.mcpRouterReferer')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.mcpRouterRefererDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempMCPRouterConfig.referer}
|
||||
onChange={(e) => handleMCPRouterConfigChange('referer', e.target.value)}
|
||||
placeholder={t('settings.mcpRouterRefererPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveMCPRouterConfig('referer')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.mcpRouterTitle')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.mcpRouterTitleDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempMCPRouterConfig.title}
|
||||
onChange={(e) => handleMCPRouterConfigChange('title', e.target.value)}
|
||||
placeholder={t('settings.mcpRouterTitlePlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveMCPRouterConfig('title')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.mcpRouterBaseUrl')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.mcpRouterBaseUrlDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempMCPRouterConfig.baseUrl}
|
||||
onChange={(e) => handleMCPRouterConfigChange('baseUrl', e.target.value)}
|
||||
placeholder={t('settings.mcpRouterBaseUrlPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveMCPRouterConfig('baseUrl')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
|
||||
{/* Route Configuration Settings */}
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('routingConfig')}
|
||||
@@ -392,13 +513,13 @@ const SettingsPage: React.FC = () => {
|
||||
value={tempRoutingConfig.bearerAuthKey}
|
||||
onChange={(e) => handleBearerAuthKeyChange(e.target.value)}
|
||||
placeholder={t('settings.bearerAuthKeyPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading || !routingConfig.enableBearerAuth}
|
||||
/>
|
||||
<button
|
||||
onClick={saveBearerAuthKey}
|
||||
disabled={loading || !routingConfig.enableBearerAuth}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
@@ -430,77 +551,117 @@ const SettingsPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PermissionChecker permissions={PERMISSIONS.SETTINGS_SKIP_AUTH}>
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.skipAuth')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.skipAuthDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.skipAuth}
|
||||
onCheckedChange={(checked) => handleRoutingConfigChange('skipAuth', checked)}
|
||||
/>
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Installation Configuration Settings */}
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('installConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('settings.installConfig')}</h2>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.installConfig ? '▼' : '►'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.installConfig && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.pythonIndexUrl')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.pythonIndexUrlDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={installConfig.pythonIndexUrl}
|
||||
onChange={(e) => handleInstallConfigChange('pythonIndexUrl', e.target.value)}
|
||||
placeholder={t('settings.pythonIndexUrlPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveInstallConfig('pythonIndexUrl')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.npmRegistry')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.npmRegistryDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={installConfig.npmRegistry}
|
||||
onChange={(e) => handleInstallConfigChange('npmRegistry', e.target.value)}
|
||||
placeholder={t('settings.npmRegistryPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveInstallConfig('npmRegistry')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<PermissionChecker permissions={PERMISSIONS.SETTINGS_INSTALL_CONFIG}>
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('installConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('settings.installConfig')}</h2>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.installConfig ? '▼' : '►'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sectionsVisible.installConfig && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.baseUrl')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.baseUrlDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={installConfig.baseUrl}
|
||||
onChange={(e) => handleInstallConfigChange('baseUrl', e.target.value)}
|
||||
placeholder={t('settings.baseUrlPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveInstallConfig('baseUrl')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.pythonIndexUrl')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.pythonIndexUrlDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={installConfig.pythonIndexUrl}
|
||||
onChange={(e) => handleInstallConfigChange('pythonIndexUrl', e.target.value)}
|
||||
placeholder={t('settings.pythonIndexUrlPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveInstallConfig('pythonIndexUrl')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.npmRegistry')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.npmRegistryDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={installConfig.npmRegistry}
|
||||
onChange={(e) => handleInstallConfigChange('npmRegistry', e.target.value)}
|
||||
placeholder={t('settings.npmRegistryPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveInstallConfig('npmRegistry')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
|
||||
{/* Change Password */}
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('password')}
|
||||
|
||||
9
frontend/src/pages/UsersPage.tsx
Normal file
9
frontend/src/pages/UsersPage.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
const UsersPage: React.FC = () => {
|
||||
return (
|
||||
<div></div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsersPage;
|
||||
@@ -4,45 +4,27 @@ import {
|
||||
RegisterCredentials,
|
||||
ChangePasswordCredentials,
|
||||
} from '../types';
|
||||
import { getApiUrl } from '../utils/runtime';
|
||||
import { apiPost, apiGet } from '../utils/fetchInterceptor';
|
||||
import { getToken, setToken, removeToken } from '../utils/interceptors';
|
||||
|
||||
// Token key in localStorage
|
||||
const TOKEN_KEY = 'mcphub_token';
|
||||
|
||||
// Get token from localStorage
|
||||
export const getToken = (): string | null => {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
};
|
||||
|
||||
// Set token in localStorage
|
||||
export const setToken = (token: string): void => {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
};
|
||||
|
||||
// Remove token from localStorage
|
||||
export const removeToken = (): void => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
};
|
||||
// Export token management functions
|
||||
export { getToken, setToken, removeToken };
|
||||
|
||||
// Login user
|
||||
export const login = async (credentials: LoginCredentials): Promise<AuthResponse> => {
|
||||
try {
|
||||
console.log(getApiUrl('/auth/login'));
|
||||
const response = await fetch(getApiUrl('/auth/login'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(credentials),
|
||||
});
|
||||
const response = await apiPost<AuthResponse>('/auth/login', credentials);
|
||||
|
||||
const data: AuthResponse = await response.json();
|
||||
|
||||
if (data.success && data.token) {
|
||||
setToken(data.token);
|
||||
// The auth API returns data directly, not wrapped in a data field
|
||||
if (response.success && response.token) {
|
||||
setToken(response.token);
|
||||
return response;
|
||||
}
|
||||
|
||||
return data;
|
||||
return {
|
||||
success: false,
|
||||
message: response.message || 'Login failed',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return {
|
||||
@@ -55,21 +37,17 @@ export const login = async (credentials: LoginCredentials): Promise<AuthResponse
|
||||
// Register user
|
||||
export const register = async (credentials: RegisterCredentials): Promise<AuthResponse> => {
|
||||
try {
|
||||
const response = await fetch(getApiUrl('/auth/register'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(credentials),
|
||||
});
|
||||
const response = await apiPost<AuthResponse>('/auth/register', credentials);
|
||||
|
||||
const data: AuthResponse = await response.json();
|
||||
|
||||
if (data.success && data.token) {
|
||||
setToken(data.token);
|
||||
if (response.success && response.token) {
|
||||
setToken(response.token);
|
||||
return response;
|
||||
}
|
||||
|
||||
return data;
|
||||
return {
|
||||
success: false,
|
||||
message: response.message || 'Registration failed',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Register error:', error);
|
||||
return {
|
||||
@@ -91,14 +69,8 @@ export const getCurrentUser = async (): Promise<AuthResponse> => {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(getApiUrl('/auth/user'), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-auth-token': token,
|
||||
},
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
const response = await apiGet<AuthResponse>('/auth/user');
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Get current user error:', error);
|
||||
return {
|
||||
@@ -122,16 +94,8 @@ export const changePassword = async (
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(getApiUrl('/auth/change-password'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token,
|
||||
},
|
||||
body: JSON.stringify(credentials),
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
const response = await apiPost<AuthResponse>('/auth/change-password', credentials);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Change password error:', error);
|
||||
return {
|
||||
|
||||
98
frontend/src/services/configService.ts
Normal file
98
frontend/src/services/configService.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { apiGet, fetchWithInterceptors } from '../utils/fetchInterceptor';
|
||||
import { getBasePath } from '../utils/runtime';
|
||||
|
||||
export interface SystemConfig {
|
||||
routing?: {
|
||||
enableGlobalRoute?: boolean;
|
||||
enableGroupNameRoute?: boolean;
|
||||
enableBearerAuth?: boolean;
|
||||
bearerAuthKey?: string;
|
||||
skipAuth?: boolean;
|
||||
};
|
||||
install?: {
|
||||
pythonIndexUrl?: string;
|
||||
npmRegistry?: string;
|
||||
};
|
||||
smartRouting?: {
|
||||
enabled?: boolean;
|
||||
dbUrl?: string;
|
||||
openaiApiBaseUrl?: string;
|
||||
openaiApiKey?: string;
|
||||
openaiApiEmbeddingModel?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PublicConfigResponse {
|
||||
success: boolean;
|
||||
data?: {
|
||||
skipAuth?: boolean;
|
||||
permissions?: any;
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface SystemConfigResponse {
|
||||
success: boolean;
|
||||
data?: {
|
||||
systemConfig?: SystemConfig;
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public configuration (skipAuth setting) without authentication
|
||||
*/
|
||||
export const getPublicConfig = async (): Promise<{ skipAuth: boolean; permissions?: any }> => {
|
||||
try {
|
||||
const basePath = getBasePath();
|
||||
const response = await fetchWithInterceptors(`${basePath}/public-config`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data: PublicConfigResponse = await response.json();
|
||||
return { skipAuth: data.data?.skipAuth === true, permissions: data.data?.permissions || {} };
|
||||
}
|
||||
|
||||
return { skipAuth: false };
|
||||
} catch (error) {
|
||||
console.debug('Failed to get public config:', error);
|
||||
return { skipAuth: false };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get system configuration without authentication
|
||||
* This function tries to get the system configuration first without auth,
|
||||
* and if that fails (likely due to auth requirements), it returns null
|
||||
*/
|
||||
export const getSystemConfigPublic = async (): Promise<SystemConfig | null> => {
|
||||
try {
|
||||
const response = await apiGet<SystemConfigResponse>('/settings');
|
||||
|
||||
if (response.success) {
|
||||
return response.data?.systemConfig || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.debug('Failed to get system config without auth:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if authentication should be skipped based on system configuration
|
||||
*/
|
||||
export const shouldSkipAuth = async (): Promise<boolean> => {
|
||||
try {
|
||||
const config = await getPublicConfig();
|
||||
return config.skipAuth;
|
||||
} catch (error) {
|
||||
console.debug('Failed to check skipAuth setting:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getToken } from './authService'; // Import getToken function
|
||||
import { apiGet, apiDelete } from '../utils/fetchInterceptor';
|
||||
import { getApiUrl } from '../utils/runtime';
|
||||
import { getToken } from '../utils/interceptors';
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: number;
|
||||
@@ -13,25 +14,13 @@ export interface LogEntry {
|
||||
// Fetch all logs
|
||||
export const fetchLogs = async (): Promise<LogEntry[]> => {
|
||||
try {
|
||||
// Get authentication token
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
throw new Error('Authentication token not found. Please log in.');
|
||||
const response = await apiGet<{ success: boolean; data: LogEntry[]; error?: string }>('/logs');
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to fetch logs');
|
||||
}
|
||||
|
||||
const response = await fetch(getApiUrl('/logs'), {
|
||||
headers: {
|
||||
'x-auth-token': token,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to fetch logs');
|
||||
}
|
||||
|
||||
return result.data;
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching logs:', error);
|
||||
throw error;
|
||||
@@ -41,23 +30,10 @@ export const fetchLogs = async (): Promise<LogEntry[]> => {
|
||||
// Clear all logs
|
||||
export const clearLogs = async (): Promise<void> => {
|
||||
try {
|
||||
// Get authentication token
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
throw new Error('Authentication token not found. Please log in.');
|
||||
}
|
||||
const response = await apiDelete<{ success: boolean; error?: string }>('/logs');
|
||||
|
||||
const response = await fetch(getApiUrl('/logs'), {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-auth-token': token,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to clear logs');
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to clear logs');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error clearing logs:', error);
|
||||
@@ -84,12 +60,6 @@ export const useLogs = () => {
|
||||
|
||||
// Get the authentication token
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
setError(new Error('Authentication token not found. Please log in.'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Connect to SSE endpoint with auth token in URL
|
||||
eventSource = new EventSource(getApiUrl(`/logs/stream?token=${token}`));
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getApiUrl } from '../utils/runtime';
|
||||
import { getToken } from './authService';
|
||||
import { apiPost, apiPut } from '../utils/fetchInterceptor';
|
||||
|
||||
export interface ToolCallRequest {
|
||||
toolName: string;
|
||||
@@ -25,42 +24,32 @@ export const callTool = async (
|
||||
server?: string,
|
||||
): Promise<ToolCallResult> => {
|
||||
try {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
throw new Error('Authentication token not found. Please log in.');
|
||||
}
|
||||
|
||||
// Construct the URL with optional server parameter
|
||||
const url = server ? `/tools/call/${server}` : '/tools/call';
|
||||
|
||||
const response = await fetch(getApiUrl(url), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token,
|
||||
Authorization: `Bearer ${token}`, // Add bearer auth for MCP routing
|
||||
},
|
||||
body: JSON.stringify({
|
||||
const response = await apiPost<any>(
|
||||
url,
|
||||
{
|
||||
toolName: request.toolName,
|
||||
arguments: request.arguments,
|
||||
}),
|
||||
});
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('mcphub_token')}`, // Add bearer auth for MCP routing
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
if (!response.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: data.message || 'Tool call failed',
|
||||
error: response.message || 'Tool call failed',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
content: data.data.content || [],
|
||||
content: response.data?.content || [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error calling tool:', error);
|
||||
@@ -80,29 +69,19 @@ export const toggleTool = async (
|
||||
enabled: boolean,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
throw new Error('Authentication token not found. Please log in.');
|
||||
}
|
||||
|
||||
const response = await fetch(getApiUrl(`/servers/${serverName}/tools/${toolName}/toggle`), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token,
|
||||
Authorization: `Bearer ${token}`,
|
||||
const response = await apiPost<any>(
|
||||
`/servers/${serverName}/tools/${toolName}/toggle`,
|
||||
{ enabled },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('mcphub_token')}`,
|
||||
},
|
||||
},
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
success: data.success,
|
||||
error: data.success ? undefined : data.message,
|
||||
success: response.success,
|
||||
error: response.success ? undefined : response.message,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error toggling tool:', error);
|
||||
@@ -122,32 +101,19 @@ export const updateToolDescription = async (
|
||||
description: string,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
throw new Error('Authentication token not found. Please log in.');
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
getApiUrl(`/servers/${serverName}/tools/${toolName}/description`),
|
||||
const response = await apiPut<any>(
|
||||
`/servers/${serverName}/tools/${toolName}/description`,
|
||||
{ description },
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token,
|
||||
Authorization: `Bearer ${token}`,
|
||||
Authorization: `Bearer ${localStorage.getItem('mcphub_token')}`,
|
||||
},
|
||||
body: JSON.stringify({ description }),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
success: data.success,
|
||||
error: data.success ? undefined : data.message,
|
||||
success: response.success,
|
||||
error: response.success ? undefined : response.message,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error updating tool description:', error);
|
||||
|
||||
@@ -55,6 +55,27 @@ export interface MarketServer {
|
||||
is_official?: boolean;
|
||||
}
|
||||
|
||||
// Cloud Server types (for MCPRouter API)
|
||||
export interface CloudServer {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
name: string;
|
||||
author_name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
content: string;
|
||||
server_key: string;
|
||||
config_name: string;
|
||||
server_url: string;
|
||||
tools?: CloudServerTool[];
|
||||
}
|
||||
|
||||
export interface CloudServerTool {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, any>;
|
||||
}
|
||||
|
||||
// Tool input schema types
|
||||
export interface ToolInputSchema {
|
||||
type: string;
|
||||
@@ -137,11 +158,17 @@ export interface Server {
|
||||
}
|
||||
|
||||
// Group types
|
||||
// Group server configuration - supports tool selection
|
||||
export interface IGroupServerConfig {
|
||||
name: string; // Server name
|
||||
tools?: string[] | 'all'; // Array of specific tool names to include, or 'all' for all tools (default: 'all')
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
servers: string[];
|
||||
servers: string[] | IGroupServerConfig[]; // Supports both old and new format
|
||||
}
|
||||
|
||||
// Environment variable types
|
||||
@@ -196,7 +223,7 @@ export interface ServerFormData {
|
||||
export interface GroupFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
servers: string[]; // Added servers array to include in form data
|
||||
servers: string[] | IGroupServerConfig[]; // Updated to support new format
|
||||
}
|
||||
|
||||
// API response types
|
||||
@@ -210,6 +237,30 @@ export interface ApiResponse<T = any> {
|
||||
export interface IUser {
|
||||
username: string;
|
||||
isAdmin?: boolean;
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
// User management types
|
||||
export interface User {
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface UserFormData {
|
||||
username: string;
|
||||
password: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface UserUpdateData {
|
||||
isAdmin?: boolean;
|
||||
newPassword?: string;
|
||||
}
|
||||
|
||||
export interface UserStats {
|
||||
totalUsers: number;
|
||||
adminUsers: number;
|
||||
regularUsers: number;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
|
||||
174
frontend/src/utils/fetchInterceptor.ts
Normal file
174
frontend/src/utils/fetchInterceptor.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { getApiUrl } from './runtime';
|
||||
|
||||
// Define the interceptor interface
|
||||
export interface FetchInterceptor {
|
||||
request?: (url: string, config: RequestInit) => Promise<{ url: string; config: RequestInit }>;
|
||||
response?: (response: Response) => Promise<Response>;
|
||||
error?: (error: Error) => Promise<Error>;
|
||||
}
|
||||
|
||||
// Define the enhanced fetch response interface
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Global interceptors store
|
||||
const interceptors: FetchInterceptor[] = [];
|
||||
|
||||
// Add an interceptor
|
||||
export const addInterceptor = (interceptor: FetchInterceptor): void => {
|
||||
interceptors.push(interceptor);
|
||||
};
|
||||
|
||||
// Remove an interceptor
|
||||
export const removeInterceptor = (interceptor: FetchInterceptor): void => {
|
||||
const index = interceptors.indexOf(interceptor);
|
||||
if (index > -1) {
|
||||
interceptors.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
// Clear all interceptors
|
||||
export const clearInterceptors = (): void => {
|
||||
interceptors.length = 0;
|
||||
};
|
||||
|
||||
// Enhanced fetch function with interceptors
|
||||
export const fetchWithInterceptors = async (
|
||||
input: string | URL | Request,
|
||||
init: RequestInit = {},
|
||||
): Promise<Response> => {
|
||||
let url = input.toString();
|
||||
let config = { ...init };
|
||||
|
||||
try {
|
||||
// Apply request interceptors
|
||||
for (const interceptor of interceptors) {
|
||||
if (interceptor.request) {
|
||||
const result = await interceptor.request(url, config);
|
||||
url = result.url;
|
||||
config = result.config;
|
||||
}
|
||||
}
|
||||
|
||||
// Make the actual fetch request
|
||||
let response = await fetch(url, config);
|
||||
|
||||
// Apply response interceptors
|
||||
for (const interceptor of interceptors) {
|
||||
if (interceptor.response) {
|
||||
response = await interceptor.response(response);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
let processedError = error as Error;
|
||||
|
||||
// Apply error interceptors
|
||||
for (const interceptor of interceptors) {
|
||||
if (interceptor.error) {
|
||||
processedError = await interceptor.error(processedError);
|
||||
}
|
||||
}
|
||||
|
||||
throw processedError;
|
||||
}
|
||||
};
|
||||
|
||||
// Convenience function for API calls with automatic URL construction
|
||||
export const apiRequest = async <T = any>(endpoint: string, init: RequestInit = {}): Promise<T> => {
|
||||
try {
|
||||
const url = getApiUrl(endpoint);
|
||||
const response = await fetchWithInterceptors(url, init);
|
||||
|
||||
// Try to parse JSON response
|
||||
let data: T;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (parseError) {
|
||||
// If JSON parsing fails, create a generic response
|
||||
const genericResponse = {
|
||||
success: response.ok,
|
||||
message: response.ok
|
||||
? 'Request successful'
|
||||
: `HTTP ${response.status}: ${response.statusText}`,
|
||||
};
|
||||
data = genericResponse as T;
|
||||
}
|
||||
|
||||
// If response is not ok, but no explicit error in parsed data
|
||||
if (!response.ok && typeof data === 'object' && data !== null) {
|
||||
const responseObj = data as any;
|
||||
if (responseObj.success !== false) {
|
||||
responseObj.success = false;
|
||||
responseObj.message =
|
||||
responseObj.message || `HTTP ${response.status}: ${response.statusText}`;
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('API request error:', error);
|
||||
const errorResponse = {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'An unknown error occurred',
|
||||
};
|
||||
return errorResponse as T;
|
||||
}
|
||||
};
|
||||
|
||||
// Convenience methods for common HTTP methods
|
||||
export const apiGet = <T = any>(endpoint: string, init: Omit<RequestInit, 'method'> = {}) =>
|
||||
apiRequest<T>(endpoint, { ...init, method: 'GET' });
|
||||
|
||||
export const apiPost = <T = any>(
|
||||
endpoint: string,
|
||||
data?: any,
|
||||
init: Omit<RequestInit, 'method' | 'body'> = {},
|
||||
) =>
|
||||
apiRequest<T>(endpoint, {
|
||||
...init,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...init.headers,
|
||||
},
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
|
||||
export const apiPut = <T = any>(
|
||||
endpoint: string,
|
||||
data?: any,
|
||||
init: Omit<RequestInit, 'method' | 'body'> = {},
|
||||
) =>
|
||||
apiRequest<T>(endpoint, {
|
||||
...init,
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...init.headers,
|
||||
},
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
|
||||
export const apiDelete = <T = any>(endpoint: string, init: Omit<RequestInit, 'method'> = {}) =>
|
||||
apiRequest<T>(endpoint, { ...init, method: 'DELETE' });
|
||||
|
||||
export const apiPatch = <T = any>(
|
||||
endpoint: string,
|
||||
data?: any,
|
||||
init: Omit<RequestInit, 'method' | 'body'> = {},
|
||||
) =>
|
||||
apiRequest<T>(endpoint, {
|
||||
...init,
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...init.headers,
|
||||
},
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
99
frontend/src/utils/interceptors.ts
Normal file
99
frontend/src/utils/interceptors.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { addInterceptor, removeInterceptor, type FetchInterceptor } from './fetchInterceptor';
|
||||
|
||||
// Token key in localStorage
|
||||
const TOKEN_KEY = 'mcphub_token';
|
||||
|
||||
// Get token from localStorage
|
||||
export const getToken = (): string | null => {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
};
|
||||
|
||||
// Set token in localStorage
|
||||
export const setToken = (token: string): void => {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
};
|
||||
|
||||
// Remove token from localStorage
|
||||
export const removeToken = (): void => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
};
|
||||
|
||||
// Auth interceptor for automatically adding authorization headers
|
||||
export const authInterceptor: FetchInterceptor = {
|
||||
request: async (url: string, config: RequestInit) => {
|
||||
const headers = new Headers(config.headers);
|
||||
const language = localStorage.getItem('i18nextLng') || 'en';
|
||||
headers.set('Accept-Language', language);
|
||||
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
headers.set('x-auth-token', token);
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
config: {
|
||||
...config,
|
||||
headers,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
response: async (response: Response) => {
|
||||
// Handle unauthorized responses
|
||||
if (response.status === 401) {
|
||||
// Token might be expired or invalid, remove it
|
||||
removeToken();
|
||||
|
||||
// You could also trigger a redirect to login page here
|
||||
// window.location.href = '/login';
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
error: async (error: Error) => {
|
||||
console.error('Auth interceptor error:', error);
|
||||
return error;
|
||||
},
|
||||
};
|
||||
|
||||
// Install the auth interceptor
|
||||
export const installAuthInterceptor = (): void => {
|
||||
addInterceptor(authInterceptor);
|
||||
};
|
||||
|
||||
// Uninstall the auth interceptor
|
||||
export const uninstallAuthInterceptor = (): void => {
|
||||
removeInterceptor(authInterceptor);
|
||||
};
|
||||
|
||||
// Logging interceptor for development
|
||||
export const loggingInterceptor: FetchInterceptor = {
|
||||
request: async (url: string, config: RequestInit) => {
|
||||
console.log(`🚀 [${config.method || 'GET'}] ${url}`, config);
|
||||
return { url, config };
|
||||
},
|
||||
|
||||
response: async (response: Response) => {
|
||||
console.log(`✅ [${response.status}] ${response.url}`);
|
||||
return response;
|
||||
},
|
||||
|
||||
error: async (error: Error) => {
|
||||
console.error(`❌ Fetch error:`, error);
|
||||
return error;
|
||||
},
|
||||
};
|
||||
|
||||
// Install the logging interceptor (only in development)
|
||||
export const installLoggingInterceptor = (): void => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
addInterceptor(loggingInterceptor);
|
||||
}
|
||||
};
|
||||
|
||||
// Uninstall the logging interceptor
|
||||
export const uninstallLoggingInterceptor = (): void => {
|
||||
removeInterceptor(loggingInterceptor);
|
||||
};
|
||||
@@ -56,7 +56,7 @@ export const loadRuntimeConfig = async (): Promise<RuntimeConfig> => {
|
||||
const currentPath = window.location.pathname;
|
||||
const possibleConfigPaths = [
|
||||
// If we're already on a subpath, try to use it
|
||||
currentPath.replace(/\/[^\/]*$/, '') + '/config',
|
||||
currentPath.replace(/\/[^/]*$/, '') + '/config',
|
||||
// Try root config
|
||||
'/config',
|
||||
// Try with potential base paths
|
||||
|
||||
19
frontend/src/utils/setupInterceptors.ts
Normal file
19
frontend/src/utils/setupInterceptors.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { installAuthInterceptor, installLoggingInterceptor } from './interceptors';
|
||||
|
||||
/**
|
||||
* Setup all default interceptors for the application
|
||||
* This should be called once when the app initializes
|
||||
*/
|
||||
export const setupInterceptors = (): void => {
|
||||
// Install auth interceptor for automatic token handling
|
||||
installAuthInterceptor();
|
||||
|
||||
// Install logging interceptor in development mode
|
||||
installLoggingInterceptor();
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize interceptors automatically when this module is imported
|
||||
* This ensures interceptors are set up as early as possible
|
||||
*/
|
||||
setupInterceptors();
|
||||
27
frontend/src/utils/variableDetection.ts
Normal file
27
frontend/src/utils/variableDetection.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Utility function to detect ${} variables in server configurations
|
||||
export const detectVariables = (payload: any): string[] => {
|
||||
const variables = new Set<string>();
|
||||
const variableRegex = /\$\{([^}]+)\}/g;
|
||||
|
||||
const checkString = (str: string) => {
|
||||
let match;
|
||||
while ((match = variableRegex.exec(str)) !== null) {
|
||||
variables.add(match[1]);
|
||||
}
|
||||
};
|
||||
|
||||
const checkObject = (obj: any, path: string = '') => {
|
||||
if (typeof obj === 'string') {
|
||||
checkString(obj);
|
||||
} else if (Array.isArray(obj)) {
|
||||
obj.forEach((item, index) => checkObject(item, `${path}[${index}]`));
|
||||
} else if (obj && typeof obj === 'object') {
|
||||
Object.entries(obj).forEach(([key, value]) => {
|
||||
checkObject(value, path ? `${path}.${key}` : key);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
checkObject(payload);
|
||||
return Array.from(variables);
|
||||
};
|
||||
@@ -1,12 +1,9 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
darkMode: 'class', // Use class strategy for dark mode
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
plugins: [require('@tailwindcss/line-clamp')],
|
||||
};
|
||||
|
||||
@@ -39,6 +39,14 @@ export default defineConfig({
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
[`${basePath}/config`]: {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
[`${basePath}/public-config`]: {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
47
jest.config.cjs
Normal file
47
jest.config.cjs
Normal file
@@ -0,0 +1,47 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest/presets/default-esm',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src', '<rootDir>/tests'],
|
||||
testMatch: [
|
||||
'<rootDir>/src/**/__tests__/**/*.{ts,tsx}',
|
||||
'<rootDir>/src/**/*.{test,spec}.{ts,tsx}',
|
||||
'<rootDir>/tests/**/*.{test,spec}.{ts,tsx}',
|
||||
],
|
||||
transform: {
|
||||
'^.+\\.tsx?$': [
|
||||
'ts-jest',
|
||||
{
|
||||
useESM: true,
|
||||
tsconfig: './tsconfig.test.json',
|
||||
},
|
||||
],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
||||
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{ts,tsx}',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/index.ts',
|
||||
'!src/**/__tests__/**',
|
||||
'!src/**/*.test.{ts,tsx}',
|
||||
'!src/**/*.spec.{ts,tsx}',
|
||||
],
|
||||
coverageDirectory: 'coverage',
|
||||
coverageReporters: ['text', 'lcov', 'html'],
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
branches: 0,
|
||||
functions: 0,
|
||||
lines: 0,
|
||||
statements: 0,
|
||||
},
|
||||
},
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!(@modelcontextprotocol|other-esm-packages)/)'],
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
testTimeout: 30000,
|
||||
verbose: true,
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src'],
|
||||
transform: {
|
||||
'^.+\\.tsx?$': 'ts-jest',
|
||||
},
|
||||
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$',
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
||||
};
|
||||
@@ -117,6 +117,11 @@
|
||||
"argumentsPlaceholder": "Enter arguments",
|
||||
"errorDetails": "Error Details",
|
||||
"viewErrorDetails": "View error details",
|
||||
"confirmVariables": "Confirm Variable Configuration",
|
||||
"variablesDetected": "Variables detected in configuration. Please confirm these variables are properly configured:",
|
||||
"detectedVariables": "Detected Variables",
|
||||
"confirmVariablesMessage": "Please ensure these variables are properly defined in your runtime environment. Continue adding server?",
|
||||
"confirmAndAdd": "Confirm and Add",
|
||||
"openapi": {
|
||||
"inputMode": "Input Mode",
|
||||
"inputModeUrl": "Specification URL",
|
||||
@@ -168,21 +173,31 @@
|
||||
"cancel": "Cancel",
|
||||
"refresh": "Refresh",
|
||||
"create": "Create",
|
||||
"creating": "Creating...",
|
||||
"update": "Update",
|
||||
"updating": "Updating...",
|
||||
"submitting": "Submitting...",
|
||||
"delete": "Delete",
|
||||
"remove": "Remove",
|
||||
"copy": "Copy",
|
||||
"copyId": "Copy ID",
|
||||
"copyUrl": "Copy URL",
|
||||
"copyJson": "Copy JSON",
|
||||
"copySuccess": "Copied to clipboard",
|
||||
"copyFailed": "Copy failed",
|
||||
"close": "Close"
|
||||
"close": "Close",
|
||||
"confirm": "Confirm",
|
||||
"language": "Language"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"servers": "Servers",
|
||||
"groups": "Groups",
|
||||
"users": "Users",
|
||||
"settings": "Settings",
|
||||
"changePassword": "Change Password",
|
||||
"market": "Market",
|
||||
"cloud": "Cloud Market",
|
||||
"logs": "Logs"
|
||||
},
|
||||
"pages": {
|
||||
@@ -200,6 +215,9 @@
|
||||
"groups": {
|
||||
"title": "Group Management"
|
||||
},
|
||||
"users": {
|
||||
"title": "User Management"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"language": "Language",
|
||||
@@ -211,7 +229,7 @@
|
||||
"smartRouting": "Smart Routing"
|
||||
},
|
||||
"market": {
|
||||
"title": "Server Market - (Data from mcpm.sh)"
|
||||
"title": "Market Hub - Local and Cloud Markets"
|
||||
},
|
||||
"logs": {
|
||||
"title": "System Logs"
|
||||
@@ -254,10 +272,17 @@
|
||||
"noGroups": "No groups available. Create a new group to get started.",
|
||||
"noServers": "No servers in this group.",
|
||||
"noServerOptions": "No servers available",
|
||||
"serverCount": "{{count}} Servers"
|
||||
"serverCount": "{{count}} Servers",
|
||||
"toolSelection": "Tool Selection",
|
||||
"toolsSelected": "Selected",
|
||||
"allTools": "All",
|
||||
"selectedTools": "Selected tools",
|
||||
"selectAll": "Select All",
|
||||
"selectNone": "Select None",
|
||||
"configureTools": "Configure Tools"
|
||||
},
|
||||
"market": {
|
||||
"title": "Server Market",
|
||||
"title": "Local Installation",
|
||||
"official": "Official",
|
||||
"by": "By",
|
||||
"unknown": "Unknown",
|
||||
@@ -296,7 +321,61 @@
|
||||
"tagFilterError": "Error filtering servers by tag",
|
||||
"noInstallationMethod": "No installation method available for this server",
|
||||
"showing": "Showing {{from}}-{{to}} of {{total}} servers",
|
||||
"perPage": "Per page"
|
||||
"perPage": "Per page",
|
||||
"confirmVariablesMessage": "Please ensure these variables are properly defined in your runtime environment. Continue installing server?",
|
||||
"confirmAndInstall": "Confirm and Install"
|
||||
},
|
||||
"cloud": {
|
||||
"title": "Cloud Support",
|
||||
"subtitle": "Powered by MCPRouter",
|
||||
"by": "By",
|
||||
"server": "Server",
|
||||
"config": "Config",
|
||||
"created": "Created",
|
||||
"updated": "Updated",
|
||||
"available": "Available",
|
||||
"description": "Description",
|
||||
"details": "Details",
|
||||
"tools": "Tools",
|
||||
"tool": "tool",
|
||||
"toolsAvailable": "{{count}} tool available||{{count}} tools available",
|
||||
"loadingTools": "Loading tools...",
|
||||
"noTools": "No tools available for this server",
|
||||
"noDescription": "No description available",
|
||||
"viewDetails": "View Details",
|
||||
"parameters": "Parameters",
|
||||
"result": "Result",
|
||||
"error": "Error",
|
||||
"callTool": "Call",
|
||||
"calling": "Calling...",
|
||||
"toolCallSuccess": "Tool {{toolName}} executed successfully",
|
||||
"toolCallError": "Failed to call tool {{toolName}}: {{error}}",
|
||||
"viewSchema": "View Schema",
|
||||
"backToList": "Back to Cloud Market",
|
||||
"search": "Search",
|
||||
"searchPlaceholder": "Search cloud servers by name, title, or author",
|
||||
"clearFilters": "Clear Filters",
|
||||
"clearCategoryFilter": "Clear",
|
||||
"clearTagFilter": "Clear",
|
||||
"categories": "Categories",
|
||||
"tags": "Tags",
|
||||
"noCategories": "No categories found",
|
||||
"noTags": "No tags found",
|
||||
"noServers": "No cloud servers found",
|
||||
"fetchError": "Error fetching cloud servers",
|
||||
"serverNotFound": "Cloud server not found",
|
||||
"searchError": "Error searching cloud servers",
|
||||
"filterError": "Error filtering cloud servers by category",
|
||||
"tagFilterError": "Error filtering cloud servers by tag",
|
||||
"showing": "Showing {{from}}-{{to}} of {{total}} cloud servers",
|
||||
"perPage": "Per page",
|
||||
"apiKeyNotConfigured": "MCPRouter API key not configured",
|
||||
"apiKeyNotConfiguredDescription": "To use cloud servers, you need to configure your MCPRouter API key.",
|
||||
"getApiKey": "Get API Key",
|
||||
"configureInSettings": "Configure in Settings",
|
||||
"installServer": "Install {{name}}",
|
||||
"installSuccess": "Server {{name}} installed successfully",
|
||||
"installError": "Failed to install server: {{error}}"
|
||||
},
|
||||
"tool": {
|
||||
"run": "Run",
|
||||
@@ -343,12 +422,17 @@
|
||||
"bearerAuthKey": "Bearer Authentication Key",
|
||||
"bearerAuthKeyDescription": "The authentication key that will be required in the Bearer token",
|
||||
"bearerAuthKeyPlaceholder": "Enter bearer authentication key",
|
||||
"skipAuth": "Skip Authentication",
|
||||
"skipAuthDescription": "Bypass login requirement for frontend and API access (DEFAULT OFF for security)",
|
||||
"pythonIndexUrl": "Python Package Repository URL",
|
||||
"pythonIndexUrlDescription": "Set UV_DEFAULT_INDEX environment variable for Python package installation",
|
||||
"pythonIndexUrlPlaceholder": "e.g. https://pypi.org/simple",
|
||||
"npmRegistry": "NPM Registry URL",
|
||||
"npmRegistryDescription": "Set npm_config_registry environment variable for NPM package installation",
|
||||
"npmRegistryPlaceholder": "e.g. https://registry.npmjs.org/",
|
||||
"baseUrl": "Base URL",
|
||||
"baseUrlDescription": "Base URL for MCP requests",
|
||||
"baseUrlPlaceholder": "e.g. http://localhost:3000",
|
||||
"installConfig": "Installation",
|
||||
"systemConfigUpdated": "System configuration updated successfully",
|
||||
"enableSmartRouting": "Enable Smart Routing",
|
||||
@@ -363,6 +447,138 @@
|
||||
"openaiApiEmbeddingModelPlaceholder": "text-embedding-3-small",
|
||||
"smartRoutingConfigUpdated": "Smart routing configuration updated successfully",
|
||||
"smartRoutingRequiredFields": "Database URL and OpenAI API Key are required to enable smart routing",
|
||||
"smartRoutingValidationError": "Please fill in the required fields before enabling Smart Routing: {{fields}}"
|
||||
"smartRoutingValidationError": "Please fill in the required fields before enabling Smart Routing: {{fields}}",
|
||||
"mcpRouterConfig": "Cloud Market",
|
||||
"mcpRouterApiKey": "MCPRouter API Key",
|
||||
"mcpRouterApiKeyDescription": "API key for accessing MCPRouter cloud market services",
|
||||
"mcpRouterApiKeyPlaceholder": "Enter MCPRouter API key",
|
||||
"mcpRouterReferer": "Referer",
|
||||
"mcpRouterRefererDescription": "Referer header for MCPRouter API requests",
|
||||
"mcpRouterRefererPlaceholder": "https://mcphub.app",
|
||||
"mcpRouterTitle": "Title",
|
||||
"mcpRouterTitleDescription": "Title header for MCPRouter API requests",
|
||||
"mcpRouterTitlePlaceholder": "MCPHub",
|
||||
"mcpRouterBaseUrl": "Base URL",
|
||||
"mcpRouterBaseUrlDescription": "Base URL for MCPRouter API",
|
||||
"mcpRouterBaseUrlPlaceholder": "https://api.mcprouter.to/v1"
|
||||
},
|
||||
"dxt": {
|
||||
"upload": "Upload",
|
||||
"uploadTitle": "Upload DXT Extension",
|
||||
"dropFileHere": "Drop your .dxt file here",
|
||||
"orClickToSelect": "or click to select from your computer",
|
||||
"invalidFileType": "Please select a valid .dxt file",
|
||||
"noFileSelected": "Please select a .dxt file to upload",
|
||||
"uploading": "Uploading...",
|
||||
"uploadFailed": "Failed to upload DXT file",
|
||||
"installServer": "Install MCP Server from DXT",
|
||||
"extensionInfo": "Extension Information",
|
||||
"name": "Name",
|
||||
"version": "Version",
|
||||
"description": "Description",
|
||||
"author": "Author",
|
||||
"tools": "Tools",
|
||||
"serverName": "Server Name",
|
||||
"serverNamePlaceholder": "Enter a name for this server",
|
||||
"install": "Install",
|
||||
"installing": "Installing...",
|
||||
"installFailed": "Failed to install server from DXT",
|
||||
"serverExistsTitle": "Server Already Exists",
|
||||
"serverExistsConfirm": "Server '{{serverName}}' already exists. Do you want to override it with the new version?",
|
||||
"override": "Override"
|
||||
},
|
||||
"users": {
|
||||
"add": "Add User",
|
||||
"addNew": "Add New User",
|
||||
"edit": "Edit User",
|
||||
"delete": "Delete User",
|
||||
"create": "Create User",
|
||||
"update": "Update User",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"newPassword": "New Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"adminRole": "Administrator",
|
||||
"admin": "Admin",
|
||||
"user": "User",
|
||||
"permissions": "Permissions",
|
||||
"adminPermissions": "Full system access",
|
||||
"userPermissions": "Limited access",
|
||||
"currentUser": "You",
|
||||
"noUsers": "No users found",
|
||||
"adminRequired": "Administrator access required to manage users",
|
||||
"usernameRequired": "Username is required",
|
||||
"passwordRequired": "Password is required",
|
||||
"passwordTooShort": "Password must be at least 6 characters long",
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"usernamePlaceholder": "Enter username",
|
||||
"passwordPlaceholder": "Enter password",
|
||||
"newPasswordPlaceholder": "Leave empty to keep current password",
|
||||
"confirmPasswordPlaceholder": "Confirm new password",
|
||||
"createError": "Failed to create user",
|
||||
"updateError": "Failed to update user",
|
||||
"deleteError": "Failed to delete user",
|
||||
"statsError": "Failed to fetch user statistics",
|
||||
"deleteConfirmation": "Are you sure you want to delete user '{{username}}'? This action cannot be undone.",
|
||||
"confirmDelete": "Delete User",
|
||||
"deleteWarning": "Are you sure you want to delete user '{{username}}'? This action cannot be undone."
|
||||
},
|
||||
"api": {
|
||||
"errors": {
|
||||
"readonly": "Readonly for demo environment",
|
||||
"serverNameRequired": "Server name is required",
|
||||
"serverConfigRequired": "Server configuration is required",
|
||||
"serverConfigInvalid": "Server configuration must include either a URL, OpenAPI specification URL or schema, or command with arguments",
|
||||
"serverTypeInvalid": "Server type must be one of: stdio, sse, streamable-http, openapi",
|
||||
"urlRequiredForType": "URL is required for {{type}} server type",
|
||||
"openapiSpecRequired": "OpenAPI specification URL or schema is required for openapi server type",
|
||||
"headersInvalidFormat": "Headers must be an object",
|
||||
"headersNotSupportedForStdio": "Headers are not supported for stdio server type",
|
||||
"serverNotFound": "Server not found",
|
||||
"failedToRemoveServer": "Server not found or failed to remove",
|
||||
"internalServerError": "Internal server error",
|
||||
"failedToGetServers": "Failed to get servers information",
|
||||
"failedToGetServerSettings": "Failed to get server settings",
|
||||
"failedToGetServerConfig": "Failed to get server configuration",
|
||||
"failedToSaveSettings": "Failed to save settings",
|
||||
"toolNameRequired": "Server name and tool name are required",
|
||||
"descriptionMustBeString": "Description must be a string",
|
||||
"groupIdRequired": "Group ID is required",
|
||||
"groupNameRequired": "Group name is required",
|
||||
"groupNotFound": "Group not found",
|
||||
"groupIdAndServerNameRequired": "Group ID and server name are required",
|
||||
"groupOrServerNotFound": "Group or server not found",
|
||||
"toolsMustBeAllOrArray": "Tools must be \"all\" or an array of strings",
|
||||
"serverNameAndToolNameRequired": "Server name and tool name are required",
|
||||
"usernameRequired": "Username is required",
|
||||
"userNotFound": "User not found",
|
||||
"failedToGetUsers": "Failed to get users information",
|
||||
"failedToGetUserInfo": "Failed to get user information",
|
||||
"failedToGetUserStats": "Failed to get user statistics",
|
||||
"marketServerNameRequired": "Server name is required",
|
||||
"marketServerNotFound": "Market server not found",
|
||||
"failedToGetMarketServers": "Failed to get market servers information",
|
||||
"failedToGetMarketServer": "Failed to get market server information",
|
||||
"failedToGetMarketCategories": "Failed to get market categories",
|
||||
"failedToGetMarketTags": "Failed to get market tags",
|
||||
"failedToSearchMarketServers": "Failed to search market servers",
|
||||
"failedToFilterMarketServers": "Failed to filter market servers",
|
||||
"failedToProcessDxtFile": "Failed to process DXT file"
|
||||
},
|
||||
"success": {
|
||||
"serverCreated": "Server created successfully",
|
||||
"serverUpdated": "Server updated successfully",
|
||||
"serverRemoved": "Server removed successfully",
|
||||
"serverToggled": "Server status toggled successfully",
|
||||
"toolToggled": "Tool {{name}} {{action}} successfully",
|
||||
"toolDescriptionUpdated": "Tool {{name}} description updated successfully",
|
||||
"systemConfigUpdated": "System configuration updated successfully",
|
||||
"groupCreated": "Group created successfully",
|
||||
"groupUpdated": "Group updated successfully",
|
||||
"groupDeleted": "Group deleted successfully",
|
||||
"serverAddedToGroup": "Server added to group successfully",
|
||||
"serverRemovedFromGroup": "Server removed from group successfully",
|
||||
"serverToolsUpdated": "Server tools updated successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,11 @@
|
||||
"argumentsPlaceholder": "请输入参数",
|
||||
"errorDetails": "错误详情",
|
||||
"viewErrorDetails": "查看错误详情",
|
||||
"confirmVariables": "确认变量配置",
|
||||
"variablesDetected": "检测到配置中包含变量,请确认这些变量是否已正确配置:",
|
||||
"detectedVariables": "检测到的变量",
|
||||
"confirmVariablesMessage": "请确保这些变量在运行环境中已正确定义。是否继续添加服务器?",
|
||||
"confirmAndAdd": "确认并添加",
|
||||
"openapi": {
|
||||
"inputMode": "输入模式",
|
||||
"inputModeUrl": "规范 URL",
|
||||
@@ -169,13 +174,21 @@
|
||||
"cancel": "取消",
|
||||
"refresh": "刷新",
|
||||
"create": "创建",
|
||||
"creating": "创建中...",
|
||||
"update": "更新",
|
||||
"updating": "更新中...",
|
||||
"submitting": "提交中...",
|
||||
"delete": "删除",
|
||||
"remove": "移除",
|
||||
"copy": "复制",
|
||||
"copyId": "复制ID",
|
||||
"copyUrl": "复制URL",
|
||||
"copyJson": "复制JSON",
|
||||
"copySuccess": "已复制到剪贴板",
|
||||
"copyFailed": "复制失败",
|
||||
"close": "关闭"
|
||||
"close": "关闭",
|
||||
"confirm": "确认",
|
||||
"language": "语言"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "仪表盘",
|
||||
@@ -183,7 +196,9 @@
|
||||
"settings": "设置",
|
||||
"changePassword": "修改密码",
|
||||
"groups": "分组",
|
||||
"users": "用户",
|
||||
"market": "市场",
|
||||
"cloud": "云端市场",
|
||||
"logs": "日志"
|
||||
},
|
||||
"pages": {
|
||||
@@ -211,8 +226,11 @@
|
||||
"groups": {
|
||||
"title": "分组管理"
|
||||
},
|
||||
"users": {
|
||||
"title": "用户管理"
|
||||
},
|
||||
"market": {
|
||||
"title": "服务器市场 - (数据来源于 mcpm.sh)"
|
||||
"title": "市场中心 - 本地市场和云端市场"
|
||||
},
|
||||
"logs": {
|
||||
"title": "系统日志"
|
||||
@@ -255,10 +273,17 @@
|
||||
"noGroups": "暂无可用分组。创建一个新分组以开始使用。",
|
||||
"noServers": "此分组中没有服务器。",
|
||||
"noServerOptions": "没有可用的服务器",
|
||||
"serverCount": "{{count}} 台服务器"
|
||||
"serverCount": "{{count}} 台服务器",
|
||||
"toolSelection": "工具选择",
|
||||
"toolsSelected": "选择",
|
||||
"allTools": "全部",
|
||||
"selectedTools": "选中的工具",
|
||||
"selectAll": "全选",
|
||||
"selectNone": "全不选",
|
||||
"configureTools": "配置工具"
|
||||
},
|
||||
"market": {
|
||||
"title": "服务器市场",
|
||||
"title": "本地安装",
|
||||
"official": "官方",
|
||||
"by": "作者",
|
||||
"unknown": "未知",
|
||||
@@ -290,19 +315,73 @@
|
||||
"required": "必填",
|
||||
"example": "示例",
|
||||
"viewSchema": "查看结构",
|
||||
"fetchError": "获取服务器市场数据失败",
|
||||
"fetchError": "获取本地市场服务器数据失败",
|
||||
"serverNotFound": "未找到服务器",
|
||||
"searchError": "搜索服务器失败",
|
||||
"filterError": "按分类筛选服务器失败",
|
||||
"tagFilterError": "按标签筛选服务器失败",
|
||||
"noInstallationMethod": "该服务器没有可用的安装方法",
|
||||
"showing": "显示 {{from}}-{{to}}/{{total}} 个服务器",
|
||||
"perPage": "每页显示"
|
||||
"perPage": "每页显示",
|
||||
"confirmVariablesMessage": "请确保这些变量在运行环境中已正确定义。是否继续安装服务器?",
|
||||
"confirmAndInstall": "确认并安装"
|
||||
},
|
||||
"cloud": {
|
||||
"title": "云端支持",
|
||||
"subtitle": "由 MCPRouter 提供支持",
|
||||
"by": "作者",
|
||||
"server": "服务器",
|
||||
"config": "配置",
|
||||
"created": "创建时间",
|
||||
"updated": "更新时间",
|
||||
"available": "可用",
|
||||
"description": "描述",
|
||||
"details": "详细信息",
|
||||
"tools": "工具",
|
||||
"tool": "个工具",
|
||||
"toolsAvailable": "{{count}} 个工具可用",
|
||||
"loadingTools": "加载工具中...",
|
||||
"noTools": "该服务器没有可用工具",
|
||||
"noDescription": "无描述信息",
|
||||
"viewDetails": "查看详情",
|
||||
"parameters": "参数",
|
||||
"result": "结果",
|
||||
"error": "错误",
|
||||
"callTool": "调用",
|
||||
"calling": "调用中...",
|
||||
"toolCallSuccess": "工具 {{toolName}} 执行成功",
|
||||
"toolCallError": "调用工具 {{toolName}} 失败:{{error}}",
|
||||
"viewSchema": "查看结构",
|
||||
"backToList": "返回云端市场",
|
||||
"search": "搜索",
|
||||
"searchPlaceholder": "搜索云端服务器名称、标题或作者",
|
||||
"clearFilters": "清除筛选",
|
||||
"clearCategoryFilter": "清除",
|
||||
"clearTagFilter": "清除",
|
||||
"categories": "分类",
|
||||
"tags": "标签",
|
||||
"noCategories": "未找到分类",
|
||||
"noTags": "未找到标签",
|
||||
"noServers": "未找到云端服务器",
|
||||
"fetchError": "获取云端服务器失败",
|
||||
"serverNotFound": "未找到云端服务器",
|
||||
"searchError": "搜索云端服务器失败",
|
||||
"filterError": "按分类筛选云端服务器失败",
|
||||
"tagFilterError": "按标签筛选云端服务器失败",
|
||||
"showing": "显示 {{from}}-{{to}}/{{total}} 个云端服务器",
|
||||
"perPage": "每页显示",
|
||||
"apiKeyNotConfigured": "MCPRouter API 密钥未配置",
|
||||
"apiKeyNotConfiguredDescription": "要使用云端服务器,您需要配置 MCPRouter API 密钥。",
|
||||
"getApiKey": "获取 API 密钥",
|
||||
"configureInSettings": "在设置中配置",
|
||||
"installServer": "安装 {{name}}",
|
||||
"installSuccess": "服务器 {{name}} 安装成功",
|
||||
"installError": "安装服务器失败:{{error}}"
|
||||
},
|
||||
"tool": {
|
||||
"run": "运行",
|
||||
"running": "运行中...",
|
||||
"runTool": "运行工具",
|
||||
"runTool": "运行",
|
||||
"cancel": "取消",
|
||||
"noDescription": "无描述信息",
|
||||
"inputSchema": "输入模式:",
|
||||
@@ -344,12 +423,17 @@
|
||||
"bearerAuthKey": "Bearer 认证密钥",
|
||||
"bearerAuthKeyDescription": "Bearer 令牌中需要携带的认证密钥",
|
||||
"bearerAuthKeyPlaceholder": "请输入 Bearer 认证密钥",
|
||||
"skipAuth": "免登录开关",
|
||||
"skipAuthDescription": "跳过前端和 API 访问的登录要求(默认关闭确保安全性)",
|
||||
"pythonIndexUrl": "Python 包仓库地址",
|
||||
"pythonIndexUrlDescription": "设置 UV_DEFAULT_INDEX 环境变量,用于 Python 包安装",
|
||||
"pythonIndexUrlPlaceholder": "例如: https://mirrors.aliyun.com/pypi/simple",
|
||||
"npmRegistry": "NPM 仓库地址",
|
||||
"npmRegistryDescription": "设置 npm_config_registry 环境变量,用于 NPM 包安装",
|
||||
"npmRegistryPlaceholder": "例如: https://registry.npmmirror.com/",
|
||||
"baseUrl": "基础地址",
|
||||
"baseUrlDescription": "用于 MCP 请求的基础地址",
|
||||
"baseUrlPlaceholder": "例如: http://localhost:3000",
|
||||
"installConfig": "安装配置",
|
||||
"systemConfigUpdated": "系统配置更新成功",
|
||||
"enableSmartRouting": "启用智能路由",
|
||||
@@ -365,6 +449,138 @@
|
||||
"openaiApiEmbeddingModelPlaceholder": "text-embedding-3-small",
|
||||
"smartRoutingConfigUpdated": "智能路由配置更新成功",
|
||||
"smartRoutingRequiredFields": "启用智能路由需要填写数据库连接地址和 OpenAI API 密钥",
|
||||
"smartRoutingValidationError": "启用智能路由前请先填写必要字段:{{fields}}"
|
||||
"smartRoutingValidationError": "启用智能路由前请先填写必要字段:{{fields}}",
|
||||
"mcpRouterConfig": "云端市场",
|
||||
"mcpRouterApiKey": "MCPRouter API 密钥",
|
||||
"mcpRouterApiKeyDescription": "用于访问 MCPRouter 云端市场服务的 API 密钥",
|
||||
"mcpRouterApiKeyPlaceholder": "请输入 MCPRouter API 密钥",
|
||||
"mcpRouterReferer": "引用地址",
|
||||
"mcpRouterRefererDescription": "MCPRouter API 请求的引用地址头",
|
||||
"mcpRouterRefererPlaceholder": "https://mcphub.app",
|
||||
"mcpRouterTitle": "标题",
|
||||
"mcpRouterTitleDescription": "MCPRouter API 请求的标题头",
|
||||
"mcpRouterTitlePlaceholder": "MCPHub",
|
||||
"mcpRouterBaseUrl": "基础地址",
|
||||
"mcpRouterBaseUrlDescription": "MCPRouter API 的基础地址",
|
||||
"mcpRouterBaseUrlPlaceholder": "https://api.mcprouter.to/v1"
|
||||
},
|
||||
"dxt": {
|
||||
"upload": "上传",
|
||||
"uploadTitle": "上传 DXT 扩展",
|
||||
"dropFileHere": "将 .dxt 文件拖拽到此处",
|
||||
"orClickToSelect": "或点击从计算机选择",
|
||||
"invalidFileType": "请选择有效的 .dxt 文件",
|
||||
"noFileSelected": "请选择要上传的 .dxt 文件",
|
||||
"uploading": "上传中...",
|
||||
"uploadFailed": "上传 DXT 文件失败",
|
||||
"installServer": "从 DXT 安装 MCP 服务器",
|
||||
"extensionInfo": "扩展信息",
|
||||
"name": "名称",
|
||||
"version": "版本",
|
||||
"description": "描述",
|
||||
"author": "作者",
|
||||
"tools": "工具",
|
||||
"serverName": "服务器名称",
|
||||
"serverNamePlaceholder": "为此服务器输入名称",
|
||||
"install": "安装",
|
||||
"installing": "安装中...",
|
||||
"installFailed": "从 DXT 安装服务器失败",
|
||||
"serverExistsTitle": "服务器已存在",
|
||||
"serverExistsConfirm": "服务器 '{{serverName}}' 已存在。是否要用新版本覆盖它?",
|
||||
"override": "覆盖"
|
||||
},
|
||||
"users": {
|
||||
"add": "添加",
|
||||
"addNew": "添加新用户",
|
||||
"edit": "编辑用户",
|
||||
"delete": "删除用户",
|
||||
"create": "创建",
|
||||
"update": "更新",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"newPassword": "新密码",
|
||||
"confirmPassword": "确认密码",
|
||||
"adminRole": "管理员",
|
||||
"admin": "管理员",
|
||||
"user": "用户",
|
||||
"permissions": "权限",
|
||||
"adminPermissions": "完全系统访问权限",
|
||||
"userPermissions": "受限访问权限",
|
||||
"currentUser": "当前用户",
|
||||
"noUsers": "没有找到用户",
|
||||
"adminRequired": "需要管理员权限才能管理用户",
|
||||
"usernameRequired": "用户名是必需的",
|
||||
"passwordRequired": "密码是必需的",
|
||||
"passwordTooShort": "密码至少需要6个字符",
|
||||
"passwordMismatch": "密码不匹配",
|
||||
"usernamePlaceholder": "输入用户名",
|
||||
"passwordPlaceholder": "输入密码",
|
||||
"newPasswordPlaceholder": "留空保持当前密码",
|
||||
"confirmPasswordPlaceholder": "确认新密码",
|
||||
"createError": "创建用户失败",
|
||||
"updateError": "更新用户失败",
|
||||
"deleteError": "删除用户失败",
|
||||
"statsError": "获取用户统计失败",
|
||||
"deleteConfirmation": "您确定要删除用户 '{{username}}' 吗?此操作无法撤消。",
|
||||
"confirmDelete": "删除用户",
|
||||
"deleteWarning": "您确定要删除用户 '{{username}}' 吗?此操作无法撤消。"
|
||||
},
|
||||
"api": {
|
||||
"errors": {
|
||||
"readonly": "演示环境无法修改数据",
|
||||
"serverNameRequired": "服务器名称是必需的",
|
||||
"serverConfigRequired": "服务器配置是必需的",
|
||||
"serverConfigInvalid": "服务器配置必须包含 URL、OpenAPI 规范 URL 或模式,或者带参数的命令",
|
||||
"serverTypeInvalid": "服务器类型必须是以下之一:stdio、sse、streamable-http、openapi",
|
||||
"urlRequiredForType": "{{type}} 服务器类型需要 URL",
|
||||
"openapiSpecRequired": "openapi 服务器类型需要 OpenAPI 规范 URL 或模式",
|
||||
"headersInvalidFormat": "请求头必须是对象格式",
|
||||
"headersNotSupportedForStdio": "stdio 服务器类型不支持请求头",
|
||||
"serverNotFound": "找不到服务器",
|
||||
"failedToRemoveServer": "找不到服务器或删除失败",
|
||||
"internalServerError": "服务器内部错误",
|
||||
"failedToGetServers": "获取服务器信息失败",
|
||||
"failedToGetServerSettings": "获取服务器设置失败",
|
||||
"failedToGetServerConfig": "获取服务器配置失败",
|
||||
"failedToSaveSettings": "保存设置失败",
|
||||
"toolNameRequired": "服务器名称和工具名称是必需的",
|
||||
"descriptionMustBeString": "描述必须是字符串",
|
||||
"groupIdRequired": "分组 ID 是必需的",
|
||||
"groupNameRequired": "分组名称是必需的",
|
||||
"groupNotFound": "找不到分组",
|
||||
"groupIdAndServerNameRequired": "分组 ID 和服务器名称是必需的",
|
||||
"groupOrServerNotFound": "找不到分组或服务器",
|
||||
"toolsMustBeAllOrArray": "工具必须是 \"all\" 或字符串数组",
|
||||
"serverNameAndToolNameRequired": "服务器名称和工具名称是必需的",
|
||||
"usernameRequired": "用户名是必需的",
|
||||
"userNotFound": "找不到用户",
|
||||
"failedToGetUsers": "获取用户信息失败",
|
||||
"failedToGetUserInfo": "获取用户信息失败",
|
||||
"failedToGetUserStats": "获取用户统计信息失败",
|
||||
"marketServerNameRequired": "服务器名称是必需的",
|
||||
"marketServerNotFound": "找不到市场服务器",
|
||||
"failedToGetMarketServers": "获取市场服务器信息失败",
|
||||
"failedToGetMarketServer": "获取市场服务器信息失败",
|
||||
"failedToGetMarketCategories": "获取市场类别失败",
|
||||
"failedToGetMarketTags": "获取市场标签失败",
|
||||
"failedToSearchMarketServers": "搜索市场服务器失败",
|
||||
"failedToFilterMarketServers": "过滤市场服务器失败",
|
||||
"failedToProcessDxtFile": "处理 DXT 文件失败"
|
||||
},
|
||||
"success": {
|
||||
"serverCreated": "服务器创建成功",
|
||||
"serverUpdated": "服务器更新成功",
|
||||
"serverRemoved": "服务器删除成功",
|
||||
"serverToggled": "服务器状态切换成功",
|
||||
"toolToggled": "工具 {{name}} {{action}} 成功",
|
||||
"toolDescriptionUpdated": "工具 {{name}} 描述更新成功",
|
||||
"systemConfigUpdated": "系统配置更新成功",
|
||||
"groupCreated": "分组创建成功",
|
||||
"groupUpdated": "分组更新成功",
|
||||
"groupDeleted": "分组删除成功",
|
||||
"serverAddedToGroup": "服务器添加到分组成功",
|
||||
"serverRemovedFromGroup": "服务器从分组移除成功",
|
||||
"serverToolsUpdated": "服务器工具更新成功"
|
||||
}
|
||||
}
|
||||
}
|
||||
20
package.json
20
package.json
@@ -25,6 +25,10 @@
|
||||
"lint": "eslint . --ext .ts",
|
||||
"format": "prettier --write \"src/**/*.ts\"",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"test:verbose": "jest --verbose",
|
||||
"test:ci": "jest --ci --coverage --watchAll=false",
|
||||
"frontend:dev": "cd frontend && vite",
|
||||
"frontend:build": "cd frontend && vite build",
|
||||
"frontend:preview": "cd frontend && vite preview",
|
||||
@@ -43,14 +47,19 @@
|
||||
"dependencies": {
|
||||
"@apidevtools/swagger-parser": "^11.0.1",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/multer": "^1.4.13",
|
||||
"@types/pg": "^8.15.2",
|
||||
"adm-zip": "^0.5.16",
|
||||
"axios": "^1.10.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"dotenv": "^16.3.1",
|
||||
"dotenv-expand": "^12.0.2",
|
||||
"express": "^4.21.2",
|
||||
"express-validator": "^7.2.1",
|
||||
"i18next-fs-backend": "^2.6.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.1",
|
||||
"openai": "^4.103.0",
|
||||
"openapi-types": "^12.1.3",
|
||||
"pg": "^8.16.0",
|
||||
@@ -64,6 +73,9 @@
|
||||
"@radix-ui/react-accordion": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@shadcn/ui": "^0.0.4",
|
||||
"@swc/core": "^1.13.0",
|
||||
"@swc/jest": "^0.2.39",
|
||||
"@tailwindcss/line-clamp": "^0.4.4",
|
||||
"@tailwindcss/postcss": "^4.1.3",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
@@ -73,6 +85,7 @@
|
||||
"@types/node": "^22.15.21",
|
||||
"@types/react": "^19.0.12",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
||||
"@typescript-eslint/parser": "^6.7.4",
|
||||
@@ -85,6 +98,8 @@
|
||||
"i18next": "^24.2.3",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-node": "^30.0.0",
|
||||
"jest-mock-extended": "4.0.0-beta1",
|
||||
"lucide-react": "^0.486.0",
|
||||
"next": "^15.2.4",
|
||||
"postcss": "^8.5.3",
|
||||
@@ -93,6 +108,7 @@
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"supertest": "^7.1.1",
|
||||
"tailwind-merge": "^3.1.0",
|
||||
"tailwind-scrollbar-hide": "^2.0.0",
|
||||
"tailwindcss": "^4.0.17",
|
||||
@@ -106,5 +122,5 @@
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.11.0+sha256.a69e9cb077da419d47d18f1dd52e207245b29cac6e076acedbeb8be3b1a67bd7"
|
||||
}
|
||||
"packageManager": "pnpm@10.12.4+sha256.cadfd9e6c9fcc2cb76fe7c0779a5250b632898aea5f53d833a73690c77a778d9"
|
||||
}
|
||||
|
||||
1665
pnpm-lock.yaml
generated
1665
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
ignoredBuiltDependencies:
|
||||
- '@swc/core'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user