Compare commits

...

49 Commits

Author SHA1 Message Date
samanhappy
604fe4f71d fix: remove registration endpoint from authentication bypass in middleware (#267) 2025-08-11 14:18:28 +08:00
samanhappy
907bca8aac Refactor cloud and market pages for improved functionality and UI consistency (#265) 2025-08-10 17:39:34 +08:00
samanhappy
8c58200dcc feat: add health check endpoint to monitor MCP server status (#264) 2025-08-10 16:46:22 +08:00
samanhappy
0b4dc453a5 fix: reinitialize mcp server connection on update (#263) 2025-08-10 16:20:36 +08:00
samanhappy
35012f99fc refine docs 2025-08-10 12:52:54 +08:00
samanhappy
22ad4f83f6 fix: handle undefined and null values for number inputs in DynamicForm (#261)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-10 12:52:20 +08:00
samanhappy
26720d9e49 feat: introduce cloud server market (#260) 2025-08-09 21:14:26 +08:00
samanhappy
a9aa4a9a08 feat: Update registerService to handle environment-specific service overrides (#257) 2025-08-05 14:48:48 +08:00
samanhappy
48bcf9f5f0 feat: Add cleanInputSchema function to remove $schema field from inputSchema (#255) 2025-08-05 13:45:52 +08:00
samanhappy
f63f06d879 feat: Enhance authentication flow by integrating permissions retrieval and updating related services (#256) 2025-08-05 13:45:31 +08:00
samanhappy
63b356b8d7 Add Chinese localization support and i18n middleware (#253) 2025-08-03 11:53:04 +08:00
samanhappy
a6cea2ad3f feat: Enhance group management with server tool configuration (#250) 2025-07-29 17:31:05 +08:00
samanhappy
5bb2715094 refactor: simplify LanguageSwitch component by removing unnecessary language count checks and enhancing dropdown behavior (#249) 2025-07-27 20:44:50 +08:00
samanhappy
9b40f7e101 feat: enhance LanguageSwitch component with language toggle functionality and improve dropdown behavior; update UserProfileMenu styles (#248) 2025-07-26 22:58:01 +08:00
samanhappy
df872823c1 Implement language and theme switchers in Header (#247) 2025-07-26 21:46:14 +08:00
samanhappy
9304653c34 feat: enhance GroupCard with copy options for ID, URL, and JSON; update translations (#246)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-25 14:30:52 +08:00
dependabot[bot]
b5685b7010 chore(deps): bump axios from 1.10.0 to 1.11.0 (#245)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-24 10:39:17 +08:00
samanhappy
89c37b2f02 Enhance operation name generation in OpenAPIClient (#244) 2025-07-23 19:02:43 +08:00
Oven
c316cb896e fix: create when dxt upload path does not exist (#243) 2025-07-23 13:47:11 +08:00
samanhappy
bc3c8facfa feat: add replaceEnvVarsInArray function and integrate it into server transport configuration (#241)
Co-authored-by: samanhappy@qq.com <my6051199>
2025-07-22 23:24:04 +08:00
dependabot[bot]
69afb865c0 chore(deps): bump brace-expansion from 1.1.11 to 1.1.12 (#231)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-22 23:07:20 +08:00
dependabot[bot]
ba30d88840 chore(deps): bump multer from 2.0.1 to 2.0.2 (#229)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-22 23:06:24 +08:00
samanhappy
6d0d622bd8 feat: add permissions for contents and packages in build workflow (#238) 2025-07-22 10:05:16 +08:00
samanhappy
ab50c7e9eb feat: add conditional check for repository in build and npm publish workflows (#236)
Co-authored-by: samanhappy@qq.com <my6051199>
2025-07-22 08:56:50 +08:00
samanhappy
e507bea2e3 Refactor service registration and revert lazy loading implementation (#234) 2025-07-20 22:30:09 +08:00
samanhappy
0f00ad7200 feat: implement lazy loading for data service and enhance service registration (#233) 2025-07-20 21:37:43 +08:00
samanhappy
b0b0c93337 feat: enable immediate loading of service overrides during registration (#232) 2025-07-20 20:35:00 +08:00
samanhappy
20fd355b87 feat: enhance JSON serialization safety & add dxt upload limit (#230) 2025-07-20 19:18:10 +08:00
dependabot[bot]
4388084704 chore(deps): bump typeorm from 0.3.24 to 0.3.25 (#210)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-07 09:54:52 +08:00
dependabot[bot]
fe2535461d chore(deps-dev): bump @types/react-dom from 19.1.5 to 19.1.6 (#211)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-07 09:39:52 +08:00
dependabot[bot]
985598e529 chore(deps): bump pg from 8.16.0 to 8.16.3 (#212)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-05 15:24:37 +08:00
dependabot[bot]
b2b6d0588b chore(deps-dev): bump tailwindcss from 4.1.8 to 4.1.11 (#213)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-03 14:11:24 +08:00
dependabot[bot]
64628ee3ed chore(deps-dev): bump tsx from 4.19.4 to 4.20.3 (#214)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-02 18:04:25 +08:00
samanhappy
66d4142039 feat: add variable detection and confirmation dialogs in server forms (#205) 2025-06-29 22:23:42 +08:00
samanhappy
cf72295f99 Refactor UI components across multiple pages for improved styling and consistency (#204) 2025-06-29 22:01:00 +08:00
samanhappy
89f85c73ff fix: resolve race conditions in initializeClientsFromSettings (#201) 2025-06-28 22:11:14 +08:00
samanhappy
adabf1d92b feat:support DXT file server installation (#200) 2025-06-27 14:45:24 +08:00
samanhappy
c3a6dfadb4 feat: Add dynamic header input fields for server configuration in ServerForm (#193) 2025-06-20 14:52:22 +08:00
samanhappy
d119be0f82 feat: Implement bearer token validation in auth middleware (#186) 2025-06-19 12:11:35 +08:00
samanhappy
1e308ec4c5 feat: add Jest testing framework and CI/CD configuration (#187)
Co-authored-by: samanhappy@qq.com <my6051199>
2025-06-18 14:02:52 +08:00
samanhappy
1bd4fd6d9c feat: Add OpenAPI support with comprehensive configuration options and client integration (#184)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-16 17:50:51 +08:00
Imgodmaoyouknow
4b3bb26301 fix: 修复从市场安装服务器时,选择SSE和Streamable HTTP不生效的问题 (#180) 2025-06-13 22:50:36 +08:00
samanhappy
40af398f68 chore: update @modelcontextprotocol/sdk to version 1.12.1 (#176) 2025-06-10 16:50:28 +08:00
samanhappy
4726f00a22 feat: add request options configuration to server form (#171) 2025-06-10 13:51:01 +08:00
samanhappy
77f64b7b98 feat: enhance DynamicForm to support array and object (#173)
Co-authored-by: samanhappy@qq.com <my6051199>
2025-06-10 13:50:14 +08:00
samanhappy
d9cbc5381a feat: implement keep-alive functionality for SSE connections (#166)
Co-authored-by: samanhappy@qq.com <my6051199>
2025-06-07 20:41:51 +08:00
samanhappy
56c6447469 feat: implement settings cache with load, save, clear, and status functions (#167) 2025-06-07 20:36:52 +08:00
samanhappy
f8149c4b0b fix: update SSE transport path to use basePath from config (#165)
Co-authored-by: samanhappy@qq.com <my6051199>
2025-06-07 20:35:20 +08:00
purefkh
e259f30539 fix: save user config when install mcp server from market (#168) 2025-06-07 20:34:51 +08:00
158 changed files with 17901 additions and 5091 deletions

16
.coveragerc Normal file
View 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/**"

View File

@@ -20,6 +20,6 @@
}
],
"@typescript-eslint/no-explicit-any": "off",
"no-undef": "off",
"no-undef": "off"
}
}

50
.github/copilot-instructions.md vendored Normal file
View 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/`

View File

@@ -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
View 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

View File

@@ -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
View File

@@ -24,3 +24,5 @@ yarn-error.log*
.vscode/
*.log
coverage/
data/

View File

@@ -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 && \

View File

@@ -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:

View File

@@ -57,7 +57,7 @@ MCPHub 通过将多个 MCPModel 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
```
或使用默认配置运行:

View File

@@ -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.

View File

@@ -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": {

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View File

@@ -0,0 +1,149 @@
# OpenAPI Schema Support in MCPHub
MCPHub now supports both OpenAPI specification URLs and complete JSON schemas for OpenAPI server configuration. This allows you to either reference an external OpenAPI specification file or embed the complete schema directly in your configuration.
## Configuration Options
### 1. Using OpenAPI Specification URL (Traditional)
```json
{
"type": "openapi",
"openapi": {
"url": "https://api.example.com/openapi.json",
"version": "3.1.0",
"security": {
"type": "apiKey",
"apiKey": {
"name": "X-API-Key",
"in": "header",
"value": "your-api-key"
}
}
}
}
```
### 2. Using Complete JSON Schema (New)
```json
{
"type": "openapi",
"openapi": {
"schema": {
"openapi": "3.1.0",
"info": {
"title": "My API",
"version": "1.0.0"
},
"servers": [
{
"url": "https://api.example.com"
}
],
"paths": {
"/users": {
"get": {
"operationId": "getUsers",
"summary": "Get all users",
"responses": {
"200": {
"description": "List of users"
}
}
}
}
}
},
"version": "3.1.0",
"security": {
"type": "apiKey",
"apiKey": {
"name": "X-API-Key",
"in": "header",
"value": "your-api-key"
}
}
}
}
```
## Benefits of JSON Schema Support
1. **Offline Development**: No need for external URLs during development
2. **Version Control**: Schema changes can be tracked in your configuration
3. **Security**: No external dependencies or network calls required
4. **Customization**: Full control over the API specification
5. **Testing**: Easy to create test configurations with mock schemas
## Frontend Form Support
The web interface now includes:
- **Input Mode Selection**: Choose between "Specification URL" or "JSON Schema"
- **URL Input**: Traditional URL input field for external specifications
- **Schema Editor**: Large text area with syntax highlighting for JSON schema input
- **Validation**: Client-side JSON validation before submission
- **Help Text**: Contextual help for schema format
## API Validation
The backend validates that:
- At least one of `url` or `schema` is provided for OpenAPI servers
- JSON schemas are properly formatted when provided
- Security configurations are valid for both input modes
- All required OpenAPI fields are present
## Migration Guide
### From URL to Schema
If you want to convert an existing URL-based configuration to schema-based:
1. Download your OpenAPI specification from the URL
2. Copy the JSON content
3. Update your configuration to use the `schema` field instead of `url`
4. Paste the JSON content as the value of the `schema` field
### Maintaining Both
You can include both `url` and `schema` in your configuration. The system will prioritize the `schema` field if both are present.
## Examples
See the `examples/openapi-schema-config.json` file for complete configuration examples showing both URL and schema-based configurations.
## Technical Implementation
- **Backend**: OpenAPI client supports both SwaggerParser.dereference() with URLs and direct schema objects
- **Frontend**: Dynamic form rendering based on selected input mode
- **Validation**: Enhanced validation logic in server controllers
- **Type Safety**: Updated TypeScript interfaces for both input modes
## Security Considerations
When using JSON schemas:
- Ensure schemas are properly validated before use
- Be aware that large schemas increase configuration file size
- Consider using URL-based approach for frequently changing APIs
- Store sensitive information (like API keys) in environment variables, not in schemas
## Troubleshooting
### Common Issues
1. **Invalid JSON**: Ensure your schema is valid JSON format
2. **Missing Required Fields**: OpenAPI schemas must include `openapi`, `info`, and `paths` fields
3. **Schema Size**: Very large schemas may impact performance
4. **Server Configuration**: Ensure the `servers` field in your schema points to the correct endpoints
### Validation Errors
The system provides detailed error messages for:
- Malformed JSON in schema field
- Missing required OpenAPI fields
- Invalid security configurations
- Network issues with URL-based configurations

172
docs/openapi-support.md Normal file
View File

@@ -0,0 +1,172 @@
# OpenAPI Support in MCPHub
MCPHub now supports OpenAPI 3.1.1 servers as a new server type, allowing you to integrate REST APIs directly into your MCP workflow.
## Features
-**Full OpenAPI 3.1.1 Support**: Load and parse OpenAPI specifications
-**Multiple Security Types**: None, API Key, HTTP Authentication, OAuth 2.0, OpenID Connect
-**Dynamic Tool Generation**: Automatically creates MCP tools from OpenAPI operations
-**Type Safety**: Full TypeScript support with proper type definitions
-**Frontend Integration**: Easy-to-use forms for configuring OpenAPI servers
-**Internationalization**: Support for English and Chinese languages
## Configuration
### Basic Configuration
```json
{
"type": "openapi",
"openapi": {
"url": "https://api.example.com/v1/openapi.json",
"version": "3.1.0",
"security": {
"type": "none"
}
}
}
```
### With API Key Authentication
```json
{
"type": "openapi",
"openapi": {
"url": "https://api.example.com/v1/openapi.json",
"version": "3.1.0",
"security": {
"type": "apiKey",
"apiKey": {
"name": "X-API-Key",
"in": "header",
"value": "your-api-key-here"
}
}
},
"headers": {
"Accept": "application/json"
}
}
```
### With HTTP Bearer Authentication
```json
{
"type": "openapi",
"openapi": {
"url": "https://api.example.com/v1/openapi.json",
"version": "3.1.0",
"security": {
"type": "http",
"http": {
"scheme": "bearer",
"credentials": "your-bearer-token-here"
}
}
}
}
```
## Supported Security Types
1. **None**: No authentication required
2. **API Key**: API key in header or query parameter
3. **HTTP**: Basic, Bearer, or Digest authentication
4. **OAuth 2.0**: OAuth 2.0 access tokens
5. **OpenID Connect**: OpenID Connect ID tokens
## How It Works
1. **Specification Loading**: The OpenAPI client fetches and parses the OpenAPI specification
2. **Tool Generation**: Each operation in the spec becomes an MCP tool
3. **Request Handling**: Tools handle parameter validation and API calls
4. **Response Processing**: API responses are returned as tool results
## Frontend Usage
1. Navigate to the Servers page
2. Click "Add Server"
3. Select "OpenAPI" as the server type
4. Enter the OpenAPI specification URL
5. Configure security settings if needed
6. Add any additional headers
7. Save the configuration
## Testing
You can test the OpenAPI integration using the provided test scripts:
```bash
# Test OpenAPI client directly
npx tsx test-openapi.ts
# Test full integration
npx tsx test-integration.ts
```
## Example: Swagger Petstore
The Swagger Petstore API is a perfect example for testing:
```json
{
"type": "openapi",
"openapi": {
"url": "https://petstore3.swagger.io/api/v3/openapi.json",
"version": "3.1.0",
"security": {
"type": "none"
}
}
}
```
This will create tools like:
- `addPet`: Add a new pet to the store
- `findPetsByStatus`: Find pets by status
- `getPetById`: Find pet by ID
- And many more...
## Error Handling
The OpenAPI client includes comprehensive error handling:
- Network errors are properly caught and reported
- Invalid specifications are rejected with clear error messages
- API errors include response status and body information
- Type validation ensures proper parameter handling
## Limitations
- Only supports OpenAPI 3.x specifications (3.0.0 and above)
- Complex authentication flows (like OAuth 2.0 authorization code flow) require manual token management
- Large specifications may take time to parse initially
- Some advanced OpenAPI features may not be fully supported
## Contributing
To add new features or fix bugs in the OpenAPI integration:
1. Backend types: `src/types/index.ts`
2. OpenAPI client: `src/clients/openapi.ts`
3. Service integration: `src/services/mcpService.ts`
4. Frontend forms: `frontend/src/components/ServerForm.tsx`
5. Internationalization: `frontend/src/locales/`
## Troubleshooting
**Q: My OpenAPI server won't connect**
A: Check that the specification URL is accessible and returns valid JSON/YAML
**Q: Tools aren't showing up**
A: Verify that your OpenAPI specification includes valid operations with required fields
**Q: Authentication isn't working**
A: Double-check your security configuration matches the API's requirements
**Q: Getting CORS errors**
A: The API server needs to allow CORS requests from your MCPHub domain

View File

@@ -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
View 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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
View 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>

View File

@@ -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) 获取支持!

View File

@@ -0,0 +1,256 @@
{
"mcpServers": {
"example-api-url": {
"type": "openapi",
"openapi": {
"url": "https://api.example.com/openapi.json",
"version": "3.1.0",
"security": {
"type": "apiKey",
"apiKey": {
"name": "X-API-Key",
"in": "header",
"value": "your-api-key-here"
}
}
},
"headers": {
"User-Agent": "MCPHub/1.0"
},
"enabled": true
},
"example-api-schema": {
"type": "openapi",
"openapi": {
"schema": {
"openapi": "3.1.0",
"info": {
"title": "Example API",
"version": "1.0.0",
"description": "A sample API for demonstration"
},
"servers": [
{
"url": "https://api.example.com",
"description": "Production server"
}
],
"paths": {
"/users": {
"get": {
"operationId": "listUsers",
"summary": "List all users",
"description": "Retrieve a list of all users in the system",
"parameters": [
{
"name": "limit",
"in": "query",
"description": "Maximum number of users to return",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 10
}
},
{
"name": "offset",
"in": "query",
"description": "Number of users to skip",
"required": false,
"schema": {
"type": "integer",
"minimum": 0,
"default": 0
}
}
],
"responses": {
"200": {
"description": "List of users",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"users": {
"type": "array",
"items": {
"$ref": "#/components/schemas/User"
}
},
"total": {
"type": "integer",
"description": "Total number of users"
}
}
}
}
}
}
}
},
"post": {
"operationId": "createUser",
"summary": "Create a new user",
"description": "Create a new user in the system",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateUserRequest"
}
}
}
},
"responses": {
"201": {
"description": "User created successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/User"
}
}
}
}
}
}
},
"/users/{userId}": {
"get": {
"operationId": "getUserById",
"summary": "Get user by ID",
"description": "Retrieve a specific user by their ID",
"parameters": [
{
"name": "userId",
"in": "path",
"required": true,
"description": "ID of the user to retrieve",
"schema": {
"type": "integer",
"minimum": 1
}
}
],
"responses": {
"200": {
"description": "User details",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/User"
}
}
}
},
"404": {
"description": "User not found"
}
}
}
}
},
"components": {
"schemas": {
"User": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"description": "Unique identifier for the user"
},
"name": {
"type": "string",
"description": "Full name of the user"
},
"email": {
"type": "string",
"format": "email",
"description": "Email address of the user"
},
"createdAt": {
"type": "string",
"format": "date-time",
"description": "Timestamp when the user was created"
},
"status": {
"type": "string",
"enum": [
"active",
"inactive",
"suspended"
],
"description": "Current status of the user"
}
},
"required": [
"id",
"name",
"email"
]
},
"CreateUserRequest": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 100,
"description": "Full name of the user"
},
"email": {
"type": "string",
"format": "email",
"description": "Email address of the user"
},
"status": {
"type": "string",
"enum": [
"active",
"inactive"
],
"default": "active",
"description": "Initial status of the user"
}
},
"required": [
"name",
"email"
]
}
},
"securitySchemes": {
"ApiKeyAuth": {
"type": "apiKey",
"in": "header",
"name": "X-API-Key"
}
}
},
"security": [
{
"ApiKeyAuth": []
}
]
},
"version": "3.1.0",
"security": {
"type": "apiKey",
"apiKey": {
"name": "X-API-Key",
"in": "header",
"value": "your-api-key-here"
}
}
},
"headers": {
"User-Agent": "MCPHub/1.0"
},
"enabled": true
}
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)

View File

@@ -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>
)
}

View File

@@ -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">

View 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;

View 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;

View 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;

View File

@@ -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>
)

View File

@@ -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 }))
}

View File

@@ -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>

View File

@@ -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' : ''
}`}
>

View 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;

View File

@@ -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>

View File

@@ -2,11 +2,14 @@ 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';
interface MarketServerDetailProps {
server: MarketServer;
onBack: () => void;
onInstall: (server: MarketServer) => void;
onInstall: (server: MarketServer, config: ServerConfig) => void;
installing?: boolean;
isInstalled?: boolean;
}
@@ -21,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 = () => {
@@ -38,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')
};
@@ -48,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 = () => {
@@ -70,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 to the parent component for installation
onInstall(server);
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'));
}
};
@@ -112,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>
@@ -130,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>
)}
@@ -167,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')}
@@ -196,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">
@@ -226,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>
@@ -279,19 +314,73 @@ 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>
);
};
export default MarketServerDetail;
export default MarketServerDetail;

View 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;

View File

@@ -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 />;
};

View File

@@ -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>

View File

@@ -26,7 +26,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
}
};
const [serverType, setServerType] = useState<'stdio' | 'sse' | 'streamable-http'>(getInitialServerType());
const [serverType, setServerType] = useState<'stdio' | 'sse' | 'streamable-http' | 'openapi'>(getInitialServerType());
const [formData, setFormData] = useState<ServerFormData>({
name: (initialData && initialData.name) || '',
@@ -41,7 +41,38 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
args: (initialData && initialData.config && initialData.config.args) || [],
type: getInitialServerType(), // Initialize the type field
env: [],
headers: []
headers: [],
options: {
timeout: (initialData && initialData.config && initialData.config.options && initialData.config.options.timeout) || 60000,
resetTimeoutOnProgress: (initialData && initialData.config && initialData.config.options && initialData.config.options.resetTimeoutOnProgress) || false,
maxTotalTimeout: (initialData && initialData.config && initialData.config.options && initialData.config.options.maxTotalTimeout) || undefined,
},
// OpenAPI configuration initialization
openapi: initialData && initialData.config && initialData.config.openapi ? {
url: initialData.config.openapi.url || '',
schema: initialData.config.openapi.schema ? JSON.stringify(initialData.config.openapi.schema, null, 2) : '',
inputMode: initialData.config.openapi.url ? 'url' : (initialData.config.openapi.schema ? 'schema' : 'url'),
version: initialData.config.openapi.version || '3.1.0',
securityType: initialData.config.openapi.security?.type || 'none',
// API Key initialization
apiKeyName: initialData.config.openapi.security?.apiKey?.name || '',
apiKeyIn: initialData.config.openapi.security?.apiKey?.in || 'header',
apiKeyValue: initialData.config.openapi.security?.apiKey?.value || '',
// HTTP auth initialization
httpScheme: initialData.config.openapi.security?.http?.scheme || 'bearer',
httpCredentials: initialData.config.openapi.security?.http?.credentials || '',
// OAuth2 initialization
oauth2Token: initialData.config.openapi.security?.oauth2?.token || '',
// OpenID Connect initialization
openIdConnectUrl: initialData.config.openapi.security?.openIdConnect?.url || '',
openIdConnectToken: initialData.config.openapi.security?.openIdConnect?.token || ''
} : {
inputMode: 'url',
url: '',
schema: '',
version: '3.1.0',
securityType: 'none'
}
})
const [envVars, setEnvVars] = useState<EnvVar[]>(
@@ -56,6 +87,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
: [],
)
const [isRequestOptionsExpanded, setIsRequestOptionsExpanded] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const isEdit = !!initialData
@@ -66,11 +98,11 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
// Transform space-separated arguments string into array
const handleArgsChange = (value: string) => {
let args = value.split(' ').filter((arg) => arg.trim() !== '')
const args = value.split(' ').filter((arg) => arg.trim() !== '')
setFormData({ ...formData, arguments: value, args })
}
const updateServerType = (type: 'stdio' | 'sse' | 'streamable-http') => {
const updateServerType = (type: 'stdio' | 'sse' | 'streamable-http' | 'openapi') => {
setServerType(type);
setFormData(prev => ({ ...prev, type }));
}
@@ -107,6 +139,17 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
setHeaderVars(newHeaderVars)
}
// Handle options changes
const handleOptionsChange = (field: 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout', value: number | boolean | undefined) => {
setFormData(prev => ({
...prev,
options: {
...prev.options,
[field]: value
}
}))
}
// Submit handler for server configuration
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -127,21 +170,87 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
}
})
// Prepare options object, only include defined values
const options: any = {}
if (formData.options?.timeout && formData.options.timeout !== 60000) {
options.timeout = formData.options.timeout
}
if (formData.options?.resetTimeoutOnProgress) {
options.resetTimeoutOnProgress = formData.options.resetTimeoutOnProgress
}
if (formData.options?.maxTotalTimeout) {
options.maxTotalTimeout = formData.options.maxTotalTimeout
}
const payload = {
name: formData.name,
config: {
type: serverType, // Always include the type
...(serverType === 'sse' || serverType === 'streamable-http'
...(serverType === 'openapi'
? {
url: formData.url,
openapi: (() => {
const openapi: any = {
version: formData.openapi?.version || '3.1.0'
};
// Add URL or schema based on input mode
if (formData.openapi?.inputMode === 'url') {
openapi.url = formData.openapi?.url || '';
} else if (formData.openapi?.inputMode === 'schema' && formData.openapi?.schema) {
try {
openapi.schema = JSON.parse(formData.openapi.schema);
} catch (e) {
throw new Error('Invalid JSON schema format');
}
}
// Add security configuration if provided
if (formData.openapi?.securityType && formData.openapi.securityType !== 'none') {
openapi.security = {
type: formData.openapi.securityType,
...(formData.openapi.securityType === 'apiKey' && {
apiKey: {
name: formData.openapi.apiKeyName || '',
in: formData.openapi.apiKeyIn || 'header',
value: formData.openapi.apiKeyValue || ''
}
}),
...(formData.openapi.securityType === 'http' && {
http: {
scheme: formData.openapi.httpScheme || 'bearer',
credentials: formData.openapi.httpCredentials || ''
}
}),
...(formData.openapi.securityType === 'oauth2' && {
oauth2: {
token: formData.openapi.oauth2Token || ''
}
}),
...(formData.openapi.securityType === 'openIdConnect' && {
openIdConnect: {
url: formData.openapi.openIdConnectUrl || '',
token: formData.openapi.openIdConnectToken || ''
}
})
};
}
return openapi;
})(),
...(Object.keys(headers).length > 0 ? { headers } : {})
}
: {
command: formData.command,
args: formData.args,
env: Object.keys(env).length > 0 ? env : undefined,
}
)
: serverType === 'sse' || serverType === 'streamable-http'
? {
url: formData.url,
...(Object.keys(headers).length > 0 ? { headers } : {})
}
: {
command: formData.command,
args: formData.args,
env: Object.keys(env).length > 0 ? env : undefined,
}
),
...(Object.keys(options).length > 0 ? { options } : {})
}
}
@@ -177,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}
@@ -223,10 +332,334 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
/>
<label htmlFor="streamable-http">Streamable HTTP</label>
</div>
<div>
<input
type="radio"
id="openapi"
name="serverType"
value="openapi"
checked={serverType === 'openapi'}
onChange={() => updateServerType('openapi')}
className="mr-1"
/>
<label htmlFor="openapi">OpenAPI</label>
</div>
</div>
</div>
{serverType === 'sse' || serverType === 'streamable-http' ? (
{serverType === 'openapi' ? (
<>
{/* Input Mode Selection */}
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">
{t('server.openapi.inputMode')}
</label>
<div className="flex space-x-4">
<div>
<input
type="radio"
id="input-mode-url"
name="inputMode"
value="url"
checked={formData.openapi?.inputMode === 'url'}
onChange={() => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi!, inputMode: 'url' }
}))}
className="mr-1"
/>
<label htmlFor="input-mode-url">{t('server.openapi.inputModeUrl')}</label>
</div>
<div>
<input
type="radio"
id="input-mode-schema"
name="inputMode"
value="schema"
checked={formData.openapi?.inputMode === 'schema'}
onChange={() => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi!, inputMode: 'schema' }
}))}
className="mr-1"
/>
<label htmlFor="input-mode-schema">{t('server.openapi.inputModeSchema')}</label>
</div>
</div>
</div>
{/* URL Input */}
{formData.openapi?.inputMode === 'url' && (
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="openapi-url">
{t('server.openapi.specUrl')}
</label>
<input
type="url"
name="openapi-url"
id="openapi-url"
value={formData.openapi?.url || ''}
onChange={(e) => setFormData(prev => ({
...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 form-input"
placeholder="e.g.: https://api.example.com/openapi.json"
required={serverType === 'openapi' && formData.openapi?.inputMode === 'url'}
/>
</div>
)}
{/* Schema Input */}
{formData.openapi?.inputMode === 'schema' && (
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="openapi-schema">
{t('server.openapi.schema')}
</label>
<textarea
name="openapi-schema"
id="openapi-schema"
rows={10}
value={formData.openapi?.schema || ''}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi!, schema: 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 font-mono text-sm"
placeholder={`{
"openapi": "3.1.0",
"info": {
"title": "API",
"version": "1.0.0"
},
"servers": [
{
"url": "https://api.example.com"
}
],
"paths": {
...
}
}`}
required={serverType === 'openapi' && formData.openapi?.inputMode === 'schema'}
/>
<p className="text-xs text-gray-500 mt-1">{t('server.openapi.schemaHelp')}</p>
</div>
)}
{/* Security Configuration */}
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">
{t('server.openapi.security')}
</label>
<select
value={formData.openapi?.securityType || 'none'}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: {
...prev.openapi,
securityType: e.target.value as any,
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 form-input"
>
<option value="none">{t('server.openapi.securityNone')}</option>
<option value="apiKey">{t('server.openapi.securityApiKey')}</option>
<option value="http">{t('server.openapi.securityHttp')}</option>
<option value="oauth2">{t('server.openapi.securityOAuth2')}</option>
<option value="openIdConnect">{t('server.openapi.securityOpenIdConnect')}</option>
</select>
</div>
{/* API Key Configuration */}
{formData.openapi?.securityType === 'apiKey' && (
<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>
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.apiKeyName')}</label>
<input
type="text"
value={formData.openapi?.apiKeyName || ''}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi, apiKeyName: e.target.value, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm form-input focus:outline-none"
placeholder="Authorization"
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.apiKeyIn')}</label>
<select
value={formData.openapi?.apiKeyIn || 'header'}
onChange={(e) => setFormData(prev => ({
...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 focus:outline-none form-input"
>
<option value="header">Header</option>
<option value="query">Query</option>
<option value="cookie">Cookie</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.apiKeyValue')}</label>
<input
type="password"
value={formData.openapi?.apiKeyValue || ''}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi, apiKeyValue: e.target.value, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
placeholder="your-api-key"
/>
</div>
</div>
</div>
)}
{/* HTTP Authentication Configuration */}
{formData.openapi?.securityType === 'http' && (
<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>
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.httpScheme')}</label>
<select
value={formData.openapi?.httpScheme || 'bearer'}
onChange={(e) => setFormData(prev => ({
...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 focus:outline-none form-input"
>
<option value="basic">Basic</option>
<option value="bearer">Bearer</option>
<option value="digest">Digest</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.httpCredentials')}</label>
<input
type="password"
value={formData.openapi?.httpCredentials || ''}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi, httpCredentials: e.target.value, url: prev.openapi?.url || '' }
}))}
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>
</div>
</div>
)}
{/* OAuth2 Configuration */}
{formData.openapi?.securityType === 'oauth2' && (
<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>
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.oauth2Token')}</label>
<input
type="password"
value={formData.openapi?.oauth2Token || ''}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi, oauth2Token: e.target.value, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
placeholder="access-token"
/>
</div>
</div>
</div>
)}
{/* OpenID Connect Configuration */}
{formData.openapi?.securityType === 'openIdConnect' && (
<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>
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.openIdConnectUrl')}</label>
<input
type="url"
value={formData.openapi?.openIdConnectUrl || ''}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi, openIdConnectUrl: e.target.value, url: prev.openapi?.url || '' }
}))}
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>
<div>
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.openIdConnectToken')}</label>
<input
type="password"
value={formData.openapi?.openIdConnectToken || ''}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi, openIdConnectToken: e.target.value, url: prev.openapi?.url || '' }
}))}
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' ? (
<>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="url">
@@ -238,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'}
/>
@@ -252,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) => (
@@ -264,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>
@@ -272,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>
))}
@@ -299,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'}
/>
@@ -314,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'}
/>
@@ -328,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) => (
@@ -340,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>
@@ -348,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>
))}
@@ -365,17 +798,88 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
</>
)}
{/* Request Options Configuration */}
{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 border-gray-200"
onClick={() => setIsRequestOptionsExpanded(!isRequestOptionsExpanded)}
>
<label className="text-gray-700 text-sm font-bold">
{t('server.requestOptions')}
</label>
<span className="text-gray-500 text-sm">
{isRequestOptionsExpanded ? '▼' : '▶'}
</span>
</div>
{isRequestOptionsExpanded && (
<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">
{t('server.timeout')}
</label>
<input
type="number"
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 form-input"
placeholder="30000"
min="1000"
max="300000"
/>
<p className="text-xs text-gray-500 mt-1">{t('server.timeoutDescription')}</p>
</div>
<div>
<label className="block text-gray-600 text-sm font-medium mb-1" htmlFor="maxTotalTimeout">
{t('server.maxTotalTimeout')}
</label>
<input
type="number"
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 form-input"
placeholder="Optional"
min="1000"
/>
<p className="text-xs text-gray-500 mt-1">{t('server.maxTotalTimeoutDescription')}</p>
</div>
</div>
<div className="mt-3">
<label className="flex items-center">
<input
type="checkbox"
checked={formData.options?.resetTimeoutOnProgress || false}
onChange={(e) => handleOptionsChange('resetTimeoutOnProgress', e.target.checked)}
className="mr-2"
/>
<span className="text-gray-600 text-sm">{t('server.resetTimeoutOnProgress')}</span>
</label>
<p className="text-xs text-gray-500 mt-1 ml-6">
{t('server.resetTimeoutOnProgressDescription')}
</p>
</div>
</div>
)}
</div>
)}
<div className="flex justify-end mt-6">
<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>

View 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>
);
};

View 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;

View File

@@ -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

View 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));
};

View File

@@ -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>
);
};

View File

@@ -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} />

View File

@@ -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'

View File

@@ -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

View 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;

View File

@@ -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>

View File

@@ -18,12 +18,16 @@ 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>>({});
const [isJsonMode, setIsJsonMode] = useState<boolean>(false);
const [jsonText, setJsonText] = useState<string>('');
const [jsonError, setJsonError] = useState<string>('');
// Convert ToolInputSchema to JsonSchema - memoized to prevent infinite re-renders
const jsonSchema = useMemo(() => {
@@ -77,7 +81,13 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
} else if (propSchema.type === 'array') {
values[key] = [];
} else if (propSchema.type === 'object') {
values[key] = initializeValues(propSchema, fullPath);
// For objects with properties, recursively initialize
if (propSchema.properties) {
values[key] = initializeValues(propSchema, fullPath);
} else {
// For objects without properties, initialize as empty object
values[key] = {};
}
}
});
}
@@ -104,6 +114,58 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
setFormValues(initialValues);
}, [jsonSchema, storageKey]);
// Sync JSON text with form values when switching modes
useEffect(() => {
if (isJsonMode && Object.keys(formValues).length > 0) {
setJsonText(JSON.stringify(formValues, null, 2));
setJsonError('');
}
}, [isJsonMode, formValues]);
const handleJsonTextChange = (text: string) => {
setJsonText(text);
setJsonError('');
try {
const parsedJson = JSON.parse(text);
setFormValues(parsedJson);
// Save to localStorage if storageKey is provided
if (storageKey) {
try {
localStorage.setItem(storageKey, JSON.stringify(parsedJson));
} catch (error) {
console.warn('Failed to save form data to localStorage:', error);
}
}
} catch (error) {
setJsonError(t('tool.invalidJsonFormat'));
}
};
const switchToJsonMode = () => {
setJsonText(JSON.stringify(formValues, null, 2));
setJsonError('');
setIsJsonMode(true);
};
const switchToFormMode = () => {
// Validate JSON before switching
if (jsonText.trim()) {
try {
const parsedJson = JSON.parse(jsonText);
setFormValues(parsedJson);
setJsonError('');
setIsJsonMode(false);
} catch (error) {
setJsonError(t('tool.fixJsonBeforeSwitching'));
return;
}
} else {
setIsJsonMode(false);
}
};
const handleInputChange = (path: string, value: any) => {
setFormValues(prev => {
const newValues = { ...prev };
@@ -140,7 +202,6 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
});
}
};
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
@@ -148,7 +209,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
if (schema.type === 'object' && schema.properties) {
Object.entries(schema.properties).forEach(([key, propSchema]) => {
const fullPath = path ? `${path}.${key}` : key;
const value = values?.[key];
const value = getNestedValue(values, fullPath);
// Check required fields
if (schema.required?.includes(key) && (value === undefined || value === null || value === '')) {
@@ -166,6 +227,15 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
newErrors[fullPath] = `${key} must be an integer`;
} else if (propSchema.type === 'boolean' && typeof value !== 'boolean') {
newErrors[fullPath] = `${key} must be a boolean`;
} else if (propSchema.type === 'array' && Array.isArray(value)) {
// Validate array items
if (propSchema.items) {
value.forEach((item: any, index: number) => {
if (propSchema.items?.type === 'object' && propSchema.items.properties) {
validateObject(propSchema.items, item, `${fullPath}.${index}`);
}
});
}
} else if (propSchema.type === 'object' && typeof value === 'object') {
validateObject(propSchema, value, fullPath);
}
@@ -186,18 +256,240 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
}
};
const getNestedValue = (obj: any, path: string): any => {
return path.split('.').reduce((current, key) => current?.[key], obj);
};
const renderObjectField = (key: string, schema: JsonSchema, currentValue: any, onChange: (value: any) => void): React.ReactNode => {
const value = currentValue?.[key];
if (schema.type === 'string') {
if (schema.enum) {
return (
<select
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"
>
<option value="">{t('tool.selectOption')}</option>
{schema.enum.map((option: any, idx: number) => (
<option key={idx} value={option}>
{option}
</option>
))}
</select>
);
} else {
return (
<input
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 form-input"
placeholder={schema.description || t('tool.enterKey', { key })}
/>
);
}
}
if (schema.type === 'number' || schema.type === 'integer') {
return (
<input
type="number"
step={schema.type === 'integer' ? '1' : 'any'}
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 form-input"
/>
);
}
if (schema.type === 'boolean') {
return (
<input
type="checkbox"
checked={value || false}
onChange={(e) => onChange(e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
);
}
// Default to text input
return (
<input
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 form-input"
placeholder={schema.description || t('tool.enterKey', { key })}
/>
);
};
const renderField = (key: string, propSchema: JsonSchema, path: string = ''): React.ReactNode => {
const fullPath = path ? `${path}.${key}` : key;
const value = formValues[key];
const error = errors[fullPath];
const value = getNestedValue(formValues, fullPath);
const error = errors[fullPath]; // Handle array type
if (propSchema.type === 'array') {
const arrayValue = getNestedValue(formValues, fullPath) || [];
if (propSchema.type === 'string') {
return (
<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-status-red ml-1">*</span>}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
)}
<div className="border border-gray-200 rounded-md p-3 bg-gray-50">
{arrayValue.map((item: any, index: number) => (
<div key={index} className="mb-3 p-3 bg-white border rounded-md">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-gray-600">{t('tool.item', { index: index + 1 })}</span>
<button
type="button"
onClick={() => {
const newArray = [...arrayValue];
newArray.splice(index, 1);
handleInputChange(fullPath, newArray);
}}
className="text-status-red hover:text-red-700 text-sm"
>
{t('common.remove')}
</button>
</div>
{propSchema.items?.type === 'string' && propSchema.items.enum ? (
<select
value={item || ''}
onChange={(e) => {
const newArray = [...arrayValue];
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"
>
<option value="">{t('tool.selectOption')}</option>
{propSchema.items.enum.map((option: any, idx: number) => (
<option key={idx} value={option}>
{option}
</option>
))}
</select>
) : propSchema.items?.type === 'object' && propSchema.items.properties ? (
<div className="space-y-3">
{Object.entries(propSchema.items.properties).map(([objKey, objSchema]) => (
<div key={objKey}>
<label className="block text-xs font-medium text-gray-600 mb-1">
{objKey}
{propSchema.items?.required?.includes(objKey) && <span className="text-status-red ml-1">*</span>}
</label>
{renderObjectField(objKey, objSchema as JsonSchema, item, (newValue) => {
const newArray = [...arrayValue];
newArray[index] = { ...newArray[index], [objKey]: newValue };
handleInputChange(fullPath, newArray);
})}
</div>
))}
</div>
) : (
<input
type="text"
value={item || ''}
onChange={(e) => {
const newArray = [...arrayValue];
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 form-input"
placeholder={t('tool.enterValue', { type: propSchema.items?.type || 'value' })}
/>
)}
</div>
))}
<button
type="button"
onClick={() => {
const newItem = propSchema.items?.type === 'object' ? {} : '';
handleInputChange(fullPath, [...arrayValue, newItem]);
}}
className="w-full mt-2 px-3 py-2 text-sm text-blue-600 border border-blue-300 rounded-md hover:bg-blue-50"
>
{t('tool.addItem', { key })}
</button>
</div>
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
} // Handle object type
if (propSchema.type === 'object') {
if (propSchema.properties) {
// Object with defined properties - render as nested form
return (
<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-status-red ml-1">*</span>}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
)}
<div className="border border-gray-200 rounded-md p-4 bg-gray-50">
{Object.entries(propSchema.properties).map(([objKey, objSchema]) => (
renderField(objKey, objSchema as JsonSchema, fullPath)
))}
</div>
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
} else {
// Object without defined properties - render as JSON textarea
return (
<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-status-red ml-1">*</span>}
<span className="text-xs text-gray-500 ml-1">(JSON object)</span>
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
)}
<textarea
value={typeof value === 'object' ? JSON.stringify(value, null, 2) : value || '{}'}
onChange={(e) => {
try {
const parsedValue = JSON.parse(e.target.value);
handleInputChange(fullPath, parsedValue);
} catch (err) {
// Keep the string value if it's not valid JSON yet
handleInputChange(fullPath, e.target.value);
}
}}
placeholder={`{\n "key": "value"\n}`}
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-status-red text-xs mt-1">{error}</p>}
</div>
);
}
} if (propSchema.type === 'string') {
if (propSchema.enum) {
return (
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{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>
@@ -214,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 {
@@ -222,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}
{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>
@@ -231,20 +523,18 @@ 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>
);
}
}
if (propSchema.type === 'number' || propSchema.type === 'integer') {
} if (propSchema.type === 'number' || propSchema.type === 'integer') {
return (
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{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>
@@ -252,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>
);
}
@@ -276,23 +566,21 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
/>
<label className="ml-2 block text-sm text-gray-700">
{key}
{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
} // For other types, show as text input with description
return (
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{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 && (
@@ -303,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>
);
};
@@ -335,28 +623,101 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
{Object.entries(jsonSchema.properties || {}).map(([key, propSchema]) =>
renderField(key, propSchema)
)}
<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"
>
{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"
>
{loading ? t('tool.running') : t('tool.runTool')}
</button>
<div className="space-y-4">
{/* Mode Toggle */}
<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-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')}
</button>
<button
type="button"
onClick={switchToJsonMode}
className={`px-3 py-1 text-sm rounded-md transition-colors ${isJsonMode
? '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')}
</button>
</div>
</div>
</form>
{/* JSON Mode */}
{isJsonMode ? (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('tool.jsonConfiguration')}
</label>
<textarea
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 form-input ${jsonError ? 'border-red-500' : 'border-gray-300'
} focus:outline-none focus:ring-2 focus:ring-blue-500`}
/>
{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-1 text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary"
>
{t('tool.cancel')}
</button>
<button
onClick={() => {
try {
const parsedJson = JSON.parse(jsonText);
onSubmit(parsedJson);
} catch (error) {
setJsonError(t('tool.invalidJsonFormat'));
}
}}
disabled={loading || !!jsonError}
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>
</div>
</div>
) : (
/* Form Mode */
<form onSubmit={handleSubmit} className="space-y-4">
{Object.entries(jsonSchema.properties || {}).map(([key, propSchema]) =>
renderField(key, propSchema)
)}
<div className="flex justify-end space-x-2 pt-4">
<button
type="button"
onClick={onCancel}
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-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary"
>
{loading ? t('tool.running') : t('tool.runTool')}
</button>
</div>
</form>
)}
</div>
);
};

View 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;

View File

@@ -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'
}`}
>
&laquo; 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 &raquo;
</button>

View File

@@ -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>
);
};

View File

@@ -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)}

View File

@@ -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 && (

View File

@@ -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">

View File

@@ -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>
);
};

View 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;

View File

@@ -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,

View 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,
};
};

View File

@@ -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;
}

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { MarketServer, ApiResponse } from '@/types';
import { getApiUrl } from '../utils/runtime';
import { MarketServer, ApiResponse, ServerConfig } from '@/types';
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
@@ -347,7 +259,7 @@ export const useMarketData = () => {
// Install server to the local environment
const installServer = useCallback(
async (server: MarketServer) => {
async (server: MarketServer, customConfig: ServerConfig) => {
try {
const installType = server.installations?.npm
? 'npm'
@@ -362,30 +274,27 @@ export const useMarketData = () => {
const installation = server.installations[installType];
// Prepare server configuration
// Prepare server configuration, merging with customConfig
const serverConfig = {
name: server.name,
config: {
command: installation.command,
args: installation.args,
env: installation.env || {},
},
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

View File

@@ -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;
}

View File

@@ -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,
};
};

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -1,325 +0,0 @@
{
"app": {
"title": "MCP Hub Dashboard",
"error": "Error",
"closeButton": "Close",
"noServers": "No MCP servers available",
"loading": "Loading...",
"logout": "Logout",
"profile": "Profile",
"changePassword": "Change Password",
"toggleSidebar": "Toggle Sidebar",
"welcomeUser": "Welcome, {{username}}",
"name": "MCP Hub"
},
"about": {
"title": "About",
"versionInfo": "MCP Hub Version: {{version}}",
"newVersion": "New version available!",
"currentVersion": "Current version",
"newVersionAvailable": "New version {{version}} is available",
"viewOnGitHub": "View on GitHub",
"checkForUpdates": "Check for Updates",
"checking": "Checking for updates..."
},
"profile": {
"viewProfile": "View profile",
"userCenter": "User Center"
},
"sponsor": {
"label": "Sponsor",
"title": "Support the Project",
"rewardAlt": "Reward QR Code",
"supportMessage": "Support the development of MCP Hub by buying me a coffee!",
"supportButton": "Support on Ko-fi"
},
"wechat": {
"label": "WeChat",
"title": "Connect via WeChat",
"qrCodeAlt": "WeChat QR Code",
"scanMessage": "Scan this QR code to connect with us on WeChat"
},
"discord": {
"label": "Discord",
"title": "Join our Discord server",
"community": "Join our growing community on Discord for support, discussions, and updates!"
},
"theme": {
"title": "Theme",
"light": "Light",
"dark": "Dark",
"system": "System"
},
"auth": {
"login": "Login",
"loginTitle": "Login to MCP Hub",
"username": "Username",
"password": "Password",
"loggingIn": "Logging in...",
"emptyFields": "Username and password cannot be empty",
"loginFailed": "Login failed, please check your username and password",
"loginError": "An error occurred during login",
"currentPassword": "Current Password",
"newPassword": "New Password",
"confirmPassword": "Confirm Password",
"passwordsNotMatch": "New password and confirmation do not match",
"changePasswordSuccess": "Password changed successfully",
"changePasswordError": "Failed to change password",
"changePassword": "Change Password",
"passwordChanged": "Password changed successfully",
"passwordChangeError": "Failed to change password"
},
"server": {
"addServer": "Add Server",
"add": "Add",
"edit": "Edit",
"delete": "Delete",
"confirmDelete": "Are you sure you want to delete this server?",
"deleteWarning": "Deleting server '{{name}}' will remove it and all its data. This action cannot be undone.",
"status": "Status",
"tools": "Tools",
"name": "Server Name",
"url": "Server URL",
"apiKey": "API Key",
"save": "Save",
"cancel": "Cancel",
"invalidConfig": "Could not find configuration data for {{serverName}}",
"addError": "Failed to add server",
"editError": "Failed to edit server {{serverName}}",
"deleteError": "Failed to delete server {{serverName}}",
"updateError": "Failed to update server",
"editTitle": "Edit Server: {{serverName}}",
"type": "Server Type",
"command": "Command",
"arguments": "Arguments",
"envVars": "Environment Variables",
"headers": "HTTP Headers",
"key": "key",
"value": "value",
"enabled": "Enabled",
"enable": "Enable",
"disable": "Disable",
"remove": "Remove",
"toggleError": "Failed to toggle server {{serverName}}",
"alreadyExists": "Server {{serverName}} already exists",
"invalidData": "Invalid server data provided",
"notFound": "Server {{serverName}} not found",
"namePlaceholder": "Enter server name",
"urlPlaceholder": "Enter server URL",
"commandPlaceholder": "Enter command",
"argumentsPlaceholder": "Enter arguments",
"errorDetails": "Error Details",
"viewErrorDetails": "View error details"
},
"status": {
"online": "Online",
"offline": "Offline",
"connecting": "Connecting"
},
"errors": {
"general": "Something went wrong",
"network": "Network connection error. Please check your internet connection",
"serverConnection": "Unable to connect to the server. Please check if the server is running",
"serverAdd": "Failed to add server. Please check the server status",
"serverUpdate": "Failed to edit server {{serverName}}. Please check the server status",
"serverFetch": "Failed to retrieve server data. Please try again later",
"initialStartup": "The server might be starting up. Please wait a moment as this process can take some time on first launch...",
"serverInstall": "Failed to install server",
"failedToFetchSettings": "Failed to fetch settings",
"failedToUpdateRouteConfig": "Failed to update route configuration",
"failedToUpdateSmartRoutingConfig": "Failed to update smart routing configuration"
},
"common": {
"processing": "Processing...",
"save": "Save",
"cancel": "Cancel",
"refresh": "Refresh",
"create": "Create",
"submitting": "Submitting...",
"delete": "Delete",
"copy": "Copy",
"copySuccess": "Copied to clipboard",
"copyFailed": "Copy failed",
"close": "Close"
},
"nav": {
"dashboard": "Dashboard",
"servers": "Servers",
"groups": "Groups",
"settings": "Settings",
"changePassword": "Change Password",
"market": "Market",
"logs": "Logs"
},
"pages": {
"dashboard": {
"title": "Dashboard",
"totalServers": "Total",
"onlineServers": "Online",
"offlineServers": "Offline",
"connectingServers": "Connecting",
"recentServers": "Recent Servers"
},
"servers": {
"title": "Servers Management"
},
"groups": {
"title": "Group Management"
},
"settings": {
"title": "Settings",
"language": "Language",
"account": "Account Settings",
"password": "Change Password",
"appearance": "Appearance",
"routeConfig": "Security",
"installConfig": "Installation",
"smartRouting": "Smart Routing"
},
"market": {
"title": "Server Market - (Data from mcpm.sh)"
},
"logs": {
"title": "System Logs"
}
},
"logs": {
"filters": "Filters",
"search": "Search logs...",
"autoScroll": "Auto-scroll",
"clearLogs": "Clear logs",
"loading": "Loading logs...",
"noLogs": "No logs available.",
"noMatch": "No logs match the current filters.",
"mainProcess": "Main Process",
"childProcess": "Child Process",
"main": "Main",
"child": "Child"
},
"groups": {
"add": "Add",
"addNew": "Add New Group",
"edit": "Edit Group",
"delete": "Delete",
"confirmDelete": "Are you sure you want to delete this group?",
"deleteWarning": "Deleting group '{{name}}' will remove it and all its server associations. This action cannot be undone.",
"name": "Group Name",
"namePlaceholder": "Enter group name",
"nameRequired": "Group name is required",
"description": "Description",
"descriptionPlaceholder": "Enter group description (optional)",
"createError": "Failed to create group",
"updateError": "Failed to update group",
"deleteError": "Failed to delete group",
"serverAddError": "Failed to add server to group",
"serverRemoveError": "Failed to remove server from group",
"addServer": "Add Server to Group",
"selectServer": "Select a server to add",
"servers": "Servers in Group",
"remove": "Remove",
"noGroups": "No groups available. Create a new group to get started.",
"noServers": "No servers in this group.",
"noServerOptions": "No servers available",
"serverCount": "{{count}} Servers"
},
"market": {
"title": "Server Market",
"official": "Official",
"by": "By",
"unknown": "Unknown",
"tools": "tools",
"search": "Search",
"searchPlaceholder": "Search for servers by name, category, or tags",
"clearFilters": "Clear",
"clearCategoryFilter": "",
"clearTagFilter": "",
"categories": "Categories",
"tags": "Tags",
"showTags": "Show tags",
"hideTags": "Hide tags",
"moreTags": "",
"noServers": "No servers found matching your search",
"backToList": "Back to list",
"install": "Install",
"installing": "Installing...",
"installed": "Installed",
"installServer": "Install Server: {{name}}",
"installSuccess": "Server {{serverName}} installed successfully",
"author": "Author",
"license": "License",
"repository": "Repository",
"examples": "Examples",
"arguments": "Arguments",
"argumentName": "Name",
"description": "Description",
"required": "Required",
"example": "Example",
"viewSchema": "View schema",
"fetchError": "Error fetching market servers",
"serverNotFound": "Server not found",
"searchError": "Error searching servers",
"filterError": "Error filtering servers by category",
"tagFilterError": "Error filtering servers by tag",
"noInstallationMethod": "No installation method available for this server",
"showing": "Showing {{from}}-{{to}} of {{total}} servers",
"perPage": "Per page"
},
"tool": {
"run": "Run",
"running": "Running...",
"runTool": "Run Tool",
"cancel": "Cancel",
"noDescription": "No description available",
"inputSchema": "Input Schema:",
"runToolWithName": "Run Tool: {{name}}",
"execution": "Tool Execution",
"successful": "Successful",
"failed": "Failed",
"result": "Result:",
"error": "Error",
"errorDetails": "Error Details:",
"noContent": "Tool executed successfully but returned no content.",
"unknownError": "Unknown error occurred",
"jsonResponse": "JSON Response:",
"toolResult": "Tool result",
"noParameters": "This tool does not require any parameters.",
"selectOption": "Select an option",
"enterValue": "Enter {{type}} value",
"enabled": "Enabled",
"enableSuccess": "Tool {{name}} enabled successfully",
"disableSuccess": "Tool {{name}} disabled successfully",
"toggleFailed": "Failed to toggle tool status"
},
"settings": {
"enableGlobalRoute": "Enable Global Route",
"enableGlobalRouteDescription": "Allow connections to /sse endpoint without specifying a group ID",
"enableGroupNameRoute": "Enable Group Name Route",
"enableGroupNameRouteDescription": "Allow connections to /sse endpoint using group names instead of just group IDs",
"enableBearerAuth": "Enable Bearer Authentication",
"enableBearerAuthDescription": "Require bearer token authentication for MCP requests",
"bearerAuthKey": "Bearer Authentication Key",
"bearerAuthKeyDescription": "The authentication key that will be required in the Bearer token",
"bearerAuthKeyPlaceholder": "Enter bearer authentication key",
"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/",
"installConfig": "Installation",
"systemConfigUpdated": "System configuration updated successfully",
"enableSmartRouting": "Enable Smart Routing",
"enableSmartRoutingDescription": "Enable smart routing feature to search the most suitable tool based on input (using $smart group name)",
"dbUrl": "PostgreSQL URL (requires pgvector support)",
"dbUrlPlaceholder": "e.g. postgresql://user:password@localhost:5432/dbname",
"openaiApiBaseUrl": "OpenAI API Base URL",
"openaiApiBaseUrlPlaceholder": "https://api.openai.com/v1",
"openaiApiKey": "OpenAI API Key",
"openaiApiKeyPlaceholder": "Enter OpenAI API key",
"openaiApiEmbeddingModel": "OpenAI Embedding Model",
"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}}"
}
}

View File

@@ -1,327 +0,0 @@
{
"app": {
"title": "MCP Hub 控制面板",
"error": "错误",
"closeButton": "关闭",
"noServers": "没有可用的 MCP 服务器",
"loading": "加载中...",
"logout": "退出登录",
"profile": "个人资料",
"changePassword": "修改密码",
"toggleSidebar": "切换侧边栏",
"welcomeUser": "欢迎, {{username}}",
"name": "MCP Hub"
},
"about": {
"title": "关于",
"versionInfo": "MCP Hub 版本: {{version}}",
"newVersion": "有新版本可用!",
"currentVersion": "当前版本",
"newVersionAvailable": "新版本 {{version}} 已发布",
"viewOnGitHub": "在 GitHub 上查看",
"checkForUpdates": "检查更新",
"checking": "检查更新中..."
},
"profile": {
"viewProfile": "查看个人中心",
"userCenter": "个人中心"
},
"sponsor": {
"label": "赞助",
"title": "支持项目",
"rewardAlt": "赞赏码",
"supportMessage": "通过捐赠支持 MCP Hub 的开发!",
"supportButton": "在 Ko-fi 上支持"
},
"wechat": {
"label": "微信",
"title": "微信联系",
"qrCodeAlt": "微信二维码",
"scanMessage": "扫描二维码添加微信"
},
"discord": {
"label": "Discord",
"title": "加入我们的 Discord 服务器",
"community": "加入我们不断壮大的 Discord 社区,获取支持、参与讨论并了解最新动态!"
},
"theme": {
"title": "主题",
"light": "浅色",
"dark": "深色",
"system": "系统"
},
"auth": {
"login": "登录",
"loginTitle": "登录 MCP Hub",
"username": "用户名",
"password": "密码",
"loggingIn": "登录中...",
"emptyFields": "用户名和密码不能为空",
"loginFailed": "登录失败,请检查用户名和密码",
"loginError": "登录过程中出现错误",
"currentPassword": "当前密码",
"newPassword": "新密码",
"confirmPassword": "确认密码",
"passwordsNotMatch": "新密码与确认密码不一致",
"changePasswordSuccess": "密码修改成功",
"changePasswordError": "修改密码失败",
"changePassword": "修改密码",
"passwordChanged": "密码修改成功",
"passwordChangeError": "修改密码失败"
},
"server": {
"addServer": "添加服务器",
"add": "添加",
"edit": "编辑",
"delete": "删除",
"confirmDelete": "您确定要删除此服务器吗?",
"deleteWarning": "删除服务器 '{{name}}' 将会移除该服务器及其所有数据。此操作无法撤销。",
"status": "状态",
"tools": "工具",
"name": "服务器名称",
"url": "服务器 URL",
"apiKey": "API 密钥",
"save": "保存",
"cancel": "取消",
"addError": "添加服务器失败",
"editError": "编辑服务器 {{serverName}} 失败",
"invalidConfig": "无法找到 {{serverName}} 的配置数据",
"deleteError": "删除服务器 {{serverName}} 失败",
"updateError": "更新服务器失败",
"editTitle": "编辑服务器: {{serverName}}",
"type": "服务器类型",
"command": "命令",
"arguments": "参数",
"envVars": "环境变量",
"headers": "HTTP 请求头",
"key": "键",
"value": "值",
"enabled": "已启用",
"enable": "启用",
"disable": "禁用",
"remove": "移除",
"toggleError": "切换服务器 {{serverName}} 状态失败",
"alreadyExists": "服务器 {{serverName}} 已经存在",
"invalidData": "提供的服务器数据无效",
"notFound": "找不到服务器 {{serverName}}",
"namePlaceholder": "请输入服务器名称",
"urlPlaceholder": "请输入服务器URL",
"commandPlaceholder": "请输入命令",
"argumentsPlaceholder": "请输入参数",
"errorDetails": "错误详情",
"viewErrorDetails": "查看错误详情"
},
"status": {
"online": "在线",
"offline": "离线",
"connecting": "连接中"
},
"errors": {
"general": "发生错误",
"network": "网络连接错误,请检查您的互联网连接",
"serverConnection": "无法连接到服务器,请检查服务器是否正在运行",
"serverAdd": "添加服务器失败,请检查服务器状态",
"serverUpdate": "编辑服务器 {{serverName}} 失败,请检查服务器状态",
"serverFetch": "获取服务器数据失败,请稍后重试",
"initialStartup": "服务器可能正在启动中。首次启动可能需要一些时间,请耐心等候...",
"serverInstall": "安装服务器失败",
"failedToFetchSettings": "获取设置失败",
"failedToUpdateSystemConfig": "更新系统配置失败",
"failedToUpdateRouteConfig": "更新路由配置失败",
"failedToUpdateSmartRoutingConfig": "更新智能路由配置失败"
},
"common": {
"processing": "处理中...",
"save": "保存",
"cancel": "取消",
"refresh": "刷新",
"create": "创建",
"submitting": "提交中...",
"delete": "删除",
"copy": "复制",
"copySuccess": "已复制到剪贴板",
"copyFailed": "复制失败",
"close": "关闭"
},
"nav": {
"dashboard": "仪表盘",
"servers": "服务器",
"settings": "设置",
"changePassword": "修改密码",
"groups": "分组",
"market": "市场",
"logs": "日志"
},
"pages": {
"dashboard": {
"title": "仪表盘",
"totalServers": "总数",
"onlineServers": "在线",
"offlineServers": "离线",
"connectingServers": "连接中",
"recentServers": "最近的服务器"
},
"servers": {
"title": "服务器管理"
},
"settings": {
"title": "设置",
"language": "语言",
"account": "账户设置",
"password": "修改密码",
"appearance": "外观",
"routeConfig": "安全配置",
"installConfig": "安装",
"smartRouting": "智能路由"
},
"groups": {
"title": "分组管理"
},
"market": {
"title": "服务器市场 - (数据来源于 mcpm.sh"
},
"logs": {
"title": "系统日志"
}
},
"logs": {
"filters": "筛选",
"search": "搜索日志...",
"autoScroll": "自动滚动",
"clearLogs": "清除日志",
"loading": "加载日志中...",
"noLogs": "暂无日志。",
"noMatch": "没有匹配当前筛选条件的日志。",
"mainProcess": "主进程",
"childProcess": "子进程",
"main": "主",
"child": "子"
},
"groups": {
"add": "添加",
"addNew": "添加新分组",
"edit": "编辑分组",
"delete": "删除",
"confirmDelete": "您确定要删除此分组吗?",
"deleteWarning": "删除分组 '{{name}}' 将会移除该分组及其所有服务器关联。此操作无法撤销。",
"name": "分组名称",
"namePlaceholder": "请输入分组名称",
"nameRequired": "分组名称不能为空",
"description": "描述",
"descriptionPlaceholder": "请输入分组描述(可选)",
"createError": "创建分组失败",
"updateError": "更新分组失败",
"deleteError": "删除分组失败",
"serverAddError": "向分组添加服务器失败",
"serverRemoveError": "从分组移除服务器失败",
"addServer": "添加服务器到分组",
"selectServer": "选择要添加的服务器",
"servers": "分组中的服务器",
"remove": "移除",
"noGroups": "暂无可用分组。创建一个新分组以开始使用。",
"noServers": "此分组中没有服务器。",
"noServerOptions": "没有可用的服务器",
"serverCount": "{{count}} 台服务器"
},
"market": {
"title": "服务器市场",
"official": "官方",
"by": "作者",
"unknown": "未知",
"tools": "工具",
"search": "搜索",
"searchPlaceholder": "搜索服务器名称、分类或标签",
"clearFilters": "清除",
"clearCategoryFilter": "",
"clearTagFilter": "",
"categories": "分类",
"tags": "标签",
"showTags": "显示标签",
"hideTags": "隐藏标签",
"moreTags": "",
"noServers": "未找到匹配的服务器",
"backToList": "返回列表",
"install": "安装",
"installing": "安装中...",
"installed": "已安装",
"installServer": "安装服务器: {{name}}",
"installSuccess": "服务器 {{serverName}} 安装成功",
"author": "作者",
"license": "许可证",
"repository": "代码仓库",
"examples": "示例",
"arguments": "参数",
"argumentName": "名称",
"description": "描述",
"required": "必填",
"example": "示例",
"viewSchema": "查看结构",
"fetchError": "获取服务器市场数据失败",
"serverNotFound": "未找到服务器",
"searchError": "搜索服务器失败",
"filterError": "按分类筛选服务器失败",
"tagFilterError": "按标签筛选服务器失败",
"noInstallationMethod": "该服务器没有可用的安装方法",
"showing": "显示 {{from}}-{{to}}/{{total}} 个服务器",
"perPage": "每页显示"
},
"tool": {
"run": "运行",
"running": "运行中...",
"runTool": "运行工具",
"cancel": "取消",
"noDescription": "无描述信息",
"inputSchema": "输入模式:",
"runToolWithName": "运行工具:{{name}}",
"execution": "工具执行",
"successful": "成功",
"failed": "失败",
"result": "结果:",
"error": "错误",
"errorDetails": "错误详情:",
"noContent": "工具执行成功但未返回内容。",
"unknownError": "发生未知错误",
"jsonResponse": "JSON 响应:",
"toolResult": "工具结果",
"noParameters": "此工具不需要任何参数。",
"selectOption": "选择一个选项",
"enterValue": "输入{{type}}值",
"enabled": "已启用",
"enableSuccess": "工具 {{name}} 启用成功",
"disableSuccess": "工具 {{name}} 禁用成功",
"toggleFailed": "切换工具状态失败"
},
"settings": {
"enableGlobalRoute": "启用全局路由",
"enableGlobalRouteDescription": "允许不指定组 ID 就连接到 /sse 端点",
"enableGroupNameRoute": "启用组名路由",
"enableGroupNameRouteDescription": "允许使用组名而不仅仅是组 ID 连接到 /sse 端点",
"enableBearerAuth": "启用 Bearer 认证",
"enableBearerAuthDescription": "对 MCP 请求启用 Bearer 令牌认证",
"bearerAuthKey": "Bearer 认证密钥",
"bearerAuthKeyDescription": "Bearer 令牌中需要携带的认证密钥",
"bearerAuthKeyPlaceholder": "请输入 Bearer 认证密钥",
"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/",
"installConfig": "安装配置",
"systemConfigUpdated": "系统配置更新成功",
"enableSmartRouting": "启用智能路由",
"enableSmartRoutingDescription": "开启智能路由功能,根据输入自动搜索最合适的工具(使用 $smart 分组)",
"dbUrl": "PostgreSQL 连接地址(必须支持 pgvector",
"dbUrlPlaceholder": "例如: postgresql://user:password@localhost:5432/dbname",
"openaiApiBaseUrl": "OpenAI API 基础地址",
"openaiApiBaseUrlPlaceholder": "https://api.openai.com/v1",
"openaiApiKey": "OpenAI API 密钥",
"openaiApiKeyDescription": "用于访问 OpenAI API 的密钥",
"openaiApiKeyPlaceholder": "请输入 OpenAI API 密钥",
"openaiApiEmbeddingModel": "OpenAI 嵌入模型",
"openaiApiEmbeddingModelPlaceholder": "text-embedding-3-small",
"smartRoutingConfigUpdated": "智能路由配置更新成功",
"smartRoutingRequiredFields": "启用智能路由需要填写数据库连接地址和 OpenAI API 密钥",
"smartRoutingValidationError": "启用智能路由前请先填写必要字段:{{fields}}"
}
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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>
) : (

View File

@@ -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>

View File

@@ -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}

View File

@@ -1,101 +1,158 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import { MarketServer } 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) => {
const handleLocalInstall = async (server: MarketServer, config: ServerConfig) => {
try {
setInstalling(true);
const success = await installServer(server);
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 {
@@ -103,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
@@ -124,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">
@@ -297,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}
@@ -326,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>
@@ -343,7 +509,6 @@ const MarketPage: React.FC = () => {
</div>
<div className="mt-6">
</div>
</>
)}
@@ -353,4 +518,4 @@ const MarketPage: React.FC = () => {
);
};
export default MarketPage;
export default MarketPage;

View File

@@ -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>
);
};

View File

@@ -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')}

View File

@@ -0,0 +1,9 @@
import React from 'react';
const UsersPage: React.FC = () => {
return (
<div></div>
);
};
export default UsersPage;

View File

@@ -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 {

View 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;
}
};

View File

@@ -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}`));

View File

@@ -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);

View File

@@ -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;
@@ -72,7 +93,7 @@ export interface Tool {
// Server config types
export interface ServerConfig {
type?: 'stdio' | 'sse' | 'streamable-http';
type?: 'stdio' | 'sse' | 'streamable-http' | 'openapi';
url?: string;
command?: string;
args?: string[];
@@ -80,6 +101,50 @@ export interface ServerConfig {
headers?: Record<string, string>;
enabled?: boolean;
tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions
options?: {
timeout?: number; // Request timeout in milliseconds
resetTimeoutOnProgress?: boolean; // Reset timeout on progress notifications
maxTotalTimeout?: number; // Maximum total timeout in milliseconds
}; // MCP request options configuration
// OpenAPI specific configuration
openapi?: {
url?: string; // OpenAPI specification URL
schema?: Record<string, any>; // Complete OpenAPI JSON schema
version?: string; // OpenAPI version (default: '3.1.0')
security?: OpenAPISecurityConfig; // Security configuration for API calls
};
}
// OpenAPI Security Configuration
export interface OpenAPISecurityConfig {
type: 'none' | 'apiKey' | 'http' | 'oauth2' | 'openIdConnect';
// API Key authentication
apiKey?: {
name: string; // Header/query/cookie name
in: 'header' | 'query' | 'cookie';
value: string; // The API key value
};
// HTTP authentication (Basic, Bearer, etc.)
http?: {
scheme: 'basic' | 'bearer' | 'digest'; // HTTP auth scheme
bearerFormat?: string; // Bearer token format (e.g., JWT)
credentials?: string; // Base64 encoded credentials for basic auth or bearer token
};
// OAuth2 (simplified - mainly for bearer tokens)
oauth2?: {
tokenUrl?: string; // Token endpoint for client credentials flow
clientId?: string;
clientSecret?: string;
scopes?: string[]; // Required scopes
token?: string; // Pre-obtained access token
};
// OpenID Connect
openIdConnect?: {
url: string; // OpenID Connect discovery URL
clientId?: string;
clientSecret?: string;
token?: string; // Pre-obtained ID token
};
}
// Server types
@@ -93,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
@@ -113,16 +184,46 @@ export interface ServerFormData {
command: string;
arguments: string;
args?: string[]; // Added explicit args field
type?: 'stdio' | 'sse' | 'streamable-http'; // Added type field
type?: 'stdio' | 'sse' | 'streamable-http' | 'openapi'; // Added type field with openapi support
env: EnvVar[];
headers: EnvVar[];
options?: {
timeout?: number;
resetTimeoutOnProgress?: boolean;
maxTotalTimeout?: number;
};
// OpenAPI specific fields
openapi?: {
url?: string;
schema?: string; // JSON schema as string for form input
inputMode?: 'url' | 'schema'; // Mode to determine input type
version?: string;
securityType?: 'none' | 'apiKey' | 'http' | 'oauth2' | 'openIdConnect';
// API Key fields
apiKeyName?: string;
apiKeyIn?: 'header' | 'query' | 'cookie';
apiKeyValue?: string;
// HTTP auth fields
httpScheme?: 'basic' | 'bearer' | 'digest';
httpCredentials?: string;
// OAuth2 fields
oauth2TokenUrl?: string;
oauth2ClientId?: string;
oauth2ClientSecret?: string;
oauth2Token?: string;
// OpenID Connect fields
openIdConnectUrl?: string;
openIdConnectClientId?: string;
openIdConnectClientSecret?: string;
openIdConnectToken?: string;
};
}
// Group form data types
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
@@ -136,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 {

View 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,
});

View 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);
};

View File

@@ -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

View 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();

View 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);
};

View File

@@ -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')],
};

View File

@@ -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
View 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,
};

Some files were not shown because too many files have changed in this diff Show More