mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 10:49:35 -05:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66d4142039 | ||
|
|
cf72295f99 | ||
|
|
89f85c73ff | ||
|
|
adabf1d92b | ||
|
|
c3a6dfadb4 | ||
|
|
d119be0f82 | ||
|
|
1e308ec4c5 | ||
|
|
1bd4fd6d9c | ||
|
|
4b3bb26301 | ||
|
|
40af398f68 | ||
|
|
4726f00a22 | ||
|
|
77f64b7b98 | ||
|
|
d9cbc5381a | ||
|
|
56c6447469 | ||
|
|
f8149c4b0b | ||
|
|
e259f30539 | ||
|
|
3a421bc476 | ||
|
|
503b60edb7 | ||
|
|
4039a85ee1 | ||
|
|
3a83b83a9e | ||
|
|
c1621805de |
16
.coveragerc
Normal file
16
.coveragerc
Normal file
@@ -0,0 +1,16 @@
|
||||
# Test coverage configuration
|
||||
# This file tells Jest what to include/exclude from coverage reports
|
||||
|
||||
# Coverage patterns
|
||||
- "src/**/*.{ts,tsx}"
|
||||
|
||||
# Exclusions
|
||||
- "!src/**/*.d.ts"
|
||||
- "!src/index.ts"
|
||||
- "!src/**/__tests__/**"
|
||||
- "!src/**/*.test.{ts,tsx}"
|
||||
- "!src/**/*.spec.{ts,tsx}"
|
||||
- "!**/node_modules/**"
|
||||
- "!coverage/**"
|
||||
- "!dist/**"
|
||||
- "!build/**"
|
||||
@@ -20,6 +20,6 @@
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"no-undef": "off",
|
||||
"no-undef": "off"
|
||||
}
|
||||
}
|
||||
|
||||
112
.github/workflows/ci.yml
vendored
Normal file
112
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,112 @@
|
||||
name: CI/CD Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run linter
|
||||
run: pnpm lint
|
||||
|
||||
- name: Run type checking
|
||||
run: pnpm backend:build
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test:ci
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
file: ./coverage/lcov.info
|
||||
flags: unittests
|
||||
name: codecov-umbrella
|
||||
|
||||
# build:
|
||||
# runs-on: ubuntu-latest
|
||||
# needs: test
|
||||
|
||||
# steps:
|
||||
# - name: Checkout code
|
||||
# uses: actions/checkout@v4
|
||||
|
||||
# - name: Setup Node.js
|
||||
# uses: actions/setup-node@v4
|
||||
# with:
|
||||
# node-version: '20.x'
|
||||
|
||||
# - name: Enable Corepack
|
||||
# run: corepack enable
|
||||
|
||||
# - name: Install dependencies
|
||||
# run: pnpm install --frozen-lockfile
|
||||
|
||||
# - name: Build application
|
||||
# run: pnpm build
|
||||
|
||||
# - name: Verify build artifacts
|
||||
# run: node scripts/verify-dist.js
|
||||
|
||||
# integration-test:
|
||||
# runs-on: ubuntu-latest
|
||||
# needs: test
|
||||
|
||||
# services:
|
||||
# postgres:
|
||||
# image: postgres:15
|
||||
# env:
|
||||
# POSTGRES_PASSWORD: postgres
|
||||
# POSTGRES_DB: mcphub_test
|
||||
# options: >-
|
||||
# --health-cmd pg_isready
|
||||
# --health-interval 10s
|
||||
# --health-timeout 5s
|
||||
# --health-retries 5
|
||||
|
||||
# steps:
|
||||
# - name: Checkout code
|
||||
# uses: actions/checkout@v4
|
||||
|
||||
# - name: Setup Node.js
|
||||
# uses: actions/setup-node@v4
|
||||
# with:
|
||||
# node-version: '20.x'
|
||||
|
||||
# - name: Enable Corepack
|
||||
# run: corepack enable
|
||||
|
||||
# - name: Install dependencies
|
||||
# run: pnpm install --frozen-lockfile
|
||||
|
||||
# - name: Build application
|
||||
# run: pnpm build
|
||||
|
||||
# - name: Run integration tests
|
||||
# run: |
|
||||
# export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/mcphub_test"
|
||||
# node test-integration.ts
|
||||
# env:
|
||||
# NODE_ENV: test
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -24,3 +24,5 @@ yarn-error.log*
|
||||
.vscode/
|
||||
*.log
|
||||
coverage/
|
||||
|
||||
data/
|
||||
@@ -57,7 +57,7 @@ Create a `mcp_settings.json` file to customize your server settings:
|
||||
**Recommended**: Mount your custom config:
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 -v $(pwd)/mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
|
||||
docker run -p 3000:3000 -v ./mcp_settings.json:/app/mcp_settings.json -v ./data:/app/data samanhappy/mcphub
|
||||
```
|
||||
|
||||
or run with default settings:
|
||||
|
||||
@@ -57,7 +57,7 @@ MCPHub 通过将多个 MCP(Model Context Protocol)服务器组织为灵活
|
||||
**推荐**:挂载自定义配置:
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 -v $(pwd)/mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
|
||||
docker run -p 3000:3000 -v ./mcp_settings.json:/app/mcp_settings.json -v ./data:/app/data samanhappy/mcphub
|
||||
```
|
||||
|
||||
或使用默认配置运行:
|
||||
|
||||
149
docs/openapi-schema-support.md
Normal file
149
docs/openapi-schema-support.md
Normal 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
172
docs/openapi-support.md
Normal 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
|
||||
172
docs/testing-framework.md
Normal file
172
docs/testing-framework.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# 测试框架和自动化测试实现报告
|
||||
|
||||
## 概述
|
||||
|
||||
本项目已成功引入现代化的测试框架和自动化测试流程。实现了基于Jest的测试环境,支持TypeScript、ES模块,并包含完整的CI/CD配置。
|
||||
|
||||
## 已实现的功能
|
||||
|
||||
### 1. 测试框架配置
|
||||
|
||||
- **Jest配置**: 使用`jest.config.cjs`配置文件,支持ES模块和TypeScript
|
||||
- **覆盖率报告**: 配置了代码覆盖率收集和报告
|
||||
- **测试环境**: 支持Node.js环境的单元测试和集成测试
|
||||
- **模块映射**: 配置了路径别名支持
|
||||
|
||||
### 2. 测试工具和辅助函数
|
||||
|
||||
创建了完善的测试工具库 (`tests/utils/testHelpers.ts`):
|
||||
|
||||
- **认证工具**: JWT token生成和管理
|
||||
- **HTTP测试**: Supertest集成用于API测试
|
||||
- **数据生成**: 测试数据工厂函数
|
||||
- **响应断言**: 自定义API响应验证器
|
||||
- **环境管理**: 测试环境变量配置
|
||||
|
||||
### 3. 测试用例实现
|
||||
|
||||
已实现的测试场景:
|
||||
|
||||
#### 基础配置测试 (`tests/basic.test.ts`)
|
||||
- Jest配置验证
|
||||
- 异步操作支持测试
|
||||
- 自定义匹配器验证
|
||||
|
||||
#### 认证逻辑测试 (`tests/auth.logic.test.ts`)
|
||||
- 用户登录逻辑
|
||||
- 密码验证
|
||||
- JWT生成和验证
|
||||
- 错误处理场景
|
||||
- 用户数据验证
|
||||
|
||||
#### 路径工具测试 (`tests/utils/pathLogic.test.ts`)
|
||||
- 配置文件路径解析
|
||||
- 环境变量处理
|
||||
- 文件系统操作
|
||||
- 错误处理和边界条件
|
||||
- 跨平台路径处理
|
||||
|
||||
### 4. CI/CD配置
|
||||
|
||||
GitHub Actions配置 (`.github/workflows/ci.yml`):
|
||||
|
||||
- **多Node.js版本支持**: 18.x和20.x
|
||||
- **自动化测试流程**:
|
||||
- 代码检查 (ESLint)
|
||||
- 类型检查 (TypeScript)
|
||||
- 单元测试执行
|
||||
- 覆盖率报告
|
||||
- **构建验证**: 应用构建和产物验证
|
||||
- **集成测试**: 包含数据库环境的集成测试
|
||||
|
||||
### 5. 测试脚本
|
||||
|
||||
在`package.json`中添加的测试命令:
|
||||
|
||||
```json
|
||||
{
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"test:verbose": "jest --verbose",
|
||||
"test:ci": "jest --ci --coverage --watchAll=false"
|
||||
}
|
||||
```
|
||||
|
||||
## 测试结果
|
||||
|
||||
当前测试统计:
|
||||
- **测试套件**: 3个
|
||||
- **测试用例**: 19个
|
||||
- **通过率**: 100%
|
||||
- **执行时间**: ~15秒
|
||||
|
||||
### 测试覆盖的功能模块
|
||||
|
||||
1. **认证系统**: 用户登录、JWT处理、密码验证
|
||||
2. **配置管理**: 文件路径解析、环境变量处理
|
||||
3. **基础设施**: Jest配置、测试工具验证
|
||||
|
||||
## 技术特点
|
||||
|
||||
### 现代化特性
|
||||
|
||||
- **ES模块支持**: 完全支持ES2022模块语法
|
||||
- **TypeScript集成**: 类型安全的测试编写
|
||||
- **异步测试**: Promise和async/await支持
|
||||
- **模拟系统**: Jest mock功能的深度使用
|
||||
- **参数化测试**: 数据驱动的测试用例
|
||||
|
||||
### 最佳实践
|
||||
|
||||
- **测试隔离**: 每个测试用例独立运行
|
||||
- **Mock管理**: 统一的mock清理和重置
|
||||
- **错误处理**: 完整的错误场景测试
|
||||
- **边界测试**: 输入验证和边界条件覆盖
|
||||
- **文档化**: 清晰的测试用例命名和描述
|
||||
|
||||
## 后续扩展计划
|
||||
|
||||
### 短期目标
|
||||
|
||||
1. **API测试**: 为REST API端点添加集成测试
|
||||
2. **数据库测试**: 添加数据模型和存储层测试
|
||||
3. **中间件测试**: 认证和权限中间件测试
|
||||
4. **服务层测试**: 核心业务逻辑测试
|
||||
|
||||
### 中期目标
|
||||
|
||||
1. **端到端测试**: 使用Playwright或Cypress
|
||||
2. **性能测试**: API响应时间和负载测试
|
||||
3. **安全测试**: 输入验证和安全漏洞测试
|
||||
4. **契约测试**: API契约验证
|
||||
|
||||
### 长期目标
|
||||
|
||||
1. **测试数据管理**: 测试数据库和fixture管理
|
||||
2. **视觉回归测试**: UI组件的视觉测试
|
||||
3. **监控集成**: 生产环境测试监控
|
||||
4. **自动化测试报告**: 详细的测试报告和趋势分析
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 添加新测试用例
|
||||
|
||||
1. 在`tests/`目录下创建对应的测试文件
|
||||
2. 使用`testHelpers.ts`中的工具函数
|
||||
3. 遵循命名约定: `*.test.ts`或`*.spec.ts`
|
||||
4. 确保测试用例具有清晰的描述和断言
|
||||
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
# 运行所有测试
|
||||
pnpm test
|
||||
|
||||
# 监听模式
|
||||
pnpm test:watch
|
||||
|
||||
# 生成覆盖率报告
|
||||
pnpm test:coverage
|
||||
|
||||
# CI模式运行
|
||||
pnpm test:ci
|
||||
```
|
||||
|
||||
### Mock最佳实践
|
||||
|
||||
- 在`beforeEach`中清理所有mock
|
||||
- 使用具体的mock实现而不是空函数
|
||||
- 验证mock被正确调用
|
||||
- 保持mock的一致性和可维护性
|
||||
|
||||
## 结论
|
||||
|
||||
本项目已成功建立了完整的现代化测试框架,具备以下优势:
|
||||
|
||||
1. **高度可扩展**: 易于添加新的测试用例和测试类型
|
||||
2. **开发友好**: 丰富的工具函数和清晰的结构
|
||||
3. **CI/CD就绪**: 完整的自动化流水线配置
|
||||
4. **质量保证**: 代码覆盖率和持续测试验证
|
||||
|
||||
这个测试框架为项目的持续发展和质量保证提供了坚实的基础,支持敏捷开发和持续集成的最佳实践。
|
||||
256
examples/openapi-schema-config.json
Normal file
256
examples/openapi-schema-config.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -50,7 +50,7 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
|
||||
}
|
||||
|
||||
const result = await createGroup(formData.name, formData.description, formData.servers)
|
||||
|
||||
|
||||
if (!result) {
|
||||
setError(t('groups.createError'))
|
||||
setIsSubmitting(false)
|
||||
@@ -69,7 +69,7 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
|
||||
<div className="p-6">
|
||||
<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">
|
||||
{error}
|
||||
@@ -87,7 +87,7 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
|
||||
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"
|
||||
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={t('groups.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
@@ -109,14 +109,14 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
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"
|
||||
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"
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 btn-primary"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? t('common.submitting') : t('common.create')}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ServerForm from './ServerForm'
|
||||
import { getApiUrl } from '../utils/runtime';
|
||||
import { getApiUrl } from '../utils/runtime'
|
||||
import { detectVariables } from '../utils/variableDetection'
|
||||
|
||||
interface AddServerFormProps {
|
||||
onAdd: () => void
|
||||
@@ -11,13 +12,26 @@ 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');
|
||||
@@ -65,11 +79,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 +121,60 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmationVisible && (
|
||||
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
{t('server.confirmVariables')}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{t('server.variablesDetected')}
|
||||
</p>
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 mb-4">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h4 className="text-sm font-medium text-yellow-800">
|
||||
{t('server.detectedVariables')}:
|
||||
</h4>
|
||||
<ul className="mt-1 text-sm text-yellow-700">
|
||||
{detectedVariables.map((variable, index) => (
|
||||
<li key={index} className="font-mono">
|
||||
${`{${variable}}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm mb-6">
|
||||
{t('server.confirmVariablesMessage')}
|
||||
</p>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setConfirmationVisible(false)
|
||||
setPendingPayload(null)
|
||||
}}
|
||||
className="px-4 py-2 text-gray-600 border border-gray-300 rounded hover:bg-gray-50 btn-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmSubmit}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 btn-primary"
|
||||
>
|
||||
{t('server.confirmAndAdd')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,17 +31,17 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
|
||||
// Validate passwords match
|
||||
if (formData.newPassword !== confirmPassword) {
|
||||
setError(t('auth.passwordsNotMatch'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await changePassword(formData);
|
||||
|
||||
|
||||
if (response.success) {
|
||||
setSuccess(true);
|
||||
if (onSuccess) {
|
||||
@@ -60,7 +60,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
return (
|
||||
<div className="p-6 bg-white rounded-lg shadow-md">
|
||||
<h2 className="text-xl font-bold mb-4">{t('auth.changePassword')}</h2>
|
||||
|
||||
|
||||
{success ? (
|
||||
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
|
||||
{t('auth.changePasswordSuccess')}
|
||||
@@ -72,7 +72,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="currentPassword">
|
||||
{t('auth.currentPassword')}
|
||||
@@ -81,13 +81,13 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
type="password"
|
||||
id="currentPassword"
|
||||
name="currentPassword"
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
|
||||
value={formData.currentPassword}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="newPassword">
|
||||
{t('auth.newPassword')}
|
||||
@@ -96,14 +96,14 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
type="password"
|
||||
id="newPassword"
|
||||
name="newPassword"
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
|
||||
value={formData.newPassword}
|
||||
onChange={handleChange}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="confirmPassword">
|
||||
{t('auth.confirmPassword')}
|
||||
@@ -112,14 +112,14 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
|
||||
value={confirmPassword}
|
||||
onChange={handleChange}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
{onCancel && (
|
||||
<button
|
||||
@@ -134,7 +134,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
className="py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 btn-primary"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center">
|
||||
|
||||
413
frontend/src/components/DxtUploadForm.tsx
Normal file
413
frontend/src/components/DxtUploadForm.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/dxt/upload'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
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);
|
||||
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
|
||||
// First, check if server exists
|
||||
if (!forceOverride) {
|
||||
const checkResponse = await fetch(getApiUrl('/servers'), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (checkResponse.ok) {
|
||||
const checkResult = await checkResponse.json();
|
||||
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
|
||||
const method = forceOverride ? 'PUT' : 'POST';
|
||||
const url = forceOverride ? getApiUrl(`/servers/${encodeURIComponent(serverName)}`) : getApiUrl('/servers');
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: serverName,
|
||||
config: serverConfig,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || `HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -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,7 +55,7 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
|
||||
description: formData.description,
|
||||
servers: formData.servers
|
||||
})
|
||||
|
||||
|
||||
if (!result) {
|
||||
setError(t('groups.updateError'))
|
||||
setIsSubmitting(false)
|
||||
@@ -86,7 +74,7 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
|
||||
<div className="p-6">
|
||||
<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">
|
||||
{error}
|
||||
@@ -104,7 +92,7 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
|
||||
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"
|
||||
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={t('groups.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
@@ -126,14 +114,14 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
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"
|
||||
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"
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 btn-primary"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? t('common.submitting') : t('common.save')}
|
||||
|
||||
@@ -68,7 +68,7 @@ const GroupCard = ({
|
||||
const groupServers = servers.filter(server => group.servers.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">
|
||||
@@ -89,7 +89,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
|
||||
@@ -121,7 +121,7 @@ const GroupCard = ({
|
||||
>
|
||||
<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'
|
||||
server.status === 'connecting' ? 'bg-yellow-500' : 'bg-red-500'
|
||||
}`}></span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -48,25 +48,26 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
|
||||
// Get badge color based on log type
|
||||
const getLogTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'error': return 'bg-red-400';
|
||||
case 'warn': return 'bg-yellow-400';
|
||||
case 'debug': return 'bg-purple-400';
|
||||
default: return 'bg-blue-400';
|
||||
case 'error': return 'bg-red-400/80 text-white';
|
||||
case 'warn': return 'bg-yellow-400/80 text-gray-900';
|
||||
case 'debug': return 'bg-purple-400/80 text-white';
|
||||
case 'info': return 'bg-blue-400/80 text-white';
|
||||
default: return 'bg-blue-400/80 text-white';
|
||||
}
|
||||
};
|
||||
|
||||
// Get badge color based on log source
|
||||
const getSourceColor = (source: string) => {
|
||||
switch (source) {
|
||||
case 'main': return 'bg-green-400';
|
||||
case 'child': return 'bg-orange-400';
|
||||
default: return 'bg-gray-400';
|
||||
case 'main': return 'bg-green-400/80 text-white';
|
||||
case 'child': return 'bg-orange-400/80 text-white';
|
||||
default: return 'bg-gray-400/80 text-white';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="bg-card p-3 rounded-t-md border-b flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="bg-card p-3 rounded-t-md flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-sm">{t('logs.filters')}:</span>
|
||||
|
||||
@@ -74,14 +75,14 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('logs.search')}
|
||||
className="px-2 py-1 text-sm border rounded"
|
||||
className="shadow appearance-none border border-gray-200 rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Log type filters */}
|
||||
<div className="flex gap-1 items-center">
|
||||
{(['info', 'error', 'warn', 'debug'] as const).map(type => (
|
||||
{(['debug', 'info', 'error', 'warn'] as const).map(type => (
|
||||
<Badge
|
||||
key={type}
|
||||
variant={typeFilter.includes(type) ? 'default' : 'outline'}
|
||||
@@ -134,6 +135,7 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClear}
|
||||
className='btn-secondary'
|
||||
disabled={isLoading || logs.length === 0}
|
||||
>
|
||||
{t('logs.clearLogs')}
|
||||
@@ -164,7 +166,7 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
|
||||
filteredLogs.map((log, index) => (
|
||||
<div
|
||||
key={`${log.timestamp}-${index}`}
|
||||
className={`py-1 border-b border-gray-100 dark:border-gray-800 ${log.type === 'error' ? 'text-red-500' :
|
||||
className={`py-1 ${log.type === 'error' ? 'text-red-500' :
|
||||
log.type === 'warn' ? 'text-yellow-500' : ''
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -15,31 +15,31 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
|
||||
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 +49,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 +67,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,27 +79,27 @@ 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 rounded-lg shadow-md p-5 hover:shadow-lg transition-all duration-200 cursor-pointer flex flex-col h-full page-card"
|
||||
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">
|
||||
<span className="text-xs font-medium px-2.5 py-0.5 rounded flex-shrink-0 label-primary">
|
||||
{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
|
||||
<span
|
||||
key={index}
|
||||
className="bg-gray-100 text-gray-800 text-xs px-2 py-1 rounded whitespace-nowrap"
|
||||
className="bg-gray-100 text-gray-800 text-xs px-2 py-1.5 rounded whitespace-nowrap"
|
||||
>
|
||||
{category}
|
||||
</span>
|
||||
@@ -108,15 +108,15 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
|
||||
<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
|
||||
<span
|
||||
key={index}
|
||||
className="bg-green-50 text-green-700 text-xs px-2 py-1 rounded flex-shrink-0"
|
||||
className="bg-green-50 text-green-700 text-xs px-2 py-1 rounded flex-shrink-0 label-secondary"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
@@ -131,8 +131,8 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
|
||||
<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="flex justify-between items-center mt-auto pt-2 text-xs text-gray-500">
|
||||
<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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />;
|
||||
};
|
||||
|
||||
|
||||
@@ -11,10 +11,11 @@ interface ServerCardProps {
|
||||
server: Server
|
||||
onRemove: (serverName: string) => void
|
||||
onEdit: (server: Server) => void
|
||||
onToggle?: (server: Server, enabled: boolean) => void
|
||||
onToggle?: (server: Server, enabled: boolean) => Promise<boolean>
|
||||
onRefresh?: () => void
|
||||
}
|
||||
|
||||
const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) => {
|
||||
const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { showToast } = useToast()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
@@ -102,9 +103,32 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
|
||||
setShowDeleteDialog(false)
|
||||
}
|
||||
|
||||
const handleToolToggle = async (toolName: string, enabled: boolean) => {
|
||||
try {
|
||||
const { toggleTool } = await import('@/services/toolService')
|
||||
const result = await toggleTool(server.name, toolName, enabled)
|
||||
|
||||
if (result.success) {
|
||||
showToast(
|
||||
t(enabled ? 'tool.enableSuccess' : 'tool.disableSuccess', { name: toolName }),
|
||||
'success'
|
||||
)
|
||||
// Trigger refresh to update the tool's state in the UI
|
||||
if (onRefresh) {
|
||||
onRefresh()
|
||||
}
|
||||
} else {
|
||||
showToast(result.error || t('tool.toggleFailed'), 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling tool:', error)
|
||||
showToast(t('tool.toggleFailed'), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
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)}
|
||||
@@ -114,7 +138,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
|
||||
<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>
|
||||
@@ -150,7 +174,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
|
||||
<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} />}
|
||||
@@ -177,7 +201,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
|
||||
<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>
|
||||
@@ -187,8 +211,8 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
|
||||
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}
|
||||
>
|
||||
@@ -202,11 +226,11 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
|
||||
</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>
|
||||
@@ -217,7 +241,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
|
||||
<h6 className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}>{t('server.tools')}</h6>
|
||||
<div className="space-y-4">
|
||||
{server.tools.map((tool, index) => (
|
||||
<ToolCard key={index} server={server.name} tool={tool} />
|
||||
<ToolCard key={index} server={server.name} tool={tool} onToggle={handleToolToggle} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 btn-primary"
|
||||
>
|
||||
+ {t('server.add')}
|
||||
</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-[56px] ml-2 btn-danger"
|
||||
>
|
||||
- {t('server.remove')}
|
||||
</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,7 +685,7 @@ 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 btn-primary"
|
||||
>
|
||||
+ {t('server.add')}
|
||||
</button>
|
||||
@@ -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,14 +705,14 @@ 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-[56px] ml-2 btn-danger"
|
||||
>
|
||||
- {t('server.remove')}
|
||||
</button>
|
||||
@@ -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,7 +761,7 @@ 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 btn-primary"
|
||||
>
|
||||
+ {t('server.add')}
|
||||
</button>
|
||||
@@ -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,14 +781,14 @@ 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-[56px] ml-2 btn-danger"
|
||||
>
|
||||
- {t('server.remove')}
|
||||
</button>
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import ThemeSwitch from '@/components/ui/ThemeSwitch';
|
||||
import GitHubIcon from '@/components/icons/GitHubIcon';
|
||||
import SponsorIcon from '@/components/icons/SponsorIcon';
|
||||
@@ -15,13 +14,12 @@ interface HeaderProps {
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { auth } = useAuth();
|
||||
const [sponsorDialogOpen, setSponsorDialogOpen] = useState(false);
|
||||
const [wechatDialogOpen, setWechatDialogOpen] = useState(false);
|
||||
|
||||
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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import UserProfileMenu from '@/components/ui/UserProfileMenu';
|
||||
|
||||
interface SidebarProps {
|
||||
@@ -15,11 +15,10 @@ interface MenuItem {
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
|
||||
// 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[] = [
|
||||
{
|
||||
@@ -71,10 +70,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 +81,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 +95,7 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
|
||||
{/* User profile menu fixed at the bottom */}
|
||||
<div className="p-3 bg-white dark:bg-gray-800">
|
||||
<UserProfileMenu collapsed={collapsed} version={appVersion} />
|
||||
|
||||
@@ -93,7 +93,7 @@ const AboutDialog: React.FC<AboutDialogProps> = ({ isOpen, onClose, version }) =
|
||||
<button
|
||||
onClick={checkForUpdates}
|
||||
disabled={isChecking}
|
||||
className={`mt-4 inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium
|
||||
className={`mt-4 inline-flex items-center px-4 py-2 border border-gray-200 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium btn-secondary
|
||||
${isChecking
|
||||
? 'text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-800'
|
||||
: 'text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ServerStatus } from '@/types';
|
||||
import { cn } from '../../utils/cn';
|
||||
|
||||
type BadgeVariant = 'default' | 'secondary' | 'outline' | 'destructive';
|
||||
@@ -19,11 +18,11 @@ const badgeVariants = {
|
||||
destructive: 'bg-red-500 text-white hover:bg-red-600',
|
||||
};
|
||||
|
||||
export function Badge({
|
||||
children,
|
||||
variant = 'default',
|
||||
className,
|
||||
onClick
|
||||
export function Badge({
|
||||
children,
|
||||
variant = 'default',
|
||||
className,
|
||||
onClick
|
||||
}: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
@@ -43,11 +42,11 @@ export function Badge({
|
||||
// For backward compatibility with existing code
|
||||
export const StatusBadge = ({ status }: { status: 'connected' | 'disconnected' | 'connecting' }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
const colors = {
|
||||
connecting: 'bg-yellow-100 text-yellow-800',
|
||||
connected: 'bg-green-100 text-green-800',
|
||||
disconnected: 'bg-red-100 text-red-800',
|
||||
connecting: 'status-badge-connecting',
|
||||
connected: 'status-badge-online',
|
||||
disconnected: 'status-badge-offline',
|
||||
};
|
||||
|
||||
// Map status to translation keys
|
||||
|
||||
142
frontend/src/components/ui/ConfirmDialog.tsx
Normal file
142
frontend/src/components/ui/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title?: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: 'danger' | 'warning' | 'info';
|
||||
}
|
||||
|
||||
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText,
|
||||
cancelText,
|
||||
variant = 'warning'
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getVariantStyles = () => {
|
||||
switch (variant) {
|
||||
case 'danger':
|
||||
return {
|
||||
icon: (
|
||||
<svg className="w-6 h-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
),
|
||||
confirmClass: 'bg-red-600 hover:bg-red-700 text-white',
|
||||
};
|
||||
case 'warning':
|
||||
return {
|
||||
icon: (
|
||||
<svg className="w-6 h-6 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
),
|
||||
confirmClass: 'bg-yellow-600 hover:bg-yellow-700 text-white',
|
||||
};
|
||||
case 'info':
|
||||
return {
|
||||
icon: (
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
confirmClass: 'bg-blue-600 hover:bg-blue-700 text-white',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: null,
|
||||
confirmClass: 'bg-blue-600 hover:bg-blue-700 text-white',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const { icon, confirmClass } = getVariantStyles();
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
} else if (e.key === 'Enter') {
|
||||
onConfirm();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-[100] flex items-center justify-center p-4"
|
||||
onClick={handleBackdropClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-xl max-w-md w-full transform transition-all duration-200 ease-out"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
aria-describedby="confirm-dialog-message"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
{icon && (
|
||||
<div className="flex-shrink-0">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
{title && (
|
||||
<h3
|
||||
id="confirm-dialog-title"
|
||||
className="text-lg font-medium text-gray-900 mb-2"
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
<p
|
||||
id="confirm-dialog-message"
|
||||
className="text-gray-600 leading-relaxed"
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-md transition-colors duration-150 btn-secondary"
|
||||
autoFocus
|
||||
>
|
||||
{cancelText || t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`px-4 py-2 rounded-md transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 ${confirmClass} ${variant === 'danger' ? 'btn-danger' : variant === 'warning' ? 'btn-warning' : 'btn-primary'}`}
|
||||
>
|
||||
{confirmText || t('common.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmDialog;
|
||||
@@ -28,13 +28,13 @@ const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName, isGroup = false
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800 btn-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
||||
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 btn-danger"
|
||||
>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
|
||||
@@ -18,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>
|
||||
@@ -257,9 +547,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -6,34 +6,33 @@ interface PaginationProps {
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
const Pagination: React.FC<PaginationProps> = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange
|
||||
const Pagination: React.FC<PaginationProps> = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange
|
||||
}) => {
|
||||
// Generate page buttons
|
||||
const getPageButtons = () => {
|
||||
const buttons = [];
|
||||
const maxDisplayedPages = 5; // Maximum number of page buttons to display
|
||||
|
||||
|
||||
// Always display first page
|
||||
buttons.push(
|
||||
<button
|
||||
key="first"
|
||||
onClick={() => onPageChange(1)}
|
||||
className={`px-3 py-1 mx-1 rounded ${
|
||||
currentPage === 1
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
|
||||
}`}
|
||||
className={`px-3 py-1 mx-1 rounded ${currentPage === 1
|
||||
? 'bg-blue-500 text-white btn-primary'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
|
||||
}`}
|
||||
>
|
||||
1
|
||||
</button>
|
||||
);
|
||||
|
||||
|
||||
// Start range
|
||||
let startPage = Math.max(2, currentPage - Math.floor(maxDisplayedPages / 2));
|
||||
|
||||
const startPage = Math.max(2, currentPage - Math.floor(maxDisplayedPages / 2));
|
||||
|
||||
// If we're showing ellipsis after first page
|
||||
if (startPage > 2) {
|
||||
buttons.push(
|
||||
@@ -42,24 +41,23 @@ const Pagination: React.FC<PaginationProps> = ({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Middle pages
|
||||
for (let i = startPage; i <= Math.min(totalPages - 1, startPage + maxDisplayedPages - 3); i++) {
|
||||
buttons.push(
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onPageChange(i)}
|
||||
className={`px-3 py-1 mx-1 rounded ${
|
||||
currentPage === i
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
|
||||
}`}
|
||||
className={`px-3 py-1 mx-1 rounded ${currentPage === i
|
||||
? 'bg-blue-500 text-white btn-primary'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
|
||||
}`}
|
||||
>
|
||||
{i}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// If we're showing ellipsis before last page
|
||||
if (startPage + maxDisplayedPages - 3 < totalPages - 1) {
|
||||
buttons.push(
|
||||
@@ -68,24 +66,23 @@ const Pagination: React.FC<PaginationProps> = ({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Always display last page if there's more than one page
|
||||
if (totalPages > 1) {
|
||||
buttons.push(
|
||||
<button
|
||||
key="last"
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
className={`px-3 py-1 mx-1 rounded ${
|
||||
currentPage === totalPages
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
|
||||
}`}
|
||||
className={`px-3 py-1 mx-1 rounded ${currentPage === totalPages
|
||||
? 'bg-blue-500 text-white btn-primary'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
|
||||
}`}
|
||||
>
|
||||
{totalPages}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return buttons;
|
||||
};
|
||||
|
||||
@@ -99,25 +96,23 @@ const Pagination: React.FC<PaginationProps> = ({
|
||||
<button
|
||||
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className={`px-3 py-1 rounded mr-2 ${
|
||||
currentPage === 1
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
|
||||
}`}
|
||||
className={`px-3 py-1 rounded mr-2 ${currentPage === 1
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
|
||||
}`}
|
||||
>
|
||||
« Prev
|
||||
</button>
|
||||
|
||||
|
||||
<div className="flex">{getPageButtons()}</div>
|
||||
|
||||
|
||||
<button
|
||||
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className={`px-3 py-1 rounded ml-2 ${
|
||||
currentPage === totalPages
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
|
||||
}`}
|
||||
className={`px-3 py-1 rounded ml-2 ${currentPage === totalPages
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
|
||||
}`}
|
||||
>
|
||||
Next »
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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();
|
||||
@@ -9,7 +9,7 @@ const ThemeSwitch: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex bg-gray-200 dark:bg-gray-700 rounded-lg p-1">
|
||||
<div className="flex bg-gray-100 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'
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -1,22 +1,48 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Tool } from '@/types'
|
||||
import { ChevronDown, ChevronRight, Play, Loader } from '@/components/icons/LucideIcons'
|
||||
import { callTool, ToolCallResult } from '@/services/toolService'
|
||||
import { ChevronDown, ChevronRight, Play, Loader, Edit, Check } from '@/components/icons/LucideIcons'
|
||||
import { callTool, ToolCallResult, updateToolDescription } from '@/services/toolService'
|
||||
import { Switch } from './ToggleGroup'
|
||||
import DynamicForm from './DynamicForm'
|
||||
import ToolResult from './ToolResult'
|
||||
|
||||
interface ToolCardProps {
|
||||
server: string
|
||||
tool: Tool
|
||||
onToggle?: (toolName: string, enabled: boolean) => void
|
||||
onDescriptionUpdate?: (toolName: string, description: string) => void
|
||||
}
|
||||
|
||||
const ToolCard = ({ tool, server }: ToolCardProps) => {
|
||||
const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [showRunForm, setShowRunForm] = useState(false)
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [result, setResult] = useState<ToolCallResult | null>(null)
|
||||
const [isEditingDescription, setIsEditingDescription] = useState(false)
|
||||
const [customDescription, setCustomDescription] = useState(tool.description || '')
|
||||
const descriptionInputRef = useRef<HTMLInputElement>(null)
|
||||
const descriptionTextRef = useRef<HTMLSpanElement>(null)
|
||||
const [textWidth, setTextWidth] = useState<number>(0)
|
||||
|
||||
// Focus the input when editing mode is activated
|
||||
useEffect(() => {
|
||||
if (isEditingDescription && descriptionInputRef.current) {
|
||||
descriptionInputRef.current.focus()
|
||||
// Set input width to match text width
|
||||
if (textWidth > 0) {
|
||||
descriptionInputRef.current.style.width = `${textWidth + 20}px` // Add some padding
|
||||
}
|
||||
}
|
||||
}, [isEditingDescription, textWidth])
|
||||
|
||||
// Measure text width when not editing
|
||||
useEffect(() => {
|
||||
if (!isEditingDescription && descriptionTextRef.current) {
|
||||
setTextWidth(descriptionTextRef.current.offsetWidth)
|
||||
}
|
||||
}, [isEditingDescription, customDescription])
|
||||
|
||||
// Generate a unique key for localStorage based on tool name and server
|
||||
const getStorageKey = useCallback(() => {
|
||||
@@ -28,6 +54,49 @@ const ToolCard = ({ tool, server }: ToolCardProps) => {
|
||||
localStorage.removeItem(getStorageKey())
|
||||
}, [getStorageKey])
|
||||
|
||||
const handleToggle = (enabled: boolean) => {
|
||||
if (onToggle) {
|
||||
onToggle(tool.name, enabled)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDescriptionEdit = () => {
|
||||
setIsEditingDescription(true)
|
||||
}
|
||||
|
||||
const handleDescriptionSave = async () => {
|
||||
try {
|
||||
const result = await updateToolDescription(server, tool.name, customDescription)
|
||||
if (result.success) {
|
||||
setIsEditingDescription(false)
|
||||
if (onDescriptionUpdate) {
|
||||
onDescriptionUpdate(tool.name, customDescription)
|
||||
}
|
||||
} else {
|
||||
// Revert on error
|
||||
setCustomDescription(tool.description || '')
|
||||
console.error('Failed to update tool description:', result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating tool description:', error)
|
||||
setCustomDescription(tool.description || '')
|
||||
setIsEditingDescription(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDescriptionChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setCustomDescription(e.target.value)
|
||||
}
|
||||
|
||||
const handleDescriptionKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleDescriptionSave()
|
||||
} else if (e.key === 'Escape') {
|
||||
setCustomDescription(tool.description || '')
|
||||
setIsEditingDescription(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRunTool = async (arguments_: Record<string, any>) => {
|
||||
setIsRunning(true)
|
||||
try {
|
||||
@@ -61,28 +130,76 @@ const ToolCard = ({ tool, server }: 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)}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
{tool.name}
|
||||
<span className="ml-2 text-sm font-normal text-gray-600">
|
||||
{tool.description || t('tool.noDescription')}
|
||||
{tool.name.replace(server + '-', '')}
|
||||
<span className="ml-2 text-sm font-normal text-gray-600 inline-flex items-center">
|
||||
{isEditingDescription ? (
|
||||
<>
|
||||
<input
|
||||
ref={descriptionInputRef}
|
||||
type="text"
|
||||
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}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
minWidth: '100px',
|
||||
width: textWidth > 0 ? `${textWidth + 20}px` : 'auto'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="ml-2 p-1 text-green-600 hover:text-green-800 cursor-pointer transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDescriptionSave()
|
||||
}}
|
||||
>
|
||||
<Check size={16} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span ref={descriptionTextRef}>{customDescription || t('tool.noDescription')}</span>
|
||||
<button
|
||||
className="ml-2 p-1 text-gray-500 hover:text-blue-600 cursor-pointer transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDescriptionEdit()
|
||||
}}
|
||||
>
|
||||
<Edit size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="flex items-center space-x-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Switch
|
||||
checked={tool.enabled ?? true}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
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"
|
||||
disabled={isRunning}
|
||||
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 ? (
|
||||
<Loader size={14} className="animate-spin" />
|
||||
@@ -111,14 +228,14 @@ const ToolCard = ({ tool, server }: 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 })}</h4>
|
||||
<div className="border border-gray-300 rounded-lg p-4">
|
||||
<DynamicForm
|
||||
schema={tool.inputSchema || { type: 'object' }}
|
||||
onSubmit={handleRunTool}
|
||||
onCancel={handleCancelRun}
|
||||
loading={isRunning}
|
||||
storageKey={getStorageKey()}
|
||||
title={t('tool.runToolWithName', { name: tool.name.replace(server + '-', '') })}
|
||||
/>
|
||||
{/* Tool Result */}
|
||||
{result && (
|
||||
|
||||
@@ -65,7 +65,6 @@ const ToolResult: React.FC<ToolResultProps> = ({ result, onClose }) => {
|
||||
|
||||
// For other structured content, try to parse as JSON
|
||||
try {
|
||||
const jsonString = typeof item === 'string' ? item : JSON.stringify(item, null, 2);
|
||||
const parsed = typeof item === 'string' ? JSON.parse(item) : item;
|
||||
|
||||
return (
|
||||
@@ -97,9 +96,9 @@ const ToolResult: React.FC<ToolResultProps> = ({ result, onClose }) => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{result.success ? (
|
||||
<CheckCircle size={20} className="text-green-500" />
|
||||
<CheckCircle size={20} className="text-status-green" />
|
||||
) : (
|
||||
<XCircle size={20} className="text-red-500" />
|
||||
<XCircle size={20} className="text-status-red" />
|
||||
)}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900">
|
||||
|
||||
@@ -73,7 +73,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 +90,7 @@ 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 py-1 z-50">
|
||||
<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"
|
||||
|
||||
@@ -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 { shouldSkipAuth } 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,26 @@ 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 = await shouldSkipAuth();
|
||||
|
||||
if (skipAuth) {
|
||||
// If authentication is disabled, set user as authenticated with a dummy user
|
||||
setAuth({
|
||||
isAuthenticated: true,
|
||||
loading: false,
|
||||
user: {
|
||||
username: 'guest',
|
||||
isAdmin: true,
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal authentication flow
|
||||
const token = authService.getToken();
|
||||
|
||||
|
||||
if (!token) {
|
||||
setAuth({
|
||||
...initialState,
|
||||
@@ -40,13 +58,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 +84,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
loadUser();
|
||||
}, []);
|
||||
|
||||
@@ -75,10 +92,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 +121,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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MarketServer, ApiResponse } from '@/types';
|
||||
import { MarketServer, ApiResponse, ServerConfig } from '@/types';
|
||||
import { getApiUrl } from '../utils/runtime';
|
||||
|
||||
export const useMarketData = () => {
|
||||
@@ -347,7 +347,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,14 +362,14 @@ 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
|
||||
|
||||
@@ -10,6 +10,7 @@ interface RoutingConfig {
|
||||
enableGroupNameRoute: boolean;
|
||||
enableBearerAuth: boolean;
|
||||
bearerAuthKey: string;
|
||||
skipAuth: boolean;
|
||||
}
|
||||
|
||||
interface InstallConfig {
|
||||
@@ -46,6 +47,7 @@ export const useSettingsData = () => {
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
skipAuth: false,
|
||||
});
|
||||
|
||||
const [tempRoutingConfig, setTempRoutingConfig] = useState<TempRoutingConfig>({
|
||||
@@ -99,6 +101,7 @@ 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) {
|
||||
|
||||
@@ -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,432 @@ 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: var(--color-blue-100) !important;
|
||||
color: var(--color-blue-800) !important;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--color-blue-200) !important;
|
||||
color: var(--color-blue-800) !important;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -99,6 +99,13 @@
|
||||
"enabled": "Enabled",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"requestOptions": "Configuration",
|
||||
"timeout": "Request Timeout",
|
||||
"timeoutDescription": "Timeout for requests to the MCP server (ms)",
|
||||
"maxTotalTimeout": "Maximum Total Timeout",
|
||||
"maxTotalTimeoutDescription": "Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)",
|
||||
"resetTimeoutOnProgress": "Reset Timeout on Progress",
|
||||
"resetTimeoutOnProgressDescription": "Reset timeout on progress notifications",
|
||||
"remove": "Remove",
|
||||
"toggleError": "Failed to toggle server {{serverName}}",
|
||||
"alreadyExists": "Server {{serverName}} already exists",
|
||||
@@ -109,7 +116,38 @@
|
||||
"commandPlaceholder": "Enter command",
|
||||
"argumentsPlaceholder": "Enter arguments",
|
||||
"errorDetails": "Error Details",
|
||||
"viewErrorDetails": "View error details"
|
||||
"viewErrorDetails": "View error details",
|
||||
"confirmVariables": "Confirm Variable Configuration",
|
||||
"variablesDetected": "Variables detected in configuration. Please confirm these variables are properly configured:",
|
||||
"detectedVariables": "Detected Variables",
|
||||
"confirmVariablesMessage": "Please ensure these variables are properly defined in your runtime environment. Continue adding server?",
|
||||
"confirmAndAdd": "Confirm and Add",
|
||||
"openapi": {
|
||||
"inputMode": "Input Mode",
|
||||
"inputModeUrl": "Specification URL",
|
||||
"inputModeSchema": "JSON Schema",
|
||||
"specUrl": "OpenAPI Specification URL",
|
||||
"schema": "OpenAPI JSON Schema",
|
||||
"schemaHelp": "Paste your complete OpenAPI JSON schema here",
|
||||
"security": "Security Type",
|
||||
"securityNone": "None",
|
||||
"securityApiKey": "API Key",
|
||||
"securityHttp": "HTTP Authentication",
|
||||
"securityOAuth2": "OAuth 2.0",
|
||||
"securityOpenIdConnect": "OpenID Connect",
|
||||
"apiKeyConfig": "API Key Configuration",
|
||||
"apiKeyName": "Header/Parameter Name",
|
||||
"apiKeyIn": "Location",
|
||||
"apiKeyValue": "API Key Value",
|
||||
"httpAuthConfig": "HTTP Authentication Configuration",
|
||||
"httpScheme": "Authentication Scheme",
|
||||
"httpCredentials": "Credentials",
|
||||
"oauth2Config": "OAuth 2.0 Configuration",
|
||||
"oauth2Token": "Access Token",
|
||||
"openIdConnectConfig": "OpenID Connect Configuration",
|
||||
"openIdConnectUrl": "Discovery URL",
|
||||
"openIdConnectToken": "ID Token"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"online": "Online",
|
||||
@@ -137,10 +175,12 @@
|
||||
"create": "Create",
|
||||
"submitting": "Submitting...",
|
||||
"delete": "Delete",
|
||||
"remove": "Remove",
|
||||
"copy": "Copy",
|
||||
"copySuccess": "Copied to clipboard",
|
||||
"copyFailed": "Copy failed",
|
||||
"close": "Close"
|
||||
"close": "Close",
|
||||
"confirm": "Confirm"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -262,7 +302,9 @@
|
||||
"tagFilterError": "Error filtering servers by tag",
|
||||
"noInstallationMethod": "No installation method available for this server",
|
||||
"showing": "Showing {{from}}-{{to}} of {{total}} servers",
|
||||
"perPage": "Per page"
|
||||
"perPage": "Per page",
|
||||
"confirmVariablesMessage": "Please ensure these variables are properly defined in your runtime environment. Continue installing server?",
|
||||
"confirmAndInstall": "Confirm and Install"
|
||||
},
|
||||
"tool": {
|
||||
"run": "Run",
|
||||
@@ -284,7 +326,20 @@
|
||||
"toolResult": "Tool result",
|
||||
"noParameters": "This tool does not require any parameters.",
|
||||
"selectOption": "Select an option",
|
||||
"enterValue": "Enter {{type}} value"
|
||||
"enterValue": "Enter {{type}} value",
|
||||
"enabled": "Enabled",
|
||||
"enableSuccess": "Tool {{name}} enabled successfully",
|
||||
"disableSuccess": "Tool {{name}} disabled successfully",
|
||||
"toggleFailed": "Failed to toggle tool status",
|
||||
"parameters": "Tool Parameters",
|
||||
"formMode": "Form Mode",
|
||||
"jsonMode": "JSON Mode",
|
||||
"jsonConfiguration": "JSON Configuration",
|
||||
"invalidJsonFormat": "Invalid JSON format",
|
||||
"fixJsonBeforeSwitching": "Please fix JSON format before switching to form mode",
|
||||
"item": "Item {{index}}",
|
||||
"addItem": "Add {{key}} item",
|
||||
"enterKey": "Enter {{key}}"
|
||||
},
|
||||
"settings": {
|
||||
"enableGlobalRoute": "Enable Global Route",
|
||||
@@ -296,6 +351,8 @@
|
||||
"bearerAuthKey": "Bearer Authentication Key",
|
||||
"bearerAuthKeyDescription": "The authentication key that will be required in the Bearer token",
|
||||
"bearerAuthKeyPlaceholder": "Enter bearer authentication key",
|
||||
"skipAuth": "Skip Authentication",
|
||||
"skipAuthDescription": "Bypass login requirement for frontend and API access (DEFAULT OFF for security)",
|
||||
"pythonIndexUrl": "Python Package Repository URL",
|
||||
"pythonIndexUrlDescription": "Set UV_DEFAULT_INDEX environment variable for Python package installation",
|
||||
"pythonIndexUrlPlaceholder": "e.g. https://pypi.org/simple",
|
||||
@@ -317,5 +374,30 @@
|
||||
"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}}"
|
||||
},
|
||||
"dxt": {
|
||||
"upload": "Upload",
|
||||
"uploadTitle": "Upload DXT Extension",
|
||||
"dropFileHere": "Drop your .dxt file here",
|
||||
"orClickToSelect": "or click to select from your computer",
|
||||
"invalidFileType": "Please select a valid .dxt file",
|
||||
"noFileSelected": "Please select a .dxt file to upload",
|
||||
"uploading": "Uploading...",
|
||||
"uploadFailed": "Failed to upload DXT file",
|
||||
"installServer": "Install MCP Server from DXT",
|
||||
"extensionInfo": "Extension Information",
|
||||
"name": "Name",
|
||||
"version": "Version",
|
||||
"description": "Description",
|
||||
"author": "Author",
|
||||
"tools": "Tools",
|
||||
"serverName": "Server Name",
|
||||
"serverNamePlaceholder": "Enter a name for this server",
|
||||
"install": "Install",
|
||||
"installing": "Installing...",
|
||||
"installFailed": "Failed to install server from DXT",
|
||||
"serverExistsTitle": "Server Already Exists",
|
||||
"serverExistsConfirm": "Server '{{serverName}}' already exists. Do you want to override it with the new version?",
|
||||
"override": "Override"
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,13 @@
|
||||
"enabled": "已启用",
|
||||
"enable": "启用",
|
||||
"disable": "禁用",
|
||||
"requestOptions": "配置",
|
||||
"timeout": "请求超时",
|
||||
"timeoutDescription": "请求超时时间(毫秒)",
|
||||
"maxTotalTimeout": "最大总超时",
|
||||
"maxTotalTimeoutDescription": "无论是否有进度通知的最大总超时时间(毫秒)",
|
||||
"resetTimeoutOnProgress": "收到进度通知时重置超时",
|
||||
"resetTimeoutOnProgressDescription": "适用于发送周期性进度更新的长时间运行操作",
|
||||
"remove": "移除",
|
||||
"toggleError": "切换服务器 {{serverName}} 状态失败",
|
||||
"alreadyExists": "服务器 {{serverName}} 已经存在",
|
||||
@@ -109,7 +116,38 @@
|
||||
"commandPlaceholder": "请输入命令",
|
||||
"argumentsPlaceholder": "请输入参数",
|
||||
"errorDetails": "错误详情",
|
||||
"viewErrorDetails": "查看错误详情"
|
||||
"viewErrorDetails": "查看错误详情",
|
||||
"confirmVariables": "确认变量配置",
|
||||
"variablesDetected": "检测到配置中包含变量,请确认这些变量是否已正确配置:",
|
||||
"detectedVariables": "检测到的变量",
|
||||
"confirmVariablesMessage": "请确保这些变量在运行环境中已正确定义。是否继续添加服务器?",
|
||||
"confirmAndAdd": "确认并添加",
|
||||
"openapi": {
|
||||
"inputMode": "输入模式",
|
||||
"inputModeUrl": "规范 URL",
|
||||
"inputModeSchema": "JSON 模式",
|
||||
"specUrl": "OpenAPI 规范 URL",
|
||||
"schema": "OpenAPI JSON 模式",
|
||||
"schemaHelp": "请在此处粘贴完整的 OpenAPI JSON 模式",
|
||||
"security": "安全类型",
|
||||
"securityNone": "无",
|
||||
"securityApiKey": "API 密钥",
|
||||
"securityHttp": "HTTP 认证",
|
||||
"securityOAuth2": "OAuth 2.0",
|
||||
"securityOpenIdConnect": "OpenID Connect",
|
||||
"apiKeyConfig": "API 密钥配置",
|
||||
"apiKeyName": "请求头/参数名称",
|
||||
"apiKeyIn": "位置",
|
||||
"apiKeyValue": "API 密钥值",
|
||||
"httpAuthConfig": "HTTP 认证配置",
|
||||
"httpScheme": "认证方案",
|
||||
"httpCredentials": "凭据",
|
||||
"oauth2Config": "OAuth 2.0 配置",
|
||||
"oauth2Token": "访问令牌",
|
||||
"openIdConnectConfig": "OpenID Connect 配置",
|
||||
"openIdConnectUrl": "发现 URL",
|
||||
"openIdConnectToken": "ID 令牌"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"online": "在线",
|
||||
@@ -138,10 +176,12 @@
|
||||
"create": "创建",
|
||||
"submitting": "提交中...",
|
||||
"delete": "删除",
|
||||
"remove": "移除",
|
||||
"copy": "复制",
|
||||
"copySuccess": "已复制到剪贴板",
|
||||
"copyFailed": "复制失败",
|
||||
"close": "关闭"
|
||||
"close": "关闭",
|
||||
"confirm": "确认"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "仪表盘",
|
||||
@@ -263,12 +303,14 @@
|
||||
"tagFilterError": "按标签筛选服务器失败",
|
||||
"noInstallationMethod": "该服务器没有可用的安装方法",
|
||||
"showing": "显示 {{from}}-{{to}}/{{total}} 个服务器",
|
||||
"perPage": "每页显示"
|
||||
"perPage": "每页显示",
|
||||
"confirmVariablesMessage": "请确保这些变量在运行环境中已正确定义。是否继续安装服务器?",
|
||||
"confirmAndInstall": "确认并安装"
|
||||
},
|
||||
"tool": {
|
||||
"run": "运行",
|
||||
"running": "运行中...",
|
||||
"runTool": "运行工具",
|
||||
"runTool": "运行",
|
||||
"cancel": "取消",
|
||||
"noDescription": "无描述信息",
|
||||
"inputSchema": "输入模式:",
|
||||
@@ -285,7 +327,20 @@
|
||||
"toolResult": "工具结果",
|
||||
"noParameters": "此工具不需要任何参数。",
|
||||
"selectOption": "选择一个选项",
|
||||
"enterValue": "输入{{type}}值"
|
||||
"enterValue": "输入{{type}}值",
|
||||
"enabled": "已启用",
|
||||
"enableSuccess": "工具 {{name}} 启用成功",
|
||||
"disableSuccess": "工具 {{name}} 禁用成功",
|
||||
"toggleFailed": "切换工具状态失败",
|
||||
"parameters": "工具参数",
|
||||
"formMode": "表单模式",
|
||||
"jsonMode": "JSON 模式",
|
||||
"jsonConfiguration": "JSON 配置",
|
||||
"invalidJsonFormat": "无效的 JSON 格式",
|
||||
"fixJsonBeforeSwitching": "请修复 JSON 格式后再切换到表单模式",
|
||||
"item": "项目 {{index}}",
|
||||
"addItem": "添加 {{key}} 项目",
|
||||
"enterKey": "输入 {{key}}"
|
||||
},
|
||||
"settings": {
|
||||
"enableGlobalRoute": "启用全局路由",
|
||||
@@ -297,6 +352,8 @@
|
||||
"bearerAuthKey": "Bearer 认证密钥",
|
||||
"bearerAuthKeyDescription": "Bearer 令牌中需要携带的认证密钥",
|
||||
"bearerAuthKeyPlaceholder": "请输入 Bearer 认证密钥",
|
||||
"skipAuth": "免登录开关",
|
||||
"skipAuthDescription": "跳过前端和 API 访问的登录要求(默认关闭确保安全性)",
|
||||
"pythonIndexUrl": "Python 包仓库地址",
|
||||
"pythonIndexUrlDescription": "设置 UV_DEFAULT_INDEX 环境变量,用于 Python 包安装",
|
||||
"pythonIndexUrlPlaceholder": "例如: https://mirrors.aliyun.com/pypi/simple",
|
||||
@@ -319,5 +376,30 @@
|
||||
"smartRoutingConfigUpdated": "智能路由配置更新成功",
|
||||
"smartRoutingRequiredFields": "启用智能路由需要填写数据库连接地址和 OpenAI API 密钥",
|
||||
"smartRoutingValidationError": "启用智能路由前请先填写必要字段:{{fields}}"
|
||||
},
|
||||
"dxt": {
|
||||
"upload": "上传",
|
||||
"uploadTitle": "上传 DXT 扩展",
|
||||
"dropFileHere": "将 .dxt 文件拖拽到此处",
|
||||
"orClickToSelect": "或点击从计算机选择",
|
||||
"invalidFileType": "请选择有效的 .dxt 文件",
|
||||
"noFileSelected": "请选择要上传的 .dxt 文件",
|
||||
"uploading": "上传中...",
|
||||
"uploadFailed": "上传 DXT 文件失败",
|
||||
"installServer": "从 DXT 安装 MCP 服务器",
|
||||
"extensionInfo": "扩展信息",
|
||||
"name": "名称",
|
||||
"version": "版本",
|
||||
"description": "描述",
|
||||
"author": "作者",
|
||||
"tools": "工具",
|
||||
"serverName": "服务器名称",
|
||||
"serverNamePlaceholder": "为此服务器输入名称",
|
||||
"install": "安装",
|
||||
"installing": "安装中...",
|
||||
"installFailed": "从 DXT 安装服务器失败",
|
||||
"serverExistsTitle": "服务器已存在",
|
||||
"serverExistsConfirm": "服务器 '{{serverName}}' 已存在。是否要用新版本覆盖它?",
|
||||
"override": "覆盖"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useServerData } from '@/hooks/useServerData';
|
||||
import { ServerStatus } from '@/types';
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -22,26 +21,20 @@ const DashboardPage: React.FC = () => {
|
||||
connecting: 'status.connecting'
|
||||
}
|
||||
|
||||
// Calculate percentage for each status (for dashboard display)
|
||||
const getStatusPercentage = (status: ServerStatus) => {
|
||||
if (servers.length === 0) return 0;
|
||||
return Math.round((servers.filter(server => server.status === status).length / servers.length) * 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-8">{t('pages.dashboard.title')}</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm">
|
||||
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm error-box">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-red-600 text-lg font-medium">{t('app.error')}</h3>
|
||||
<h3 className="text-status-red text-lg font-medium">{t('app.error')}</h3>
|
||||
<p className="text-gray-600 mt-1">{error}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="ml-4 text-gray-500 hover:text-gray-700"
|
||||
className="ml-4 text-gray-500 hover:text-gray-700 transition-colors duration-200"
|
||||
aria-label={t('app.closeButton')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
@@ -52,8 +45,8 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center">
|
||||
{isLoading && (
|
||||
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center loading-container">
|
||||
<div className="flex flex-col items-center">
|
||||
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
@@ -62,12 +55,14 @@ const DashboardPage: React.FC = () => {
|
||||
<p className="text-gray-600">{t('app.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
)}
|
||||
|
||||
{!isLoading && (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Total servers */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-blue-100 text-blue-800">
|
||||
<div className="p-3 rounded-full bg-blue-100 text-blue-800 icon-container status-icon-blue">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
@@ -80,9 +75,9 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Online servers */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-green-100 text-green-800">
|
||||
<div className="p-3 rounded-full bg-green-100 text-green-800 icon-container status-icon-green">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
@@ -92,18 +87,12 @@ const DashboardPage: React.FC = () => {
|
||||
<p className="text-3xl font-bold text-gray-900">{serverStats.online}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 h-2 bg-gray-200 rounded-full">
|
||||
<div
|
||||
className="h-full bg-green-500 rounded-full"
|
||||
style={{ width: `${getStatusPercentage('connected')}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Offline servers */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-red-100 text-red-800">
|
||||
<div className="p-3 rounded-full bg-red-100 text-red-800 icon-container status-icon-red">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
@@ -113,18 +102,12 @@ const DashboardPage: React.FC = () => {
|
||||
<p className="text-3xl font-bold text-gray-900">{serverStats.offline}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 h-2 bg-gray-200 rounded-full">
|
||||
<div
|
||||
className="h-full bg-red-500 rounded-full"
|
||||
style={{ width: `${getStatusPercentage('disconnected')}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connecting servers */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-yellow-100 text-yellow-800">
|
||||
<div className="p-3 rounded-full bg-yellow-100 text-yellow-800 icon-container status-icon-yellow">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
@@ -134,12 +117,7 @@ const DashboardPage: React.FC = () => {
|
||||
<p className="text-3xl font-bold text-gray-900">{serverStats.connecting}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 h-2 bg-gray-200 rounded-full">
|
||||
<div
|
||||
className="h-full bg-yellow-500 rounded-full"
|
||||
style={{ width: `${getStatusPercentage('connecting')}%` }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -148,20 +126,20 @@ const DashboardPage: React.FC = () => {
|
||||
{servers.length > 0 && !isLoading && (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">{t('pages.dashboard.recentServers')}</h2>
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden table-container">
|
||||
<table className="min-w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('server.name')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('server.status')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('server.tools')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('server.enabled')}
|
||||
</th>
|
||||
</tr>
|
||||
@@ -173,11 +151,11 @@ const DashboardPage: React.FC = () => {
|
||||
{server.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${server.status === 'connected'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: server.status === 'disconnected'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${server.status === 'connected'
|
||||
? 'status-badge-online'
|
||||
: server.status === 'disconnected'
|
||||
? 'status-badge-offline'
|
||||
: 'status-badge-connecting'
|
||||
}`}>
|
||||
{t(statusTranslations[server.status] || server.status)}
|
||||
</span>
|
||||
@@ -189,7 +167,7 @@ const DashboardPage: React.FC = () => {
|
||||
{server.enabled !== false ? (
|
||||
<span className="text-green-600">✓</span>
|
||||
) : (
|
||||
<span className="text-red-600">✗</span>
|
||||
<span className="text-status-red">✗</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -9,16 +9,16 @@ import GroupCard from '@/components/GroupCard';
|
||||
|
||||
const GroupsPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
groups,
|
||||
loading: groupsLoading,
|
||||
error: groupError,
|
||||
const {
|
||||
groups,
|
||||
loading: groupsLoading,
|
||||
error: groupError,
|
||||
setError: setGroupError,
|
||||
deleteGroup,
|
||||
triggerRefresh
|
||||
} = useGroupData();
|
||||
const { servers } = useServerData();
|
||||
|
||||
|
||||
const [editingGroup, setEditingGroup] = useState<Group | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -26,7 +26,7 @@ const LoginPage: React.FC = () => {
|
||||
}
|
||||
|
||||
const success = await login(username, password);
|
||||
|
||||
|
||||
if (success) {
|
||||
navigate('/');
|
||||
} else {
|
||||
@@ -40,18 +40,18 @@ 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="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">
|
||||
<ThemeSwitch />
|
||||
</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="rounded-md -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="username" className="sr-only">
|
||||
{t('auth.username')}
|
||||
@@ -62,7 +62,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 rounded-none relative block w-full px-3 py-2 border border-gray-200 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 transition-all duration-200 form-input"
|
||||
placeholder={t('auth.username')}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
@@ -78,7 +78,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 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 login-input transition-all duration-200 form-input"
|
||||
placeholder={t('auth.password')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
@@ -87,14 +87,14 @@ const LoginPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-500 dark:text-red-400 text-sm text-center">{error}</div>
|
||||
<div className="text-red-500 dark:text-red-400 text-sm text-center error-box p-2 rounded">{error}</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 login-button transition-all duration-200 btn-primary"
|
||||
>
|
||||
{loading ? t('auth.loggingIn') : t('auth.login')}
|
||||
</button>
|
||||
|
||||
@@ -11,9 +11,9 @@ const LogsPage: React.FC = () => {
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="text-2xl font-bold">{t('pages.logs.title')}</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{t('pages.logs.title')}</h1>
|
||||
</div>
|
||||
<div className="bg-card rounded-md shadow-sm">
|
||||
<div className="bg-card rounded-md shadow-sm border border-gray-200 page-card">
|
||||
<LogViewer
|
||||
logs={logs}
|
||||
isLoading={loading}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 } from 'react-router-dom';
|
||||
import { MarketServer, ServerConfig } from '@/types';
|
||||
import { useMarketData } from '@/hooks/useMarketData';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import MarketServerCard from '@/components/MarketServerCard';
|
||||
@@ -11,15 +11,13 @@ 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();
|
||||
|
||||
|
||||
const {
|
||||
servers,
|
||||
allServers,
|
||||
categories,
|
||||
tags,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
@@ -42,7 +40,6 @@ const MarketPage: React.FC = () => {
|
||||
const [selectedServer, setSelectedServer] = useState<MarketServer | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [showTags, setShowTags] = useState(false);
|
||||
|
||||
// Load server details if a server name is in the URL
|
||||
useEffect(() => {
|
||||
@@ -59,7 +56,7 @@ const MarketPage: React.FC = () => {
|
||||
setSelectedServer(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
loadServerDetails();
|
||||
}, [serverName, fetchServerByName, navigate]);
|
||||
|
||||
@@ -72,10 +69,6 @@ const MarketPage: React.FC = () => {
|
||||
filterByCategory(category);
|
||||
};
|
||||
|
||||
const handleTagClick = (tag: string) => {
|
||||
filterByTag(tag);
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSearchQuery('');
|
||||
filterByCategory('');
|
||||
@@ -90,10 +83,11 @@ const MarketPage: React.FC = () => {
|
||||
navigate('/market');
|
||||
};
|
||||
|
||||
const handleInstall = async (server: MarketServer) => {
|
||||
const handleInstall = async (server: MarketServer, config: ServerConfig) => {
|
||||
try {
|
||||
setInstalling(true);
|
||||
const success = await installServer(server);
|
||||
// Pass the server object and the config to the installServer function
|
||||
const success = await installServer(server, config);
|
||||
if (success) {
|
||||
// Show success message using toast instead of alert
|
||||
showToast(t('market.installSuccess', { serverName: server.display_name }), 'success');
|
||||
@@ -114,10 +108,6 @@ const MarketPage: React.FC = () => {
|
||||
changeServersPerPage(newValue);
|
||||
};
|
||||
|
||||
const toggleTagsVisibility = () => {
|
||||
setShowTags(!showTags);
|
||||
};
|
||||
|
||||
// Render detailed view if a server is selected
|
||||
if (selectedServer) {
|
||||
return (
|
||||
@@ -143,12 +133,12 @@ const MarketPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<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">
|
||||
<div className="flex items-center justify-between">
|
||||
<p>{error}</p>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="text-red-700 hover:text-red-900"
|
||||
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" />
|
||||
@@ -159,7 +149,7 @@ const MarketPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Search bar at the top */}
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<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
|
||||
@@ -167,12 +157,12 @@ const MarketPage: React.FC = () => {
|
||||
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"
|
||||
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
|
||||
type="submit"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded"
|
||||
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"
|
||||
>
|
||||
{t('market.search')}
|
||||
</button>
|
||||
@@ -180,7 +170,7 @@ const MarketPage: React.FC = () => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearFilters}
|
||||
className="border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded hover:bg-gray-50"
|
||||
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>
|
||||
@@ -191,14 +181,14 @@ const MarketPage: React.FC = () => {
|
||||
<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">
|
||||
<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" onClick={() => filterByCategory('')}>
|
||||
<span className="text-xs text-blue-600 cursor-pointer hover:underline transition-colors duration-200" onClick={() => filterByCategory('')}>
|
||||
{t('market.clearCategoryFilter')}
|
||||
</span>
|
||||
)}
|
||||
@@ -208,9 +198,9 @@ const MarketPage: React.FC = () => {
|
||||
<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'
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
@@ -223,7 +213,7 @@ const MarketPage: React.FC = () => {
|
||||
<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">
|
||||
<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>
|
||||
@@ -332,7 +322,7 @@ const MarketPage: React.FC = () => {
|
||||
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>
|
||||
@@ -353,4 +343,4 @@ const MarketPage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketPage;
|
||||
export default MarketPage;
|
||||
|
||||
@@ -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,7 +100,7 @@ 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>
|
||||
@@ -91,7 +108,7 @@ const ServersPage: React.FC = () => {
|
||||
</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 +120,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 +130,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>
|
||||
) : (
|
||||
@@ -125,6 +142,7 @@ const ServersPage: React.FC = () => {
|
||||
onRemove={handleServerRemove}
|
||||
onEdit={handleEditClick}
|
||||
onToggle={handleServerToggle}
|
||||
onRefresh={triggerRefresh}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -137,6 +155,13 @@ const ServersPage: React.FC = () => {
|
||||
onCancel={() => setEditingServer(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDxtUpload && (
|
||||
<DxtUploadForm
|
||||
onSuccess={handleDxtUploadSuccess}
|
||||
onCancel={() => setShowDxtUpload(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -85,7 +85,7 @@ const SettingsPage: React.FC = () => {
|
||||
}));
|
||||
};
|
||||
|
||||
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) {
|
||||
@@ -203,23 +203,23 @@ const SettingsPage: React.FC = () => {
|
||||
<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="bg-white shadow rounded-lg py-4 px-6 mb-6 page-card">
|
||||
<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'
|
||||
className={`px-3 py-1.5 rounded-md transition-all duration-200 text-sm ${currentLanguage.startsWith('en')
|
||||
? 'bg-blue-500 text-white btn-primary'
|
||||
: 'bg-blue-100 text-blue-800 hover:bg-blue-200 btn-secondary'
|
||||
}`}
|
||||
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'
|
||||
className={`px-3 py-1.5 rounded-md transition-all duration-200 text-sm ${currentLanguage.startsWith('zh')
|
||||
? 'bg-blue-500 text-white btn-primary'
|
||||
: 'bg-blue-100 text-blue-800 hover:bg-blue-200 btn-secondary'
|
||||
}`}
|
||||
onClick={() => handleLanguageChange('zh')}
|
||||
>
|
||||
@@ -230,13 +230,13 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Smart Routing 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 page-card">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
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">
|
||||
<span className="text-gray-500 transition-transform duration-200">
|
||||
{sectionsVisible.smartRoutingConfig ? '▼' : '►'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -267,13 +267,13 @@ const SettingsPage: React.FC = () => {
|
||||
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"
|
||||
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"
|
||||
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>
|
||||
@@ -298,7 +298,7 @@ const SettingsPage: React.FC = () => {
|
||||
<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"
|
||||
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>
|
||||
@@ -315,13 +315,13 @@ const SettingsPage: React.FC = () => {
|
||||
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"
|
||||
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"
|
||||
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>
|
||||
@@ -338,13 +338,13 @@ const SettingsPage: React.FC = () => {
|
||||
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"
|
||||
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"
|
||||
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>
|
||||
@@ -392,13 +392,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,6 +430,18 @@ const SettingsPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -459,13 +471,13 @@ const SettingsPage: React.FC = () => {
|
||||
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"
|
||||
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"
|
||||
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>
|
||||
@@ -483,13 +495,13 @@ const SettingsPage: React.FC = () => {
|
||||
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"
|
||||
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"
|
||||
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>
|
||||
|
||||
102
frontend/src/services/configService.ts
Normal file
102
frontend/src/services/configService.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { getApiUrl, 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;
|
||||
};
|
||||
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 }> => {
|
||||
try {
|
||||
const basePath = getBasePath();
|
||||
const response = await fetch(`${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 };
|
||||
}
|
||||
|
||||
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 fetch(getApiUrl('/settings'), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data: SystemConfigResponse = await response.json();
|
||||
return data.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;
|
||||
}
|
||||
};
|
||||
@@ -15,13 +15,9 @@ 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 fetch(getApiUrl('/logs'), {
|
||||
headers: {
|
||||
'x-auth-token': token,
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -43,14 +39,10 @@ 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 fetch(getApiUrl('/logs'), {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-auth-token': token,
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -84,12 +76,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}`));
|
||||
|
||||
|
||||
@@ -26,10 +26,6 @@ export const callTool = async (
|
||||
): 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';
|
||||
|
||||
@@ -37,7 +33,7 @@ export const callTool = async (
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token,
|
||||
'x-auth-token': token || '', // Include token for authentication
|
||||
Authorization: `Bearer ${token}`, // Add bearer auth for MCP routing
|
||||
},
|
||||
body: JSON.stringify({
|
||||
@@ -70,3 +66,82 @@ export const callTool = async (
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle a tool's enabled state for a specific server
|
||||
*/
|
||||
export const toggleTool = async (
|
||||
serverName: string,
|
||||
toolName: string,
|
||||
enabled: boolean,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
const token = getToken();
|
||||
const response = await fetch(getApiUrl(`/servers/${serverName}/tools/${toolName}/toggle`), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
Authorization: `Bearer ${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,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error toggling tool:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a tool's description for a specific server
|
||||
*/
|
||||
export const updateToolDescription = async (
|
||||
serverName: string,
|
||||
toolName: string,
|
||||
description: string,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
const token = getToken();
|
||||
const response = await fetch(
|
||||
getApiUrl(`/servers/${serverName}/tools/${toolName}/description`),
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
Authorization: `Bearer ${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,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error updating tool description:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -67,17 +67,63 @@ export interface Tool {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: ToolInputSchema;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
// Server config types
|
||||
export interface ServerConfig {
|
||||
type?: 'stdio' | 'sse' | 'streamable-http';
|
||||
type?: 'stdio' | 'sse' | 'streamable-http' | 'openapi';
|
||||
url?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
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
|
||||
@@ -111,9 +157,39 @@ 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
|
||||
|
||||
@@ -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
|
||||
|
||||
27
frontend/src/utils/variableDetection.ts
Normal file
27
frontend/src/utils/variableDetection.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Utility function to detect ${} variables in server configurations
|
||||
export const detectVariables = (payload: any): string[] => {
|
||||
const variables = new Set<string>();
|
||||
const variableRegex = /\$\{([^}]+)\}/g;
|
||||
|
||||
const checkString = (str: string) => {
|
||||
let match;
|
||||
while ((match = variableRegex.exec(str)) !== null) {
|
||||
variables.add(match[1]);
|
||||
}
|
||||
};
|
||||
|
||||
const checkObject = (obj: any, path: string = '') => {
|
||||
if (typeof obj === 'string') {
|
||||
checkString(obj);
|
||||
} else if (Array.isArray(obj)) {
|
||||
obj.forEach((item, index) => checkObject(item, `${path}[${index}]`));
|
||||
} else if (obj && typeof obj === 'object') {
|
||||
Object.entries(obj).forEach(([key, value]) => {
|
||||
checkObject(value, path ? `${path}.${key}` : key);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
checkObject(payload);
|
||||
return Array.from(variables);
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
44
jest.config.cjs
Normal file
44
jest.config.cjs
Normal file
@@ -0,0 +1,44 @@
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
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',
|
||||
},
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
testTimeout: 10000,
|
||||
verbose: true,
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src'],
|
||||
transform: {
|
||||
'^.+\\.tsx?$': 'ts-jest',
|
||||
},
|
||||
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$',
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
||||
};
|
||||
19
package.json
19
package.json
@@ -25,6 +25,10 @@
|
||||
"lint": "eslint . --ext .ts",
|
||||
"format": "prettier --write \"src/**/*.ts\"",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"test:verbose": "jest --verbose",
|
||||
"test:ci": "jest --ci --coverage --watchAll=false",
|
||||
"frontend:dev": "cd frontend && vite",
|
||||
"frontend:build": "cd frontend && vite build",
|
||||
"frontend:preview": "cd frontend && vite preview",
|
||||
@@ -41,15 +45,22 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.11.1",
|
||||
"@apidevtools/swagger-parser": "^11.0.1",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/multer": "^1.4.13",
|
||||
"@types/pg": "^8.15.2",
|
||||
"adm-zip": "^0.5.16",
|
||||
"axios": "^1.10.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"dotenv": "^16.3.1",
|
||||
"dotenv-expand": "^12.0.2",
|
||||
"express": "^4.21.2",
|
||||
"express-validator": "^7.2.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.1",
|
||||
"openai": "^4.103.0",
|
||||
"openapi-types": "^12.1.3",
|
||||
"pg": "^8.16.0",
|
||||
"pgvector": "^0.2.1",
|
||||
"postgres": "^3.4.7",
|
||||
@@ -70,6 +81,7 @@
|
||||
"@types/node": "^22.15.21",
|
||||
"@types/react": "^19.0.12",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
||||
"@typescript-eslint/parser": "^6.7.4",
|
||||
@@ -77,11 +89,13 @@
|
||||
"autoprefixer": "^10.4.21",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"concurrently": "^8.2.2",
|
||||
"concurrently": "^9.1.2",
|
||||
"eslint": "^8.50.0",
|
||||
"i18next": "^24.2.3",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-node": "^30.0.0",
|
||||
"jest-mock-extended": "4.0.0-beta1",
|
||||
"lucide-react": "^0.486.0",
|
||||
"next": "^15.2.4",
|
||||
"postcss": "^8.5.3",
|
||||
@@ -90,6 +104,7 @@
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"supertest": "^7.1.1",
|
||||
"tailwind-merge": "^3.1.0",
|
||||
"tailwind-scrollbar-hide": "^2.0.0",
|
||||
"tailwindcss": "^4.0.17",
|
||||
|
||||
1611
pnpm-lock.yaml
generated
1611
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
343
src/clients/openapi.ts
Normal file
343
src/clients/openapi.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||
import SwaggerParser from '@apidevtools/swagger-parser';
|
||||
import { OpenAPIV3 } from 'openapi-types';
|
||||
import { ServerConfig, OpenAPISecurityConfig } from '../types/index.js';
|
||||
|
||||
export interface OpenAPIToolInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
operationId: string;
|
||||
method: string;
|
||||
path: string;
|
||||
parameters?: OpenAPIV3.ParameterObject[];
|
||||
requestBody?: OpenAPIV3.RequestBodyObject;
|
||||
responses?: OpenAPIV3.ResponsesObject;
|
||||
}
|
||||
|
||||
export class OpenAPIClient {
|
||||
private httpClient: AxiosInstance;
|
||||
private spec: OpenAPIV3.Document | null = null;
|
||||
private tools: OpenAPIToolInfo[] = [];
|
||||
private baseUrl: string;
|
||||
private securityConfig?: OpenAPISecurityConfig;
|
||||
|
||||
constructor(private config: ServerConfig) {
|
||||
if (!config.openapi?.url && !config.openapi?.schema) {
|
||||
throw new Error('OpenAPI URL or schema is required');
|
||||
}
|
||||
|
||||
// 初始 baseUrl,将在 initialize() 中从 OpenAPI servers 字段更新
|
||||
this.baseUrl = config.openapi?.url ? this.extractBaseUrl(config.openapi.url) : '';
|
||||
this.securityConfig = config.openapi.security;
|
||||
|
||||
this.httpClient = axios.create({
|
||||
baseURL: this.baseUrl,
|
||||
timeout: config.options?.timeout || 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...config.headers,
|
||||
},
|
||||
});
|
||||
|
||||
this.setupSecurity();
|
||||
}
|
||||
|
||||
private extractBaseUrl(specUrl: string): string {
|
||||
try {
|
||||
const url = new URL(specUrl);
|
||||
return `${url.protocol}//${url.host}`;
|
||||
} catch {
|
||||
// If specUrl is a relative path, assume current host
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private setupSecurity(): void {
|
||||
if (!this.securityConfig || this.securityConfig.type === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (this.securityConfig.type) {
|
||||
case 'apiKey':
|
||||
if (this.securityConfig.apiKey) {
|
||||
const { name, in: location, value } = this.securityConfig.apiKey;
|
||||
if (location === 'header') {
|
||||
this.httpClient.defaults.headers.common[name] = value;
|
||||
} else if (location === 'query') {
|
||||
this.httpClient.interceptors.request.use((config: any) => {
|
||||
config.params = { ...config.params, [name]: value };
|
||||
return config;
|
||||
});
|
||||
}
|
||||
// Note: Cookie authentication would need additional setup
|
||||
}
|
||||
break;
|
||||
|
||||
case 'http':
|
||||
if (this.securityConfig.http) {
|
||||
const { scheme, credentials } = this.securityConfig.http;
|
||||
if (scheme === 'bearer' && credentials) {
|
||||
this.httpClient.defaults.headers.common['Authorization'] = `Bearer ${credentials}`;
|
||||
} else if (scheme === 'basic' && credentials) {
|
||||
this.httpClient.defaults.headers.common['Authorization'] = `Basic ${credentials}`;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'oauth2':
|
||||
if (this.securityConfig.oauth2?.token) {
|
||||
this.httpClient.defaults.headers.common['Authorization'] =
|
||||
`Bearer ${this.securityConfig.oauth2.token}`;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'openIdConnect':
|
||||
if (this.securityConfig.openIdConnect?.token) {
|
||||
this.httpClient.defaults.headers.common['Authorization'] =
|
||||
`Bearer ${this.securityConfig.openIdConnect.token}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
try {
|
||||
// Parse and dereference the OpenAPI specification
|
||||
if (this.config.openapi?.url) {
|
||||
this.spec = (await SwaggerParser.dereference(
|
||||
this.config.openapi.url,
|
||||
)) as OpenAPIV3.Document;
|
||||
} else if (this.config.openapi?.schema) {
|
||||
// For schema object, we need to pass it as a cloned object
|
||||
this.spec = (await SwaggerParser.dereference(
|
||||
JSON.parse(JSON.stringify(this.config.openapi.schema)),
|
||||
)) as OpenAPIV3.Document;
|
||||
} else {
|
||||
throw new Error('Either OpenAPI URL or schema must be provided');
|
||||
}
|
||||
|
||||
// 从 OpenAPI servers 字段更新 baseUrl
|
||||
this.updateBaseUrlFromServers();
|
||||
|
||||
this.extractTools();
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Failed to load OpenAPI specification: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
private updateBaseUrlFromServers(): void {
|
||||
if (!this.spec?.servers || this.spec.servers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取第一个 server 的 URL
|
||||
const serverUrl = this.spec.servers[0].url;
|
||||
|
||||
// 如果是相对路径,需要与原始 spec URL 结合
|
||||
if (serverUrl.startsWith('/')) {
|
||||
// 相对路径,使用原始 spec URL 的协议和主机
|
||||
if (this.config.openapi?.url) {
|
||||
const originalUrl = new URL(this.config.openapi.url);
|
||||
this.baseUrl = `${originalUrl.protocol}//${originalUrl.host}${serverUrl}`;
|
||||
}
|
||||
} else if (serverUrl.startsWith('http://') || serverUrl.startsWith('https://')) {
|
||||
// 绝对路径
|
||||
this.baseUrl = serverUrl;
|
||||
} else {
|
||||
// 相对路径但不以 / 开头,可能是相对于当前路径
|
||||
if (this.config.openapi?.url) {
|
||||
const originalUrl = new URL(this.config.openapi.url);
|
||||
this.baseUrl = `${originalUrl.protocol}//${originalUrl.host}/${serverUrl}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新 HTTP 客户端的 baseURL
|
||||
this.httpClient.defaults.baseURL = this.baseUrl;
|
||||
}
|
||||
|
||||
private extractTools(): void {
|
||||
if (!this.spec?.paths) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tools = [];
|
||||
|
||||
for (const [path, pathItem] of Object.entries(this.spec.paths)) {
|
||||
if (!pathItem) continue;
|
||||
|
||||
const methods = [
|
||||
'get',
|
||||
'post',
|
||||
'put',
|
||||
'delete',
|
||||
'patch',
|
||||
'head',
|
||||
'options',
|
||||
'trace',
|
||||
] as const;
|
||||
|
||||
for (const method of methods) {
|
||||
const operation = pathItem[method] as OpenAPIV3.OperationObject | undefined;
|
||||
if (!operation || !operation.operationId) continue;
|
||||
|
||||
const tool: OpenAPIToolInfo = {
|
||||
name: operation.operationId,
|
||||
description:
|
||||
operation.summary || operation.description || `${method.toUpperCase()} ${path}`,
|
||||
inputSchema: this.generateInputSchema(operation, path, method as string),
|
||||
operationId: operation.operationId,
|
||||
method: method as string,
|
||||
path,
|
||||
parameters: operation.parameters as OpenAPIV3.ParameterObject[],
|
||||
requestBody: operation.requestBody as OpenAPIV3.RequestBodyObject,
|
||||
responses: operation.responses,
|
||||
};
|
||||
|
||||
this.tools.push(tool);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private generateInputSchema(
|
||||
operation: OpenAPIV3.OperationObject,
|
||||
_path: string,
|
||||
_method: string,
|
||||
): Record<string, unknown> {
|
||||
const schema: Record<string, unknown> = {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
};
|
||||
|
||||
const properties = schema.properties as Record<string, unknown>;
|
||||
const required = schema.required as string[];
|
||||
|
||||
// Handle path parameters
|
||||
const pathParams = operation.parameters?.filter(
|
||||
(p: any) => 'in' in p && p.in === 'path',
|
||||
) as OpenAPIV3.ParameterObject[];
|
||||
|
||||
if (pathParams?.length) {
|
||||
for (const param of pathParams) {
|
||||
properties[param.name] = {
|
||||
type: 'string',
|
||||
description: param.description || `Path parameter: ${param.name}`,
|
||||
};
|
||||
if (param.required) {
|
||||
required.push(param.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle query parameters
|
||||
const queryParams = operation.parameters?.filter(
|
||||
(p: any) => 'in' in p && p.in === 'query',
|
||||
) as OpenAPIV3.ParameterObject[];
|
||||
|
||||
if (queryParams?.length) {
|
||||
for (const param of queryParams) {
|
||||
properties[param.name] = param.schema || {
|
||||
type: 'string',
|
||||
description: param.description || `Query parameter: ${param.name}`,
|
||||
};
|
||||
if (param.required) {
|
||||
required.push(param.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle request body
|
||||
if (operation.requestBody && 'content' in operation.requestBody) {
|
||||
const requestBody = operation.requestBody as OpenAPIV3.RequestBodyObject;
|
||||
const jsonContent = requestBody.content?.['application/json'];
|
||||
|
||||
if (jsonContent?.schema) {
|
||||
properties['body'] = jsonContent.schema;
|
||||
if (requestBody.required) {
|
||||
required.push('body');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
async callTool(toolName: string, args: Record<string, unknown>): Promise<unknown> {
|
||||
const tool = this.tools.find((t) => t.name === toolName);
|
||||
if (!tool) {
|
||||
throw new Error(`Tool '${toolName}' not found`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Build the request URL with path parameters
|
||||
let url = tool.path;
|
||||
const pathParams = tool.parameters?.filter((p) => p.in === 'path') || [];
|
||||
|
||||
for (const param of pathParams) {
|
||||
const value = args[param.name];
|
||||
if (value !== undefined) {
|
||||
url = url.replace(`{${param.name}}`, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
// Build query parameters
|
||||
const queryParams: Record<string, unknown> = {};
|
||||
const queryParamDefs = tool.parameters?.filter((p) => p.in === 'query') || [];
|
||||
|
||||
for (const param of queryParamDefs) {
|
||||
const value = args[param.name];
|
||||
if (value !== undefined) {
|
||||
queryParams[param.name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare request configuration
|
||||
const requestConfig: AxiosRequestConfig = {
|
||||
method: tool.method as any,
|
||||
url,
|
||||
params: queryParams,
|
||||
};
|
||||
|
||||
// Add request body if applicable
|
||||
if (args.body && ['post', 'put', 'patch'].includes(tool.method)) {
|
||||
requestConfig.data = args.body;
|
||||
}
|
||||
|
||||
// Add headers if any header parameters are defined
|
||||
const headerParams = tool.parameters?.filter((p) => p.in === 'header') || [];
|
||||
if (headerParams.length > 0) {
|
||||
requestConfig.headers = {};
|
||||
for (const param of headerParams) {
|
||||
const value = args[param.name];
|
||||
if (value !== undefined) {
|
||||
requestConfig.headers[param.name] = String(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await this.httpClient.request(requestConfig);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw new Error(
|
||||
`API call failed: ${error.response?.status} ${error.response?.statusText} - ${JSON.stringify(error.response?.data)}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getTools(): OpenAPIToolInfo[] {
|
||||
return this.tools;
|
||||
}
|
||||
|
||||
getSpec(): OpenAPIV3.Document | null {
|
||||
return this.spec;
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
// No persistent connection to close for OpenAPI
|
||||
}
|
||||
}
|
||||
@@ -15,18 +15,37 @@ const defaultConfig = {
|
||||
mcpHubVersion: getPackageVersion(),
|
||||
};
|
||||
|
||||
// Settings cache
|
||||
let settingsCache: McpSettings | null = null;
|
||||
|
||||
export const getSettingsPath = (): string => {
|
||||
return getConfigFilePath('mcp_settings.json', 'Settings');
|
||||
};
|
||||
|
||||
export const loadSettings = (): McpSettings => {
|
||||
// If cache exists, return cached data directly
|
||||
if (settingsCache) {
|
||||
return settingsCache;
|
||||
}
|
||||
|
||||
const settingsPath = getSettingsPath();
|
||||
try {
|
||||
const settingsData = fs.readFileSync(settingsPath, 'utf8');
|
||||
return JSON.parse(settingsData);
|
||||
const settings = JSON.parse(settingsData);
|
||||
|
||||
// Update cache
|
||||
settingsCache = settings;
|
||||
|
||||
console.log(`Loaded settings from ${settingsPath}`);
|
||||
return settings;
|
||||
} catch (error) {
|
||||
console.error(`Failed to load settings from ${settingsPath}:`, error);
|
||||
return { mcpServers: {}, users: [] };
|
||||
const defaultSettings = { mcpServers: {}, users: [] };
|
||||
|
||||
// Cache default settings
|
||||
settingsCache = defaultSettings;
|
||||
|
||||
return defaultSettings;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -34,6 +53,10 @@ export const saveSettings = (settings: McpSettings): boolean => {
|
||||
const settingsPath = getSettingsPath();
|
||||
try {
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
|
||||
|
||||
// Update cache after successful save
|
||||
settingsCache = settings;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to save settings to ${settingsPath}:`, error);
|
||||
@@ -41,6 +64,22 @@ export const saveSettings = (settings: McpSettings): boolean => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear settings cache, force next loadSettings call to re-read from file
|
||||
*/
|
||||
export const clearSettingsCache = (): void => {
|
||||
settingsCache = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current cache status (for debugging)
|
||||
*/
|
||||
export const getSettingsCacheInfo = (): { hasCache: boolean } => {
|
||||
return {
|
||||
hasCache: settingsCache !== null,
|
||||
};
|
||||
};
|
||||
|
||||
export const replaceEnvVars = (env: Record<string, any>): Record<string, any> => {
|
||||
const res: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Request, Response } from 'express';
|
||||
import config from '../config/index.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
|
||||
/**
|
||||
* Get runtime configuration for frontend
|
||||
@@ -28,3 +29,31 @@ export const getRuntimeConfig = (req: Request, res: Response): void => {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get public system configuration (only skipAuth setting)
|
||||
* This endpoint doesn't require authentication to allow checking if auth should be skipped
|
||||
*/
|
||||
export const getPublicConfig = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
const skipAuth = settings.systemConfig?.routing?.skipAuth || false;
|
||||
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.setHeader('Expires', '0');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
skipAuth,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting public config:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get public configuration',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
156
src/controllers/dxtController.ts
Normal file
156
src/controllers/dxtController.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { Request, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import AdmZip from 'adm-zip';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
|
||||
// Get the directory name in ESM
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Configure multer for file uploads
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const uploadDir = path.join(__dirname, '../../data/uploads/dxt');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const timestamp = Date.now();
|
||||
const originalName = path.parse(file.originalname).name;
|
||||
cb(null, `${originalName}-${timestamp}.dxt`);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.originalname.endsWith('.dxt')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only .dxt files are allowed'));
|
||||
}
|
||||
},
|
||||
limits: {
|
||||
fileSize: 100 * 1024 * 1024, // 100MB limit
|
||||
},
|
||||
});
|
||||
|
||||
export const uploadMiddleware = upload.single('dxtFile');
|
||||
|
||||
// Clean up old DXT server files when installing a new version
|
||||
const cleanupOldDxtServer = (serverName: string): void => {
|
||||
try {
|
||||
const uploadDir = path.join(__dirname, '../../data/uploads/dxt');
|
||||
const serverPattern = `server-${serverName}`;
|
||||
|
||||
if (fs.existsSync(uploadDir)) {
|
||||
const files = fs.readdirSync(uploadDir);
|
||||
files.forEach((file) => {
|
||||
if (file.startsWith(serverPattern)) {
|
||||
const filePath = path.join(uploadDir, file);
|
||||
if (fs.statSync(filePath).isDirectory()) {
|
||||
fs.rmSync(filePath, { recursive: true, force: true });
|
||||
console.log(`Cleaned up old DXT server directory: ${filePath}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to cleanup old DXT server files:', error);
|
||||
// Don't fail the installation if cleanup fails
|
||||
}
|
||||
};
|
||||
|
||||
export const uploadDxtFile = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'No DXT file uploaded',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const dxtFilePath = req.file.path;
|
||||
const timestamp = Date.now();
|
||||
const tempExtractDir = path.join(path.dirname(dxtFilePath), `temp-extracted-${timestamp}`);
|
||||
|
||||
try {
|
||||
// Extract the DXT file (which is a ZIP archive) to a temporary directory first
|
||||
const zip = new AdmZip(dxtFilePath);
|
||||
zip.extractAllTo(tempExtractDir, true);
|
||||
|
||||
// Read and validate the manifest.json
|
||||
const manifestPath = path.join(tempExtractDir, 'manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
throw new Error('manifest.json not found in DXT file');
|
||||
}
|
||||
|
||||
const manifestContent = fs.readFileSync(manifestPath, 'utf-8');
|
||||
const manifest = JSON.parse(manifestContent);
|
||||
|
||||
// Validate required fields in manifest
|
||||
if (!manifest.dxt_version) {
|
||||
throw new Error('Invalid manifest: missing dxt_version');
|
||||
}
|
||||
if (!manifest.name) {
|
||||
throw new Error('Invalid manifest: missing name');
|
||||
}
|
||||
if (!manifest.version) {
|
||||
throw new Error('Invalid manifest: missing version');
|
||||
}
|
||||
if (!manifest.server) {
|
||||
throw new Error('Invalid manifest: missing server configuration');
|
||||
}
|
||||
|
||||
// Use server name as the final extract directory for automatic version management
|
||||
const finalExtractDir = path.join(path.dirname(dxtFilePath), `server-${manifest.name}`);
|
||||
|
||||
// Clean up any existing version of this server
|
||||
cleanupOldDxtServer(manifest.name);
|
||||
|
||||
// Move the temporary directory to the final location
|
||||
fs.renameSync(tempExtractDir, finalExtractDir);
|
||||
console.log(`DXT server extracted to: ${finalExtractDir}`);
|
||||
|
||||
// Clean up the uploaded DXT file
|
||||
fs.unlinkSync(dxtFilePath);
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: {
|
||||
manifest,
|
||||
extractDir: finalExtractDir,
|
||||
},
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (extractError) {
|
||||
// Clean up files on error
|
||||
if (fs.existsSync(dxtFilePath)) {
|
||||
fs.unlinkSync(dxtFilePath);
|
||||
}
|
||||
if (fs.existsSync(tempExtractDir)) {
|
||||
fs.rmSync(tempExtractDir, { recursive: true, force: true });
|
||||
}
|
||||
throw extractError;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('DXT upload error:', error);
|
||||
|
||||
let message = 'Failed to process DXT file';
|
||||
if (error instanceof Error) {
|
||||
message = error.message;
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
deleteGroup,
|
||||
addServerToGroup,
|
||||
removeServerFromGroup,
|
||||
getServersInGroup
|
||||
} from '../services/groupService.js';
|
||||
|
||||
// Get all groups
|
||||
@@ -154,7 +153,7 @@ export const updateGroupServersBatch = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { servers } = req.body;
|
||||
|
||||
|
||||
if (!id) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -338,4 +337,4 @@ export const getGroupServers = (req: Request, res: Response): void => {
|
||||
message: 'Failed to get group servers',
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,9 +3,10 @@ import { ApiResponse, AddServerRequest } from '../types/index.js';
|
||||
import {
|
||||
getServersInfo,
|
||||
addServer,
|
||||
addOrUpdateServer,
|
||||
removeServer,
|
||||
updateMcpServer,
|
||||
notifyToolChanged,
|
||||
syncToolEmbedding,
|
||||
toggleServerStatus,
|
||||
} from '../services/mcpService.js';
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
@@ -62,19 +63,25 @@ export const createServer = async (req: Request, res: Response): Promise<void> =
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.url && (!config.command || !config.args)) {
|
||||
if (
|
||||
!config.url &&
|
||||
!config.openapi?.url &&
|
||||
!config.openapi?.schema &&
|
||||
(!config.command || !config.args)
|
||||
) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server configuration must include either a URL or command with arguments',
|
||||
message:
|
||||
'Server configuration must include either a URL, OpenAPI specification URL or schema, or command with arguments',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate the server type if specified
|
||||
if (config.type && !['stdio', 'sse', 'streamable-http'].includes(config.type)) {
|
||||
if (config.type && !['stdio', 'sse', 'streamable-http', 'openapi'].includes(config.type)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server type must be one of: stdio, sse, streamable-http',
|
||||
message: 'Server type must be one of: stdio, sse, streamable-http, openapi',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -88,6 +95,15 @@ export const createServer = async (req: Request, res: Response): Promise<void> =
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate that OpenAPI specification URL or schema is provided for openapi type
|
||||
if (config.type === 'openapi' && !config.openapi?.url && !config.openapi?.schema) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'OpenAPI specification URL or schema is required for openapi server type',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate headers if provided
|
||||
if (config.headers && typeof config.headers !== 'object') {
|
||||
res.status(400).json({
|
||||
@@ -97,7 +113,7 @@ export const createServer = async (req: Request, res: Response): Promise<void> =
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate that headers are only used with sse and streamable-http types
|
||||
// Validate that headers are only used with sse, streamable-http, and openapi types
|
||||
if (config.headers && config.type === 'stdio') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -106,6 +122,11 @@ export const createServer = async (req: Request, res: Response): Promise<void> =
|
||||
return;
|
||||
}
|
||||
|
||||
// Set default keep-alive interval for SSE servers if not specified
|
||||
if ((config.type === 'sse' || (!config.type && config.url)) && !config.keepAliveInterval) {
|
||||
config.keepAliveInterval = 60000; // Default 60 seconds for SSE servers
|
||||
}
|
||||
|
||||
const result = await addServer(name, config);
|
||||
if (result.success) {
|
||||
notifyToolChanged();
|
||||
@@ -179,19 +200,25 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.url && (!config.command || !config.args)) {
|
||||
if (
|
||||
!config.url &&
|
||||
!config.openapi?.url &&
|
||||
!config.openapi?.schema &&
|
||||
(!config.command || !config.args)
|
||||
) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server configuration must include either a URL or command with arguments',
|
||||
message:
|
||||
'Server configuration must include either a URL, OpenAPI specification URL or schema, or command with arguments',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate the server type if specified
|
||||
if (config.type && !['stdio', 'sse', 'streamable-http'].includes(config.type)) {
|
||||
if (config.type && !['stdio', 'sse', 'streamable-http', 'openapi'].includes(config.type)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server type must be one of: stdio, sse, streamable-http',
|
||||
message: 'Server type must be one of: stdio, sse, streamable-http, openapi',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -205,6 +232,15 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate that OpenAPI specification URL or schema is provided for openapi type
|
||||
if (config.type === 'openapi' && !config.openapi?.url && !config.openapi?.schema) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'OpenAPI specification URL or schema is required for openapi server type',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate headers if provided
|
||||
if (config.headers && typeof config.headers !== 'object') {
|
||||
res.status(400).json({
|
||||
@@ -214,7 +250,7 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate that headers are only used with sse and streamable-http types
|
||||
// Validate that headers are only used with sse, streamable-http, and openapi types
|
||||
if (config.headers && config.type === 'stdio') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -223,7 +259,12 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await updateMcpServer(name, config);
|
||||
// Set default keep-alive interval for SSE servers if not specified
|
||||
if ((config.type === 'sse' || (!config.type && config.url)) && !config.keepAliveInterval) {
|
||||
config.keepAliveInterval = 60000; // Default 60 seconds for SSE servers
|
||||
}
|
||||
|
||||
const result = await addOrUpdateServer(name, config, true); // Allow override for updates
|
||||
if (result.success) {
|
||||
notifyToolChanged();
|
||||
res.json({
|
||||
@@ -318,6 +359,136 @@ export const toggleServer = async (req: Request, res: Response): Promise<void> =
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle tool status for a specific server
|
||||
export const toggleTool = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, toolName } = req.params;
|
||||
const { enabled } = req.body;
|
||||
|
||||
if (!serverName || !toolName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server name and tool name are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof enabled !== 'boolean') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Enabled status must be a boolean',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = loadSettings();
|
||||
if (!settings.mcpServers[serverName]) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Server not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize tools config if it doesn't exist
|
||||
if (!settings.mcpServers[serverName].tools) {
|
||||
settings.mcpServers[serverName].tools = {};
|
||||
}
|
||||
|
||||
// Set the tool's enabled state
|
||||
settings.mcpServers[serverName].tools![toolName] = { enabled };
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to save settings',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Notify that tools have changed
|
||||
notifyToolChanged();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Tool ${toolName} ${enabled ? 'enabled' : 'disabled'} successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal server error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Update tool description for a specific server
|
||||
export const updateToolDescription = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, toolName } = req.params;
|
||||
const { description } = req.body;
|
||||
|
||||
if (!serverName || !toolName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server name and tool name are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof description !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Description must be a string',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = loadSettings();
|
||||
if (!settings.mcpServers[serverName]) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Server not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize tools config if it doesn't exist
|
||||
if (!settings.mcpServers[serverName].tools) {
|
||||
settings.mcpServers[serverName].tools = {};
|
||||
}
|
||||
|
||||
// Set the tool's description
|
||||
if (!settings.mcpServers[serverName].tools![toolName]) {
|
||||
settings.mcpServers[serverName].tools![toolName] = { enabled: true };
|
||||
}
|
||||
|
||||
settings.mcpServers[serverName].tools![toolName].description = description;
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to save settings',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Notify that tools have changed
|
||||
notifyToolChanged();
|
||||
|
||||
syncToolEmbedding(serverName, toolName);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Tool ${toolName} description updated successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal server error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { routing, install, smartRouting } = req.body;
|
||||
@@ -327,7 +498,8 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
(typeof routing.enableGlobalRoute !== 'boolean' &&
|
||||
typeof routing.enableGroupNameRoute !== 'boolean' &&
|
||||
typeof routing.enableBearerAuth !== 'boolean' &&
|
||||
typeof routing.bearerAuthKey !== 'string')) &&
|
||||
typeof routing.bearerAuthKey !== 'string' &&
|
||||
typeof routing.skipAuth !== 'boolean')) &&
|
||||
(!install ||
|
||||
(typeof install.pythonIndexUrl !== 'string' && typeof install.npmRegistry !== 'string')) &&
|
||||
(!smartRouting ||
|
||||
@@ -352,6 +524,7 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
skipAuth: false,
|
||||
},
|
||||
install: {
|
||||
pythonIndexUrl: '',
|
||||
@@ -373,6 +546,7 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
skipAuth: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -409,6 +583,10 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
if (typeof routing.bearerAuthKey === 'string') {
|
||||
settings.systemConfig.routing.bearerAuthKey = routing.bearerAuthKey;
|
||||
}
|
||||
|
||||
if (typeof routing.skipAuth === 'boolean') {
|
||||
settings.systemConfig.routing.skipAuth = routing.skipAuth;
|
||||
}
|
||||
}
|
||||
|
||||
if (install) {
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import 'reflect-metadata'; // Ensure reflect-metadata is imported here too
|
||||
import { DataSource, DataSourceOptions } from 'typeorm';
|
||||
import dotenv from 'dotenv';
|
||||
import dotenvExpand from 'dotenv-expand';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import entities from './entities/index.js';
|
||||
import { registerPostgresVectorType } from './types/postgresVectorType.js';
|
||||
import { VectorEmbeddingSubscriber } from './subscribers/VectorEmbeddingSubscriber.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { getSmartRoutingConfig } from '../utils/smartRouting.js';
|
||||
|
||||
// Helper function to create required PostgreSQL extensions
|
||||
const createRequiredExtensions = async (dataSource: DataSource): Promise<void> => {
|
||||
@@ -30,23 +26,7 @@ const createRequiredExtensions = async (dataSource: DataSource): Promise<void> =
|
||||
|
||||
// Get database URL from smart routing config or fallback to environment variable
|
||||
const getDatabaseUrl = (): string => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
const smartRouting = settings.systemConfig?.smartRouting;
|
||||
|
||||
// Use smart routing dbUrl if smart routing is enabled and dbUrl is configured
|
||||
if (smartRouting?.enabled && smartRouting?.dbUrl) {
|
||||
console.log('Using smart routing database URL');
|
||||
return smartRouting.dbUrl;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'Failed to load settings for smart routing database URL, falling back to environment variable:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
return '';
|
||||
return getSmartRoutingConfig().dbUrl;
|
||||
};
|
||||
|
||||
// Default database configuration
|
||||
@@ -59,7 +39,10 @@ const defaultConfig: DataSourceOptions = {
|
||||
};
|
||||
|
||||
// AppDataSource is the TypeORM data source
|
||||
let AppDataSource = new DataSource(defaultConfig);
|
||||
let appDataSource = new DataSource(defaultConfig);
|
||||
|
||||
// Global promise to track initialization status
|
||||
let initializationPromise: Promise<DataSource> | null = null;
|
||||
|
||||
// Function to create a new DataSource with updated configuration
|
||||
export const updateDataSourceConfig = (): DataSource => {
|
||||
@@ -69,31 +52,36 @@ export const updateDataSourceConfig = (): DataSource => {
|
||||
};
|
||||
|
||||
// If the configuration has changed, we need to create a new DataSource
|
||||
const currentUrl = (AppDataSource.options as any).url;
|
||||
const currentUrl = (appDataSource.options as any).url;
|
||||
if (currentUrl !== newConfig.url) {
|
||||
console.log('Database URL configuration changed, updating DataSource...');
|
||||
AppDataSource = new DataSource(newConfig);
|
||||
appDataSource = new DataSource(newConfig);
|
||||
// Reset initialization promise when configuration changes
|
||||
initializationPromise = null;
|
||||
}
|
||||
|
||||
return AppDataSource;
|
||||
return appDataSource;
|
||||
};
|
||||
|
||||
// Get the current AppDataSource instance
|
||||
export const getAppDataSource = (): DataSource => {
|
||||
return AppDataSource;
|
||||
return appDataSource;
|
||||
};
|
||||
|
||||
// Reconnect database with updated configuration
|
||||
export const reconnectDatabase = async (): Promise<DataSource> => {
|
||||
try {
|
||||
// Close existing connection if it exists
|
||||
if (AppDataSource.isInitialized) {
|
||||
if (appDataSource.isInitialized) {
|
||||
console.log('Closing existing database connection...');
|
||||
await AppDataSource.destroy();
|
||||
await appDataSource.destroy();
|
||||
}
|
||||
|
||||
// Reset initialization promise to allow fresh initialization
|
||||
initializationPromise = null;
|
||||
|
||||
// Update configuration and reconnect
|
||||
AppDataSource = updateDataSourceConfig();
|
||||
appDataSource = updateDataSourceConfig();
|
||||
return await initializeDatabase();
|
||||
} catch (error) {
|
||||
console.error('Error during database reconnection:', error);
|
||||
@@ -101,26 +89,54 @@ export const reconnectDatabase = async (): Promise<DataSource> => {
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize database connection
|
||||
// Initialize database connection with concurrency control
|
||||
export const initializeDatabase = async (): Promise<DataSource> => {
|
||||
// If initialization is already in progress, wait for it to complete
|
||||
if (initializationPromise) {
|
||||
console.log('Database initialization already in progress, waiting for completion...');
|
||||
return initializationPromise;
|
||||
}
|
||||
|
||||
// If already initialized, return the existing instance
|
||||
if (appDataSource.isInitialized) {
|
||||
console.log('Database already initialized, returning existing instance');
|
||||
return Promise.resolve(appDataSource);
|
||||
}
|
||||
|
||||
// Create a new initialization promise
|
||||
initializationPromise = performDatabaseInitialization();
|
||||
|
||||
try {
|
||||
const result = await initializationPromise;
|
||||
console.log('Database initialization completed successfully');
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Reset the promise on error so initialization can be retried
|
||||
initializationPromise = null;
|
||||
console.error('Database initialization failed:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Internal function to perform the actual database initialization
|
||||
const performDatabaseInitialization = async (): Promise<DataSource> => {
|
||||
try {
|
||||
// Update configuration before initializing
|
||||
AppDataSource = updateDataSourceConfig();
|
||||
appDataSource = updateDataSourceConfig();
|
||||
|
||||
if (!AppDataSource.isInitialized) {
|
||||
if (!appDataSource.isInitialized) {
|
||||
console.log('Initializing database connection...');
|
||||
// Register the vector type with TypeORM
|
||||
await AppDataSource.initialize();
|
||||
registerPostgresVectorType(AppDataSource);
|
||||
await appDataSource.initialize();
|
||||
registerPostgresVectorType(appDataSource);
|
||||
|
||||
// Create required PostgreSQL extensions
|
||||
await createRequiredExtensions(AppDataSource);
|
||||
await createRequiredExtensions(appDataSource);
|
||||
|
||||
// Set up vector column and index with a more direct approach
|
||||
try {
|
||||
|
||||
// Check if table exists first
|
||||
const tableExists = await AppDataSource.query(`
|
||||
const tableExists = await appDataSource.query(`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
@@ -134,7 +150,7 @@ export const initializeDatabase = async (): Promise<DataSource> => {
|
||||
|
||||
// Step 1: Drop any existing index on the column
|
||||
try {
|
||||
await AppDataSource.query(`DROP INDEX IF EXISTS idx_vector_embeddings_embedding;`);
|
||||
await appDataSource.query(`DROP INDEX IF EXISTS idx_vector_embeddings_embedding;`);
|
||||
} catch (dropError: any) {
|
||||
console.warn('Note: Could not drop existing index:', dropError.message);
|
||||
}
|
||||
@@ -142,14 +158,14 @@ export const initializeDatabase = async (): Promise<DataSource> => {
|
||||
// Step 2: Alter column type to vector (if it's not already)
|
||||
try {
|
||||
// Check column type first
|
||||
const columnType = await AppDataSource.query(`
|
||||
const columnType = await appDataSource.query(`
|
||||
SELECT data_type FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'vector_embeddings'
|
||||
AND column_name = 'embedding';
|
||||
`);
|
||||
|
||||
if (columnType.length > 0 && columnType[0].data_type !== 'vector') {
|
||||
await AppDataSource.query(`
|
||||
await appDataSource.query(`
|
||||
ALTER TABLE vector_embeddings
|
||||
ALTER COLUMN embedding TYPE vector USING embedding::vector;
|
||||
`);
|
||||
@@ -163,7 +179,7 @@ export const initializeDatabase = async (): Promise<DataSource> => {
|
||||
// Step 3: Try to create appropriate indices
|
||||
try {
|
||||
// First, let's check if there are any records to determine the dimensions
|
||||
const records = await AppDataSource.query(`
|
||||
const records = await appDataSource.query(`
|
||||
SELECT dimensions FROM vector_embeddings LIMIT 1;
|
||||
`);
|
||||
|
||||
@@ -177,13 +193,13 @@ export const initializeDatabase = async (): Promise<DataSource> => {
|
||||
|
||||
// Set the vector dimensions explicitly only if table has data
|
||||
if (records && records.length > 0) {
|
||||
await AppDataSource.query(`
|
||||
await appDataSource.query(`
|
||||
ALTER TABLE vector_embeddings
|
||||
ALTER COLUMN embedding TYPE vector(${dimensions});
|
||||
`);
|
||||
|
||||
// Now try to create the index
|
||||
await AppDataSource.query(`
|
||||
await appDataSource.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_vector_embeddings_embedding
|
||||
ON vector_embeddings USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
|
||||
`);
|
||||
@@ -199,7 +215,7 @@ export const initializeDatabase = async (): Promise<DataSource> => {
|
||||
|
||||
try {
|
||||
// Try HNSW index instead
|
||||
await AppDataSource.query(`
|
||||
await appDataSource.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_vector_embeddings_embedding
|
||||
ON vector_embeddings USING hnsw (embedding vector_cosine_ops);
|
||||
`);
|
||||
@@ -210,7 +226,7 @@ export const initializeDatabase = async (): Promise<DataSource> => {
|
||||
|
||||
try {
|
||||
// Create a basic GIN index as last resort
|
||||
await AppDataSource.query(`
|
||||
await appDataSource.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_vector_embeddings_embedding
|
||||
ON vector_embeddings USING gin (embedding);
|
||||
`);
|
||||
@@ -235,12 +251,11 @@ export const initializeDatabase = async (): Promise<DataSource> => {
|
||||
|
||||
// Run one final setup check after schema synchronization is done
|
||||
if (defaultConfig.synchronize) {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
console.log('Running final vector configuration check...');
|
||||
try {
|
||||
console.log('Running final vector configuration check...');
|
||||
|
||||
// Try setup again with the same code from above
|
||||
const tableExists = await AppDataSource.query(`
|
||||
// Try setup again with the same code from above
|
||||
const tableExists = await appDataSource.query(`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
@@ -248,64 +263,60 @@ export const initializeDatabase = async (): Promise<DataSource> => {
|
||||
);
|
||||
`);
|
||||
|
||||
if (tableExists[0].exists) {
|
||||
console.log('Vector embeddings table found, checking configuration...');
|
||||
if (tableExists[0].exists) {
|
||||
console.log('Vector embeddings table found, checking configuration...');
|
||||
|
||||
// Get the dimension size first
|
||||
try {
|
||||
// Try to get dimensions from an existing record
|
||||
const records = await AppDataSource.query(`
|
||||
// Get the dimension size first
|
||||
try {
|
||||
// Try to get dimensions from an existing record
|
||||
const records = await appDataSource.query(`
|
||||
SELECT dimensions FROM vector_embeddings LIMIT 1;
|
||||
`);
|
||||
|
||||
// Only proceed if we have existing data, otherwise let vector service handle it
|
||||
if (records && records.length > 0 && records[0].dimensions) {
|
||||
const dimensions = records[0].dimensions;
|
||||
console.log(`Found vector dimension from database: ${dimensions}`);
|
||||
// Only proceed if we have existing data, otherwise let vector service handle it
|
||||
if (records && records.length > 0 && records[0].dimensions) {
|
||||
const dimensions = records[0].dimensions;
|
||||
console.log(`Found vector dimension from database: ${dimensions}`);
|
||||
|
||||
// Ensure column type is vector with explicit dimensions
|
||||
await AppDataSource.query(`
|
||||
// Ensure column type is vector with explicit dimensions
|
||||
await appDataSource.query(`
|
||||
ALTER TABLE vector_embeddings
|
||||
ALTER COLUMN embedding TYPE vector(${dimensions});
|
||||
`);
|
||||
console.log('Vector embedding column type updated in final check.');
|
||||
console.log('Vector embedding column type updated in final check.');
|
||||
|
||||
// One more attempt at creating the index with dimensions
|
||||
try {
|
||||
// Drop existing index if any
|
||||
await AppDataSource.query(`
|
||||
// One more attempt at creating the index with dimensions
|
||||
try {
|
||||
// Drop existing index if any
|
||||
await appDataSource.query(`
|
||||
DROP INDEX IF EXISTS idx_vector_embeddings_embedding;
|
||||
`);
|
||||
|
||||
// Create new index with proper dimensions
|
||||
await AppDataSource.query(`
|
||||
// Create new index with proper dimensions
|
||||
await appDataSource.query(`
|
||||
CREATE INDEX idx_vector_embeddings_embedding
|
||||
ON vector_embeddings USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
|
||||
`);
|
||||
console.log('Created IVFFlat index in final check.');
|
||||
} catch (indexError: any) {
|
||||
console.warn(
|
||||
'Final index creation attempt did not succeed:',
|
||||
indexError.message,
|
||||
);
|
||||
console.warn('Using basic lookup without vector index.');
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
'No existing vector data found, vector dimensions will be configured by vector service.',
|
||||
);
|
||||
console.log('Created IVFFlat index in final check.');
|
||||
} catch (indexError: any) {
|
||||
console.warn('Final index creation attempt did not succeed:', indexError.message);
|
||||
console.warn('Using basic lookup without vector index.');
|
||||
}
|
||||
} catch (setupError: any) {
|
||||
console.warn('Vector setup in final check failed:', setupError.message);
|
||||
} else {
|
||||
console.log(
|
||||
'No existing vector data found, vector dimensions will be configured by vector service.',
|
||||
);
|
||||
}
|
||||
} catch (setupError: any) {
|
||||
console.warn('Vector setup in final check failed:', setupError.message);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.warn('Post-initialization vector setup failed:', error.message);
|
||||
}
|
||||
}, 3000); // Give synchronize some time to complete
|
||||
} catch (error: any) {
|
||||
console.warn('Post-initialization vector setup failed:', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
return AppDataSource;
|
||||
return appDataSource;
|
||||
} catch (error) {
|
||||
console.error('Error during database initialization:', error);
|
||||
throw error;
|
||||
@@ -314,18 +325,18 @@ export const initializeDatabase = async (): Promise<DataSource> => {
|
||||
|
||||
// Get database connection status
|
||||
export const isDatabaseConnected = (): boolean => {
|
||||
return AppDataSource.isInitialized;
|
||||
return appDataSource.isInitialized;
|
||||
};
|
||||
|
||||
// Close database connection
|
||||
export const closeDatabase = async (): Promise<void> => {
|
||||
if (AppDataSource.isInitialized) {
|
||||
await AppDataSource.destroy();
|
||||
if (appDataSource.isInitialized) {
|
||||
await appDataSource.destroy();
|
||||
console.log('Database connection closed.');
|
||||
}
|
||||
};
|
||||
|
||||
// Export AppDataSource for backward compatibility
|
||||
export { AppDataSource };
|
||||
export const AppDataSource = appDataSource;
|
||||
|
||||
export default getAppDataSource;
|
||||
|
||||
@@ -1,11 +1,45 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
|
||||
// Default secret key - in production, use an environment variable
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this';
|
||||
|
||||
const validateBearerAuth = (req: Request, routingConfig: any): boolean => {
|
||||
if (!routingConfig.enableBearerAuth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return authHeader.substring(7) === routingConfig.bearerAuthKey;
|
||||
};
|
||||
|
||||
// Middleware to authenticate JWT token
|
||||
export const auth = (req: Request, res: Response, next: NextFunction): void => {
|
||||
// Check if authentication is disabled globally
|
||||
const routingConfig = loadSettings().systemConfig?.routing || {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
skipAuth: false,
|
||||
};
|
||||
|
||||
if (routingConfig.skipAuth) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if bearer auth is enabled and validate it
|
||||
if (validateBearerAuth(req, routingConfig)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get token from header or query parameter
|
||||
const headerToken = req.header('x-auth-token');
|
||||
const queryToken = req.query.token as string;
|
||||
@@ -20,11 +54,11 @@ export const auth = (req: Request, res: Response, next: NextFunction): void => {
|
||||
// Verify token
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
|
||||
// Add user from payload to request
|
||||
(req as any).user = (decoded as any).user;
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(401).json({ success: false, message: 'Token is not valid' });
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,37 +1,8 @@
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
import fs from 'fs';
|
||||
import { auth } from './auth.js';
|
||||
import { initializeDefaultUser } from '../models/User.js';
|
||||
import config from '../config/index.js';
|
||||
|
||||
// Create __dirname equivalent for ES modules
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Try to find the correct frontend file path
|
||||
const findFrontendPath = (): string => {
|
||||
// First try development environment path
|
||||
const devPath = path.join(dirname(__dirname), 'frontend', 'dist', 'index.html');
|
||||
if (fs.existsSync(devPath)) {
|
||||
return path.join(dirname(__dirname), 'frontend', 'dist');
|
||||
}
|
||||
|
||||
// Try npm/npx installed path (remove /dist directory)
|
||||
const npmPath = path.join(dirname(dirname(__dirname)), 'frontend', 'dist', 'index.html');
|
||||
if (fs.existsSync(npmPath)) {
|
||||
return path.join(dirname(dirname(__dirname)), 'frontend', 'dist');
|
||||
}
|
||||
|
||||
// If none of the above paths exist, return the most reasonable default path and log a warning
|
||||
console.warn('Warning: Could not locate frontend files. Using default path.');
|
||||
return path.join(dirname(__dirname), 'frontend', 'dist');
|
||||
};
|
||||
|
||||
const frontendPath = findFrontendPath();
|
||||
|
||||
export const errorHandler = (
|
||||
err: Error,
|
||||
_req: Request,
|
||||
@@ -52,6 +23,7 @@ export const initMiddlewares = (app: express.Application): void => {
|
||||
app.use((req, res, next) => {
|
||||
const basePath = config.basePath;
|
||||
// Only apply JSON parsing for API and auth routes, not for SSE or message endpoints
|
||||
// TODO exclude sse responses by mcp endpoint
|
||||
if (
|
||||
req.path !== `${basePath}/sse` &&
|
||||
!req.path.startsWith(`${basePath}/sse/`) &&
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { IUser, McpSettings } from '../types/index.js';
|
||||
import { IUser } from '../types/index.js';
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
|
||||
// Get all users
|
||||
@@ -29,38 +27,38 @@ const saveUsers = (users: IUser[]): void => {
|
||||
// Create a new user
|
||||
export const createUser = async (userData: IUser): Promise<IUser | null> => {
|
||||
const users = getUsers();
|
||||
|
||||
|
||||
// Check if username already exists
|
||||
if (users.some(user => user.username === userData.username)) {
|
||||
if (users.some((user) => user.username === userData.username)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Hash the password
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
const hashedPassword = await bcrypt.hash(userData.password, salt);
|
||||
|
||||
|
||||
const newUser = {
|
||||
username: userData.username,
|
||||
password: hashedPassword,
|
||||
isAdmin: userData.isAdmin || false
|
||||
isAdmin: userData.isAdmin || false,
|
||||
};
|
||||
|
||||
|
||||
users.push(newUser);
|
||||
saveUsers(users);
|
||||
|
||||
|
||||
return newUser;
|
||||
};
|
||||
|
||||
// Find user by username
|
||||
export const findUserByUsername = (username: string): IUser | undefined => {
|
||||
const users = getUsers();
|
||||
return users.find(user => user.username === username);
|
||||
return users.find((user) => user.username === username);
|
||||
};
|
||||
|
||||
// Verify user password
|
||||
export const verifyPassword = async (
|
||||
plainPassword: string,
|
||||
hashedPassword: string
|
||||
plainPassword: string,
|
||||
hashedPassword: string,
|
||||
): Promise<boolean> => {
|
||||
return await bcrypt.compare(plainPassword, hashedPassword);
|
||||
};
|
||||
@@ -68,36 +66,36 @@ export const verifyPassword = async (
|
||||
// Update user password
|
||||
export const updateUserPassword = async (
|
||||
username: string,
|
||||
newPassword: string
|
||||
newPassword: string,
|
||||
): Promise<boolean> => {
|
||||
const users = getUsers();
|
||||
const userIndex = users.findIndex(user => user.username === username);
|
||||
|
||||
const userIndex = users.findIndex((user) => user.username === username);
|
||||
|
||||
if (userIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Hash the new password
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
const hashedPassword = await bcrypt.hash(newPassword, salt);
|
||||
|
||||
|
||||
// Update the user's password
|
||||
users[userIndex].password = hashedPassword;
|
||||
saveUsers(users);
|
||||
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Initialize with default admin user if no users exist
|
||||
export const initializeDefaultUser = async (): Promise<void> => {
|
||||
const users = getUsers();
|
||||
|
||||
|
||||
if (users.length === 0) {
|
||||
await createUser({
|
||||
username: 'admin',
|
||||
password: 'admin123',
|
||||
isAdmin: true
|
||||
isAdmin: true,
|
||||
});
|
||||
console.log('Default admin user created');
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
updateServer,
|
||||
deleteServer,
|
||||
toggleServer,
|
||||
toggleTool,
|
||||
updateToolDescription,
|
||||
updateSystemConfig,
|
||||
} from '../controllers/serverController.js';
|
||||
import {
|
||||
@@ -32,8 +34,9 @@ import {
|
||||
} from '../controllers/marketController.js';
|
||||
import { login, register, getCurrentUser, changePassword } from '../controllers/authController.js';
|
||||
import { getAllLogs, clearLogs, streamLogs } from '../controllers/logController.js';
|
||||
import { getRuntimeConfig } from '../controllers/configController.js';
|
||||
import { getRuntimeConfig, getPublicConfig } from '../controllers/configController.js';
|
||||
import { callTool } from '../controllers/toolController.js';
|
||||
import { uploadDxtFile, uploadMiddleware } from '../controllers/dxtController.js';
|
||||
import { auth } from '../middlewares/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -46,6 +49,8 @@ export const initRoutes = (app: express.Application): void => {
|
||||
router.put('/servers/:name', updateServer);
|
||||
router.delete('/servers/:name', deleteServer);
|
||||
router.post('/servers/:name/toggle', toggleServer);
|
||||
router.post('/servers/:serverName/tools/:toolName/toggle', toggleTool);
|
||||
router.put('/servers/:serverName/tools/:toolName/description', updateToolDescription);
|
||||
router.put('/system-config', updateSystemConfig);
|
||||
|
||||
// Group management routes
|
||||
@@ -63,6 +68,9 @@ export const initRoutes = (app: express.Application): void => {
|
||||
// Tool management routes
|
||||
router.post('/tools/call/:server', callTool);
|
||||
|
||||
// DXT upload routes
|
||||
router.post('/dxt/upload', uploadMiddleware, uploadDxtFile);
|
||||
|
||||
// Market routes
|
||||
router.get('/market/servers', getAllMarketServers);
|
||||
router.get('/market/servers/search', searchMarketServersByQuery);
|
||||
@@ -112,6 +120,9 @@ export const initRoutes = (app: express.Application): void => {
|
||||
// Runtime configuration endpoint (no auth required for frontend initialization)
|
||||
app.get(`${config.basePath}/config`, getRuntimeConfig);
|
||||
|
||||
// Public configuration endpoint (no auth required to check skipAuth setting)
|
||||
app.get(`${config.basePath}/public-config`, getPublicConfig);
|
||||
|
||||
app.use(`${config.basePath}/api`, router);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { IGroup, McpSettings } from '../types/index.js';
|
||||
import { IGroup } from '../types/index.js';
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
import { notifyToolChanged } from './mcpService.js';
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// filepath: /Users/sunmeng/code/github/mcphub/src/services/logService.ts
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as os from 'os';
|
||||
import * as process from 'process';
|
||||
@@ -157,7 +156,7 @@ class LogService {
|
||||
|
||||
if (sourcePidMatch) {
|
||||
// If we have a 'source-processId' format in the second bracket
|
||||
const [_, source, extractedProcessId] = sourcePidMatch;
|
||||
const [_, source, _extractedProcessId] = sourcePidMatch;
|
||||
return {
|
||||
text: remainingText.trim(),
|
||||
source: source.trim(),
|
||||
|
||||
@@ -4,15 +4,48 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { ServerInfo, ServerConfig } from '../types/index.js';
|
||||
import { ServerInfo, ServerConfig, ToolInfo } from '../types/index.js';
|
||||
import { loadSettings, saveSettings, expandEnvVars, replaceEnvVars } from '../config/index.js';
|
||||
import config from '../config/index.js';
|
||||
import { getGroup } from './sseService.js';
|
||||
import { getServersInGroup } from './groupService.js';
|
||||
import { saveToolsAsVectorEmbeddings, searchToolsByVector } from './vectorSearchService.js';
|
||||
import { OpenAPIClient } from '../clients/openapi.js';
|
||||
|
||||
const servers: { [sessionId: string]: Server } = {};
|
||||
|
||||
// Helper function to set up keep-alive ping for SSE connections
|
||||
const setupKeepAlive = (serverInfo: ServerInfo, serverConfig: ServerConfig): void => {
|
||||
// Only set up keep-alive for SSE connections
|
||||
if (!(serverInfo.transport instanceof SSEClientTransport)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any existing interval first
|
||||
if (serverInfo.keepAliveIntervalId) {
|
||||
clearInterval(serverInfo.keepAliveIntervalId);
|
||||
}
|
||||
|
||||
// Use configured interval or default to 60 seconds for SSE
|
||||
const interval = serverConfig.keepAliveInterval || 60000;
|
||||
|
||||
serverInfo.keepAliveIntervalId = setInterval(async () => {
|
||||
try {
|
||||
if (serverInfo.client && serverInfo.status === 'connected') {
|
||||
await serverInfo.client.ping();
|
||||
console.log(`Keep-alive ping successful for server: ${serverInfo.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Keep-alive ping failed for server ${serverInfo.name}:`, error);
|
||||
// TODO Consider handling reconnection logic here if needed
|
||||
}
|
||||
}, interval);
|
||||
|
||||
console.log(
|
||||
`Keep-alive ping set up for server ${serverInfo.name} with interval ${interval / 1000} seconds`,
|
||||
);
|
||||
};
|
||||
|
||||
export const initUpstreamServers = async (): Promise<void> => {
|
||||
await registerAllTools(true);
|
||||
};
|
||||
@@ -50,11 +83,207 @@ export const notifyToolChanged = async () => {
|
||||
});
|
||||
};
|
||||
|
||||
export const syncToolEmbedding = async (serverName: string, toolName: string) => {
|
||||
const serverInfo = getServerByName(serverName);
|
||||
if (!serverInfo) {
|
||||
console.warn(`Server not found: ${serverName}`);
|
||||
return;
|
||||
}
|
||||
const tool = serverInfo.tools.find((t) => t.name === toolName);
|
||||
if (!tool) {
|
||||
console.warn(`Tool not found: ${toolName} on server: ${serverName}`);
|
||||
return;
|
||||
}
|
||||
// Save tool as vector embedding for search
|
||||
saveToolsAsVectorEmbeddings(serverName, [tool]);
|
||||
};
|
||||
|
||||
// Store all server information
|
||||
let serverInfos: ServerInfo[] = [];
|
||||
|
||||
// Helper function to create transport based on server configuration
|
||||
const createTransportFromConfig = (name: string, conf: ServerConfig): any => {
|
||||
let transport;
|
||||
|
||||
if (conf.type === 'streamable-http') {
|
||||
const options: any = {};
|
||||
if (conf.headers && Object.keys(conf.headers).length > 0) {
|
||||
options.requestInit = {
|
||||
headers: conf.headers,
|
||||
};
|
||||
}
|
||||
transport = new StreamableHTTPClientTransport(new URL(conf.url || ''), options);
|
||||
} else if (conf.url) {
|
||||
// SSE transport
|
||||
const options: any = {};
|
||||
if (conf.headers && Object.keys(conf.headers).length > 0) {
|
||||
options.eventSourceInit = {
|
||||
headers: conf.headers,
|
||||
};
|
||||
options.requestInit = {
|
||||
headers: conf.headers,
|
||||
};
|
||||
}
|
||||
transport = new SSEClientTransport(new URL(conf.url), options);
|
||||
} else if (conf.command && conf.args) {
|
||||
// Stdio transport
|
||||
const env: Record<string, string> = {
|
||||
...(process.env as Record<string, string>),
|
||||
...replaceEnvVars(conf.env || {}),
|
||||
};
|
||||
env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
|
||||
|
||||
const settings = loadSettings();
|
||||
// Add UV_DEFAULT_INDEX and npm_config_registry if needed
|
||||
if (
|
||||
settings.systemConfig?.install?.pythonIndexUrl &&
|
||||
(conf.command === 'uvx' || conf.command === 'uv' || conf.command === 'python')
|
||||
) {
|
||||
env['UV_DEFAULT_INDEX'] = settings.systemConfig.install.pythonIndexUrl;
|
||||
}
|
||||
|
||||
if (
|
||||
settings.systemConfig?.install?.npmRegistry &&
|
||||
(conf.command === 'npm' ||
|
||||
conf.command === 'npx' ||
|
||||
conf.command === 'pnpm' ||
|
||||
conf.command === 'yarn' ||
|
||||
conf.command === 'node')
|
||||
) {
|
||||
env['npm_config_registry'] = settings.systemConfig.install.npmRegistry;
|
||||
}
|
||||
|
||||
transport = new StdioClientTransport({
|
||||
command: conf.command,
|
||||
args: conf.args,
|
||||
env: env,
|
||||
stderr: 'pipe',
|
||||
});
|
||||
transport.stderr?.on('data', (data) => {
|
||||
console.log(`[${name}] [child] ${data}`);
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Unable to create transport for server: ${name}`);
|
||||
}
|
||||
|
||||
return transport;
|
||||
};
|
||||
|
||||
// Helper function to handle client.callTool with reconnection logic
|
||||
const callToolWithReconnect = async (
|
||||
serverInfo: ServerInfo,
|
||||
toolParams: any,
|
||||
options?: any,
|
||||
maxRetries: number = 1,
|
||||
): Promise<any> => {
|
||||
if (!serverInfo.client) {
|
||||
throw new Error(`Client not found for server: ${serverInfo.name}`);
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const result = await serverInfo.client.callTool(toolParams, undefined, options || {});
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
// Check if error message starts with "Error POSTing to endpoint (HTTP 40"
|
||||
const isHttp40xError = error?.message?.startsWith?.('Error POSTing to endpoint (HTTP 40');
|
||||
// Only retry for StreamableHTTPClientTransport
|
||||
const isStreamableHttp = serverInfo.transport instanceof StreamableHTTPClientTransport;
|
||||
|
||||
if (isHttp40xError && attempt < maxRetries && serverInfo.transport && isStreamableHttp) {
|
||||
console.warn(
|
||||
`HTTP 40x error detected for StreamableHTTP server ${serverInfo.name}, attempting reconnection (attempt ${attempt + 1}/${maxRetries + 1})`,
|
||||
);
|
||||
|
||||
try {
|
||||
// Close existing connection
|
||||
if (serverInfo.keepAliveIntervalId) {
|
||||
clearInterval(serverInfo.keepAliveIntervalId);
|
||||
serverInfo.keepAliveIntervalId = undefined;
|
||||
}
|
||||
|
||||
serverInfo.client.close();
|
||||
serverInfo.transport.close();
|
||||
|
||||
// Get server configuration to recreate transport
|
||||
const settings = loadSettings();
|
||||
const conf = settings.mcpServers[serverInfo.name];
|
||||
if (!conf) {
|
||||
throw new Error(`Server configuration not found for: ${serverInfo.name}`);
|
||||
}
|
||||
|
||||
// Recreate transport using helper function
|
||||
const newTransport = createTransportFromConfig(serverInfo.name, conf);
|
||||
|
||||
// Create new client
|
||||
const client = new Client(
|
||||
{
|
||||
name: `mcp-client-${serverInfo.name}`,
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
prompts: {},
|
||||
resources: {},
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Reconnect with new transport
|
||||
await client.connect(newTransport, serverInfo.options || {});
|
||||
|
||||
// Update server info with new client and transport
|
||||
serverInfo.client = client;
|
||||
serverInfo.transport = newTransport;
|
||||
serverInfo.status = 'connected';
|
||||
|
||||
// Reload tools list after reconnection
|
||||
try {
|
||||
const tools = await client.listTools({}, serverInfo.options || {});
|
||||
serverInfo.tools = tools.tools.map((tool) => ({
|
||||
name: `${serverInfo.name}-${tool.name}`,
|
||||
description: tool.description || '',
|
||||
inputSchema: tool.inputSchema || {},
|
||||
}));
|
||||
|
||||
// Save tools as vector embeddings for search
|
||||
saveToolsAsVectorEmbeddings(serverInfo.name, serverInfo.tools);
|
||||
} catch (listToolsError) {
|
||||
console.warn(
|
||||
`Failed to reload tools after reconnection for server ${serverInfo.name}:`,
|
||||
listToolsError,
|
||||
);
|
||||
// Continue anyway, as the connection might still work for the current tool
|
||||
}
|
||||
|
||||
console.log(`Successfully reconnected to server: ${serverInfo.name}`);
|
||||
|
||||
// Continue to next attempt
|
||||
continue;
|
||||
} catch (reconnectError) {
|
||||
console.error(`Failed to reconnect to server ${serverInfo.name}:`, reconnectError);
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = `Failed to reconnect: ${reconnectError}`;
|
||||
|
||||
// If this was the last attempt, throw the original error
|
||||
if (attempt === maxRetries) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Not an HTTP 40x error or no more retries, throw the original error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This should not be reached, but just in case
|
||||
throw new Error('Unexpected error in callToolWithReconnect');
|
||||
};
|
||||
|
||||
// Initialize MCP server clients
|
||||
export const initializeClientsFromSettings = (isInit: boolean): ServerInfo[] => {
|
||||
export const initializeClientsFromSettings = async (isInit: boolean): Promise<ServerInfo[]> => {
|
||||
const settings = loadSettings();
|
||||
const existingServerInfos = serverInfos;
|
||||
serverInfos = [];
|
||||
@@ -88,74 +317,74 @@ export const initializeClientsFromSettings = (isInit: boolean): ServerInfo[] =>
|
||||
}
|
||||
|
||||
let transport;
|
||||
if (conf.type === 'streamable-http') {
|
||||
const options: any = {};
|
||||
if (conf.headers && Object.keys(conf.headers).length > 0) {
|
||||
options.requestInit = {
|
||||
headers: conf.headers,
|
||||
};
|
||||
}
|
||||
transport = new StreamableHTTPClientTransport(new URL(conf.url || ''), options);
|
||||
} else if (conf.url) {
|
||||
// Default to SSE only when 'conf.type' is not specified and 'conf.url' is available
|
||||
const options: any = {};
|
||||
if (conf.headers && Object.keys(conf.headers).length > 0) {
|
||||
options.eventSourceInit = {
|
||||
headers: conf.headers,
|
||||
};
|
||||
options.requestInit = {
|
||||
headers: conf.headers,
|
||||
};
|
||||
}
|
||||
transport = new SSEClientTransport(new URL(conf.url), options);
|
||||
} else if (conf.command && conf.args) {
|
||||
// If type is stdio or if command and args are provided without type
|
||||
const env: Record<string, string> = {
|
||||
...(process.env as Record<string, string>), // Inherit all environment variables from parent process
|
||||
...replaceEnvVars(conf.env || {}), // Override with configured env vars
|
||||
};
|
||||
env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
|
||||
let openApiClient;
|
||||
|
||||
// Add UV_DEFAULT_INDEX from settings if available (for Python packages)
|
||||
const settings = loadSettings(); // Add UV_DEFAULT_INDEX from settings if available (for Python packages)
|
||||
if (
|
||||
settings.systemConfig?.install?.pythonIndexUrl &&
|
||||
(conf.command === 'uvx' || conf.command === 'uv' || conf.command === 'python')
|
||||
) {
|
||||
env['UV_DEFAULT_INDEX'] = settings.systemConfig.install.pythonIndexUrl;
|
||||
if (conf.type === 'openapi') {
|
||||
// Handle OpenAPI type servers
|
||||
if (!conf.openapi?.url && !conf.openapi?.schema) {
|
||||
console.warn(
|
||||
`Skipping OpenAPI server '${name}': missing OpenAPI specification URL or schema`,
|
||||
);
|
||||
serverInfos.push({
|
||||
name,
|
||||
status: 'disconnected',
|
||||
error: 'Missing OpenAPI specification URL or schema',
|
||||
tools: [],
|
||||
createTime: Date.now(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add npm_config_registry from settings if available (for NPM packages)
|
||||
if (
|
||||
settings.systemConfig?.install?.npmRegistry &&
|
||||
(conf.command === 'npm' ||
|
||||
conf.command === 'npx' ||
|
||||
conf.command === 'pnpm' ||
|
||||
conf.command === 'yarn' ||
|
||||
conf.command === 'node')
|
||||
) {
|
||||
env['npm_config_registry'] = settings.systemConfig.install.npmRegistry;
|
||||
}
|
||||
|
||||
transport = new StdioClientTransport({
|
||||
command: conf.command,
|
||||
args: conf.args,
|
||||
env: env,
|
||||
stderr: 'pipe',
|
||||
});
|
||||
transport.stderr?.on('data', (data) => {
|
||||
console.log(`[${name}] [child] ${data}`);
|
||||
});
|
||||
} else {
|
||||
console.warn(`Skipping server '${name}': missing required configuration`);
|
||||
serverInfos.push({
|
||||
// Create server info first and keep reference to it
|
||||
const serverInfo: ServerInfo = {
|
||||
name,
|
||||
status: 'disconnected',
|
||||
error: 'Missing required configuration',
|
||||
status: 'connecting',
|
||||
error: null,
|
||||
tools: [],
|
||||
createTime: Date.now(),
|
||||
});
|
||||
continue;
|
||||
enabled: conf.enabled === undefined ? true : conf.enabled,
|
||||
};
|
||||
serverInfos.push(serverInfo);
|
||||
|
||||
try {
|
||||
// Create OpenAPI client instance
|
||||
openApiClient = new OpenAPIClient(conf);
|
||||
|
||||
console.log(`Initializing OpenAPI server: ${name}...`);
|
||||
|
||||
// Perform async initialization
|
||||
await openApiClient.initialize();
|
||||
|
||||
// Convert OpenAPI tools to MCP tool format
|
||||
const openApiTools = openApiClient.getTools();
|
||||
const mcpTools: ToolInfo[] = openApiTools.map((tool) => ({
|
||||
name: `${name}-${tool.name}`,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
}));
|
||||
|
||||
// Update server info with successful initialization
|
||||
serverInfo.status = 'connected';
|
||||
serverInfo.tools = mcpTools;
|
||||
serverInfo.openApiClient = openApiClient;
|
||||
|
||||
console.log(
|
||||
`Successfully initialized OpenAPI server: ${name} with ${mcpTools.length} tools`,
|
||||
);
|
||||
|
||||
// Save tools as vector embeddings for search
|
||||
saveToolsAsVectorEmbeddings(name, mcpTools);
|
||||
continue;
|
||||
} catch (error) {
|
||||
console.error(`Failed to initialize OpenAPI server ${name}:`, error);
|
||||
|
||||
// Update the already pushed server info with error status
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = `Failed to initialize OpenAPI server: ${error}`;
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
transport = createTransportFromConfig(name, conf);
|
||||
}
|
||||
|
||||
const client = new Client(
|
||||
@@ -171,76 +400,72 @@ export const initializeClientsFromSettings = (isInit: boolean): ServerInfo[] =>
|
||||
},
|
||||
},
|
||||
);
|
||||
const timeout = isInit ? Number(config.initTimeout) : Number(config.timeout);
|
||||
client
|
||||
.connect(transport, { timeout: timeout })
|
||||
.then(() => {
|
||||
console.log(`Successfully connected client for server: ${name}`);
|
||||
|
||||
client
|
||||
.listTools({}, { timeout: timeout })
|
||||
.then((tools) => {
|
||||
console.log(`Successfully listed ${tools.tools.length} tools for server: ${name}`);
|
||||
const serverInfo = getServerByName(name);
|
||||
if (!serverInfo) {
|
||||
console.warn(`Server info not found for server: ${name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
serverInfo.tools = tools.tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description || '',
|
||||
inputSchema: tool.inputSchema || {},
|
||||
}));
|
||||
serverInfo.status = 'connected';
|
||||
serverInfo.error = null;
|
||||
|
||||
// Save tools as vector embeddings for search (only when smart routing is enabled)
|
||||
if (serverInfo.tools.length > 0) {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
const smartRoutingEnabled = settings.systemConfig?.smartRouting?.enabled || false;
|
||||
if (smartRoutingEnabled) {
|
||||
console.log(
|
||||
`Smart routing enabled - saving vector embeddings for server ${name}`,
|
||||
);
|
||||
saveToolsAsVectorEmbeddings(name, serverInfo.tools);
|
||||
}
|
||||
} catch (vectorError) {
|
||||
console.warn(`Failed to save vector embeddings for server ${name}:`, vectorError);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to list tools for server ${name} by error: ${error} with stack: ${error.stack}`,
|
||||
);
|
||||
const serverInfo = getServerByName(name);
|
||||
if (serverInfo) {
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = `Failed to list tools: ${error.stack} `;
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to connect client for server ${name} by error: ${error} with stack: ${error.stack}`,
|
||||
);
|
||||
const serverInfo = getServerByName(name);
|
||||
if (serverInfo) {
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = `Failed to connect: ${error.stack} `;
|
||||
const initRequestOptions = isInit
|
||||
? {
|
||||
timeout: Number(config.initTimeout) || 60000,
|
||||
}
|
||||
});
|
||||
serverInfos.push({
|
||||
: undefined;
|
||||
|
||||
// Get request options from server configuration, with fallbacks
|
||||
const serverRequestOptions = conf.options || {};
|
||||
const requestOptions = {
|
||||
timeout: serverRequestOptions.timeout || 60000,
|
||||
resetTimeoutOnProgress: serverRequestOptions.resetTimeoutOnProgress || false,
|
||||
maxTotalTimeout: serverRequestOptions.maxTotalTimeout,
|
||||
};
|
||||
|
||||
// Create server info first and keep reference to it
|
||||
const serverInfo: ServerInfo = {
|
||||
name,
|
||||
status: 'connecting',
|
||||
error: null,
|
||||
tools: [],
|
||||
client,
|
||||
transport,
|
||||
options: requestOptions,
|
||||
createTime: Date.now(),
|
||||
});
|
||||
};
|
||||
serverInfos.push(serverInfo);
|
||||
|
||||
client
|
||||
.connect(transport, initRequestOptions || requestOptions)
|
||||
.then(() => {
|
||||
console.log(`Successfully connected client for server: ${name}`);
|
||||
client
|
||||
.listTools({}, initRequestOptions || requestOptions)
|
||||
.then((tools) => {
|
||||
console.log(`Successfully listed ${tools.tools.length} tools for server: ${name}`);
|
||||
|
||||
serverInfo.tools = tools.tools.map((tool) => ({
|
||||
name: `${name}-${tool.name}`,
|
||||
description: tool.description || '',
|
||||
inputSchema: tool.inputSchema || {},
|
||||
}));
|
||||
serverInfo.status = 'connected';
|
||||
serverInfo.error = null;
|
||||
|
||||
// Set up keep-alive ping for SSE connections
|
||||
setupKeepAlive(serverInfo, conf);
|
||||
|
||||
// Save tools as vector embeddings for search
|
||||
saveToolsAsVectorEmbeddings(name, serverInfo.tools);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to list tools for server ${name} by error: ${error} with stack: ${error.stack}`,
|
||||
);
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = `Failed to list tools: ${error.stack} `;
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to connect client for server ${name} by error: ${error} with stack: ${error.stack}`,
|
||||
);
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = `Failed to connect: ${error.stack} `;
|
||||
});
|
||||
console.log(`Initialized client for server: ${name}`);
|
||||
}
|
||||
|
||||
@@ -249,7 +474,7 @@ export const initializeClientsFromSettings = (isInit: boolean): ServerInfo[] =>
|
||||
|
||||
// Register all MCP tools
|
||||
export const registerAllTools = async (isInit: boolean): Promise<void> => {
|
||||
initializeClientsFromSettings(isInit);
|
||||
await initializeClientsFromSettings(isInit);
|
||||
};
|
||||
|
||||
// Get all server information
|
||||
@@ -258,11 +483,22 @@ export const getServersInfo = (): Omit<ServerInfo, 'client' | 'transport'>[] =>
|
||||
const infos = serverInfos.map(({ name, status, tools, createTime, error }) => {
|
||||
const serverConfig = settings.mcpServers[name];
|
||||
const enabled = serverConfig ? serverConfig.enabled !== false : true;
|
||||
|
||||
// Add enabled status and custom description to each tool
|
||||
const toolsWithEnabled = tools.map((tool) => {
|
||||
const toolConfig = serverConfig?.tools?.[tool.name];
|
||||
return {
|
||||
...tool,
|
||||
description: toolConfig?.description || tool.description, // Use custom description if available
|
||||
enabled: toolConfig?.enabled !== false, // Default to true if not explicitly disabled
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
name,
|
||||
status,
|
||||
error,
|
||||
tools,
|
||||
tools: toolsWithEnabled,
|
||||
createTime,
|
||||
enabled,
|
||||
};
|
||||
@@ -279,6 +515,23 @@ const getServerByName = (name: string): ServerInfo | undefined => {
|
||||
return serverInfos.find((serverInfo) => serverInfo.name === name);
|
||||
};
|
||||
|
||||
// Filter tools by server configuration
|
||||
const filterToolsByConfig = (serverName: string, tools: ToolInfo[]): ToolInfo[] => {
|
||||
const settings = loadSettings();
|
||||
const serverConfig = settings.mcpServers[serverName];
|
||||
|
||||
if (!serverConfig || !serverConfig.tools) {
|
||||
// If no tool configuration exists, all tools are enabled by default
|
||||
return tools;
|
||||
}
|
||||
|
||||
return tools.filter((tool) => {
|
||||
const toolConfig = serverConfig.tools?.[tool.name];
|
||||
// If tool is not in config, it's enabled by default
|
||||
return toolConfig?.enabled !== false;
|
||||
});
|
||||
};
|
||||
|
||||
// Get server by tool name
|
||||
const getServerByTool = (toolName: string): ServerInfo | undefined => {
|
||||
return serverInfos.find((serverInfo) => serverInfo.tools.some((tool) => tool.name === toolName));
|
||||
@@ -355,10 +608,53 @@ export const updateMcpServer = async (
|
||||
}
|
||||
};
|
||||
|
||||
// Add or update server (supports overriding existing servers for DXT)
|
||||
export const addOrUpdateServer = async (
|
||||
name: string,
|
||||
config: ServerConfig,
|
||||
allowOverride: boolean = false,
|
||||
): Promise<{ success: boolean; message?: string }> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
const exists = !!settings.mcpServers[name];
|
||||
|
||||
if (exists && !allowOverride) {
|
||||
return { success: false, message: 'Server name already exists' };
|
||||
}
|
||||
|
||||
// If overriding and this is a DXT server (stdio type with file paths),
|
||||
// we might want to clean up old files in the future
|
||||
if (exists && config.type === 'stdio') {
|
||||
// Close existing server connections
|
||||
closeServer(name);
|
||||
// Remove from server infos
|
||||
serverInfos = serverInfos.filter((serverInfo) => serverInfo.name !== name);
|
||||
}
|
||||
|
||||
settings.mcpServers[name] = config;
|
||||
if (!saveSettings(settings)) {
|
||||
return { success: false, message: 'Failed to save settings' };
|
||||
}
|
||||
|
||||
const action = exists ? 'updated' : 'added';
|
||||
return { success: true, message: `Server ${action} successfully` };
|
||||
} catch (error) {
|
||||
console.error(`Failed to add/update server: ${name}`, error);
|
||||
return { success: false, message: 'Failed to add/update server' };
|
||||
}
|
||||
};
|
||||
|
||||
// Close server client and transport
|
||||
function closeServer(name: string) {
|
||||
const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
|
||||
if (serverInfo && serverInfo.client && serverInfo.transport) {
|
||||
// Clear keep-alive interval if exists
|
||||
if (serverInfo.keepAliveIntervalId) {
|
||||
clearInterval(serverInfo.keepAliveIntervalId);
|
||||
serverInfo.keepAliveIntervalId = undefined;
|
||||
console.log(`Cleared keep-alive interval for server: ${serverInfo.name}`);
|
||||
}
|
||||
|
||||
serverInfo.client.close();
|
||||
serverInfo.transport.close();
|
||||
console.log(`Closed client and transport for server: ${serverInfo.name}`);
|
||||
@@ -489,7 +785,21 @@ Available servers: ${serversList}`;
|
||||
const allTools = [];
|
||||
for (const serverInfo of allServerInfos) {
|
||||
if (serverInfo.tools && serverInfo.tools.length > 0) {
|
||||
allTools.push(...serverInfo.tools);
|
||||
// Filter tools based on server configuration and apply custom descriptions
|
||||
const enabledTools = filterToolsByConfig(serverInfo.name, serverInfo.tools);
|
||||
|
||||
// Apply custom descriptions from configuration
|
||||
const settings = loadSettings();
|
||||
const serverConfig = settings.mcpServers[serverInfo.name];
|
||||
const toolsWithCustomDescriptions = enabledTools.map((tool) => {
|
||||
const toolConfig = serverConfig?.tools?.[tool.name];
|
||||
return {
|
||||
...tool,
|
||||
description: toolConfig?.description || tool.description, // Use custom description if available
|
||||
};
|
||||
});
|
||||
|
||||
allTools.push(...toolsWithCustomDescriptions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,30 +840,54 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
const searchResults = await searchToolsByVector(query, limitNum, thresholdNum, servers);
|
||||
console.log(`Search results: ${JSON.stringify(searchResults)}`);
|
||||
// Find actual tool information from serverInfos by serverName and toolName
|
||||
const tools = searchResults.map((result) => {
|
||||
// Find the server in serverInfos
|
||||
const server = serverInfos.find(
|
||||
(serverInfo) =>
|
||||
serverInfo.name === result.serverName &&
|
||||
serverInfo.status === 'connected' &&
|
||||
serverInfo.enabled !== false,
|
||||
);
|
||||
if (server && server.tools && server.tools.length > 0) {
|
||||
// Find the tool in server.tools
|
||||
const actualTool = server.tools.find((tool) => tool.name === result.toolName);
|
||||
if (actualTool) {
|
||||
// Return the actual tool info from serverInfos
|
||||
return actualTool;
|
||||
}
|
||||
}
|
||||
const tools = searchResults
|
||||
.map((result) => {
|
||||
// Find the server in serverInfos
|
||||
const server = serverInfos.find(
|
||||
(serverInfo) =>
|
||||
serverInfo.name === result.serverName &&
|
||||
serverInfo.status === 'connected' &&
|
||||
serverInfo.enabled !== false,
|
||||
);
|
||||
if (server && server.tools && server.tools.length > 0) {
|
||||
// Find the tool in server.tools
|
||||
const actualTool = server.tools.find((tool) => tool.name === result.toolName);
|
||||
if (actualTool) {
|
||||
// Check if the tool is enabled in configuration
|
||||
const enabledTools = filterToolsByConfig(server.name, [actualTool]);
|
||||
if (enabledTools.length > 0) {
|
||||
// Apply custom description from configuration
|
||||
const settings = loadSettings();
|
||||
const serverConfig = settings.mcpServers[server.name];
|
||||
const toolConfig = serverConfig?.tools?.[actualTool.name];
|
||||
|
||||
// Fallback to search result if server or tool not found
|
||||
return {
|
||||
name: result.toolName,
|
||||
description: result.description || '',
|
||||
inputSchema: result.inputSchema || {},
|
||||
};
|
||||
});
|
||||
// Return the actual tool info from serverInfos with custom description
|
||||
return {
|
||||
...actualTool,
|
||||
description: toolConfig?.description || actualTool.description,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to search result if server or tool not found or disabled
|
||||
return {
|
||||
name: result.toolName,
|
||||
description: result.description || '',
|
||||
inputSchema: result.inputSchema || {},
|
||||
};
|
||||
})
|
||||
.filter((tool) => {
|
||||
// Additional filter to remove tools that are disabled
|
||||
if (tool.name) {
|
||||
const serverName = searchResults.find((r) => r.toolName === tool.name)?.serverName;
|
||||
if (serverName) {
|
||||
const enabledTools = filterToolsByConfig(serverName, [tool as ToolInfo]);
|
||||
return enabledTools.length > 0;
|
||||
}
|
||||
}
|
||||
return true; // Keep fallback results
|
||||
});
|
||||
|
||||
// Add usage guidance to the response
|
||||
const response = {
|
||||
@@ -586,14 +920,12 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
|
||||
// Special handling for call_tool
|
||||
if (request.params.name === 'call_tool') {
|
||||
const { toolName, arguments: toolArgs = {} } = request.params.arguments || {};
|
||||
|
||||
let { toolName } = request.params.arguments || {};
|
||||
if (!toolName) {
|
||||
throw new Error('toolName parameter is required');
|
||||
}
|
||||
|
||||
// arguments parameter is now optional
|
||||
|
||||
const { arguments: toolArgs = {} } = request.params.arguments || {};
|
||||
let targetServerInfo: ServerInfo | undefined;
|
||||
if (extra && extra.server) {
|
||||
targetServerInfo = getServerByName(extra.server);
|
||||
@@ -617,7 +949,38 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
throw new Error(`Tool '${toolName}' not found on server '${targetServerInfo.name}'`);
|
||||
}
|
||||
|
||||
// Call the tool on the target server
|
||||
// Handle OpenAPI servers differently
|
||||
if (targetServerInfo.openApiClient) {
|
||||
// For OpenAPI servers, use the OpenAPI client
|
||||
const openApiClient = targetServerInfo.openApiClient;
|
||||
|
||||
// Use toolArgs if it has properties, otherwise fallback to request.params.arguments
|
||||
const finalArgs =
|
||||
toolArgs && Object.keys(toolArgs).length > 0 ? toolArgs : request.params.arguments || {};
|
||||
|
||||
console.log(
|
||||
`Invoking OpenAPI tool '${toolName}' on server '${targetServerInfo.name}' with arguments: ${JSON.stringify(finalArgs)}`,
|
||||
);
|
||||
|
||||
// Remove server prefix from tool name if present
|
||||
const cleanToolName = toolName.startsWith(`${targetServerInfo.name}-`)
|
||||
? toolName.replace(`${targetServerInfo.name}-`, '')
|
||||
: toolName;
|
||||
|
||||
const result = await openApiClient.callTool(cleanToolName, finalArgs);
|
||||
|
||||
console.log(`OpenAPI tool invocation result: ${JSON.stringify(result)}`);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(result),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Call the tool on the target server (MCP servers)
|
||||
const client = targetServerInfo.client;
|
||||
if (!client) {
|
||||
throw new Error(`Client not found for server: ${targetServerInfo.name}`);
|
||||
@@ -631,10 +994,17 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
`Invoking tool '${toolName}' on server '${targetServerInfo.name}' with arguments: ${JSON.stringify(finalArgs)}`,
|
||||
);
|
||||
|
||||
const result = await client.callTool({
|
||||
name: toolName,
|
||||
arguments: finalArgs,
|
||||
});
|
||||
toolName = toolName.startsWith(`${targetServerInfo.name}-`)
|
||||
? toolName.replace(`${targetServerInfo.name}-`, '')
|
||||
: toolName;
|
||||
const result = await callToolWithReconnect(
|
||||
targetServerInfo,
|
||||
{
|
||||
name: toolName,
|
||||
arguments: finalArgs,
|
||||
},
|
||||
targetServerInfo.options || {},
|
||||
);
|
||||
|
||||
console.log(`Tool invocation result: ${JSON.stringify(result)}`);
|
||||
return result;
|
||||
@@ -645,11 +1015,48 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
if (!serverInfo) {
|
||||
throw new Error(`Server not found: ${request.params.name}`);
|
||||
}
|
||||
|
||||
// Handle OpenAPI servers differently
|
||||
if (serverInfo.openApiClient) {
|
||||
// For OpenAPI servers, use the OpenAPI client
|
||||
const openApiClient = serverInfo.openApiClient;
|
||||
|
||||
// Remove server prefix from tool name if present
|
||||
const cleanToolName = request.params.name.startsWith(`${serverInfo.name}-`)
|
||||
? request.params.name.replace(`${serverInfo.name}-`, '')
|
||||
: request.params.name;
|
||||
|
||||
console.log(
|
||||
`Invoking OpenAPI tool '${cleanToolName}' on server '${serverInfo.name}' with arguments: ${JSON.stringify(request.params.arguments)}`,
|
||||
);
|
||||
|
||||
const result = await openApiClient.callTool(cleanToolName, request.params.arguments || {});
|
||||
|
||||
console.log(`OpenAPI tool invocation result: ${JSON.stringify(result)}`);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(result),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Handle MCP servers
|
||||
const client = serverInfo.client;
|
||||
if (!client) {
|
||||
throw new Error(`Client not found for server: ${request.params.name}`);
|
||||
throw new Error(`Client not found for server: ${serverInfo.name}`);
|
||||
}
|
||||
const result = await client.callTool(request.params);
|
||||
|
||||
request.params.name = request.params.name.startsWith(`${serverInfo.name}-`)
|
||||
? request.params.name.replace(`${serverInfo.name}-`, '')
|
||||
: request.params.name;
|
||||
const result = await callToolWithReconnect(
|
||||
serverInfo,
|
||||
request.params,
|
||||
serverInfo.options || {},
|
||||
);
|
||||
console.log(`Tool call result: ${JSON.stringify(result)}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
|
||||
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { deleteMcpServer, getMcpServer } from './mcpService.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import config from '../config/index.js';
|
||||
|
||||
const transports: { [sessionId: string]: { transport: Transport; group: string } } = {};
|
||||
|
||||
@@ -58,7 +59,7 @@ export const handleSseConnection = async (req: Request, res: Response): Promise<
|
||||
return;
|
||||
}
|
||||
|
||||
const transport = new SSEServerTransport('/messages', res);
|
||||
const transport = new SSEServerTransport(`${config.basePath}/messages`, res);
|
||||
transports[transport.sessionId] = { transport, group: group };
|
||||
|
||||
res.on('close', () => {
|
||||
@@ -108,7 +109,10 @@ export const handleSseMessage = async (req: Request, res: Response): Promise<voi
|
||||
export const handleMcpPostRequest = async (req: Request, res: Response): Promise<void> => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
const group = req.params.group;
|
||||
console.log(`Handling MCP post request for sessionId: ${sessionId} and group: ${group}`);
|
||||
const body = req.body;
|
||||
console.log(
|
||||
`Handling MCP post request for sessionId: ${sessionId} and group: ${group} with body: ${JSON.stringify(body)}`,
|
||||
);
|
||||
// Check bearer auth
|
||||
if (!validateBearerAuth(req)) {
|
||||
res.status(401).send('Bearer authentication required or invalid token');
|
||||
|
||||
@@ -2,45 +2,17 @@ import { getRepositoryFactory } from '../db/index.js';
|
||||
import { VectorEmbeddingRepository } from '../db/repositories/index.js';
|
||||
import { ToolInfo } from '../types/index.js';
|
||||
import { getAppDataSource, initializeDatabase } from '../db/connection.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { getSmartRoutingConfig } from '../utils/smartRouting.js';
|
||||
import OpenAI from 'openai';
|
||||
|
||||
// Get OpenAI configuration from smartRouting settings or fallback to environment variables
|
||||
const getOpenAIConfig = () => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
const smartRouting = settings.systemConfig?.smartRouting;
|
||||
|
||||
return {
|
||||
apiKey: smartRouting?.openaiApiKey || process.env.OPENAI_API_KEY,
|
||||
baseURL:
|
||||
smartRouting?.openaiApiBaseUrl ||
|
||||
process.env.OPENAI_API_BASE_URL ||
|
||||
'https://api.openai.com/v1',
|
||||
embeddingModel:
|
||||
smartRouting?.openaiApiEmbeddingModel ||
|
||||
process.env.OPENAI_API_EMBEDDING_MODEL ||
|
||||
'text-embedding-3-small',
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'Failed to load smartRouting settings, falling back to environment variables:',
|
||||
error,
|
||||
);
|
||||
return {
|
||||
apiKey: '',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
embeddingModel: 'text-embedding-3-small',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Environment variables for embedding configuration
|
||||
const EMBEDDING_ENV = {
|
||||
// The embedding model to use - default to OpenAI but allow BAAI/BGE models
|
||||
MODEL: process.env.EMBEDDING_MODEL || getOpenAIConfig().embeddingModel,
|
||||
// Detect if using a BGE model from the environment variable
|
||||
IS_BGE_MODEL: !!(process.env.EMBEDDING_MODEL && process.env.EMBEDDING_MODEL.includes('bge')),
|
||||
const smartRoutingConfig = getSmartRoutingConfig();
|
||||
return {
|
||||
apiKey: smartRoutingConfig.openaiApiKey,
|
||||
baseURL: smartRoutingConfig.openaiApiBaseUrl,
|
||||
embeddingModel: smartRoutingConfig.openaiApiEmbeddingModel,
|
||||
};
|
||||
};
|
||||
|
||||
// Constants for embedding models
|
||||
@@ -221,6 +193,16 @@ export const saveToolsAsVectorEmbeddings = async (
|
||||
tools: ToolInfo[],
|
||||
): Promise<void> => {
|
||||
try {
|
||||
if (tools.length === 0) {
|
||||
console.warn(`No tools to save for server: ${serverName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const smartRoutingConfig = getSmartRoutingConfig();
|
||||
if (!smartRoutingConfig.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getOpenAIConfig();
|
||||
const vectorRepository = getRepositoryFactory(
|
||||
'vectorEmbeddings',
|
||||
@@ -494,7 +476,7 @@ export const getAllVectorizedTools = async (
|
||||
*/
|
||||
export const removeServerToolEmbeddings = async (serverName: string): Promise<void> => {
|
||||
try {
|
||||
const vectorRepository = getRepositoryFactory(
|
||||
const _vectorRepository = getRepositoryFactory(
|
||||
'vectorEmbeddings',
|
||||
)() as VectorEmbeddingRepository;
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js';
|
||||
import { SmartRoutingConfig } from '../utils/smartRouting.js';
|
||||
|
||||
// User interface
|
||||
export interface IUser {
|
||||
@@ -85,31 +87,68 @@ export interface McpSettings {
|
||||
enableGroupNameRoute?: boolean; // Controls whether group routing by name is allowed
|
||||
enableBearerAuth?: boolean; // Controls whether bearer auth is enabled for group routes
|
||||
bearerAuthKey?: string; // The bearer auth key to validate against
|
||||
skipAuth?: boolean; // Controls whether authentication is required for frontend and API access
|
||||
};
|
||||
install?: {
|
||||
pythonIndexUrl?: string; // Python package repository URL (UV_DEFAULT_INDEX)
|
||||
npmRegistry?: string; // NPM registry URL (npm_config_registry)
|
||||
};
|
||||
smartRouting?: {
|
||||
enabled?: boolean; // Controls whether smart routing is enabled
|
||||
dbUrl?: string; // Database URL for smart routing
|
||||
openaiApiBaseUrl?: string; // OpenAI API base URL
|
||||
openaiApiKey?: string; // OpenAI API key
|
||||
openaiApiEmbeddingModel?: string; // OpenAI API embedding model
|
||||
};
|
||||
smartRouting?: SmartRoutingConfig;
|
||||
// Add other system configuration sections here in the future
|
||||
};
|
||||
}
|
||||
|
||||
// Configuration details for an individual server
|
||||
export interface ServerConfig {
|
||||
type?: 'stdio' | 'sse' | 'streamable-http'; // Type of server
|
||||
type?: 'stdio' | 'sse' | 'streamable-http' | 'openapi'; // Type of server
|
||||
url?: string; // URL for SSE or streamable HTTP servers
|
||||
command?: string; // Command to execute for stdio-based servers
|
||||
args?: string[]; // Arguments for the command
|
||||
env?: Record<string, string>; // Environment variables
|
||||
headers?: Record<string, string>; // HTTP headers for SSE/streamable-http servers
|
||||
headers?: Record<string, string>; // HTTP headers for SSE/streamable-http/openapi servers
|
||||
enabled?: boolean; // Flag to enable/disable the server
|
||||
keepAliveInterval?: number; // Keep-alive ping interval in milliseconds (default: 60000ms for SSE servers)
|
||||
tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions
|
||||
options?: Partial<Pick<RequestOptions, 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout'>>; // 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
|
||||
};
|
||||
}
|
||||
|
||||
// Information about a server's status and tools
|
||||
@@ -118,10 +157,13 @@ export interface ServerInfo {
|
||||
status: 'connected' | 'connecting' | 'disconnected'; // Current connection status
|
||||
error: string | null; // Error message if any
|
||||
tools: ToolInfo[]; // List of tools available on the server
|
||||
client?: Client; // Client instance for communication
|
||||
client?: Client; // Client instance for communication (MCP clients)
|
||||
transport?: SSEClientTransport | StdioClientTransport | StreamableHTTPClientTransport; // Transport mechanism used
|
||||
openApiClient?: any; // OpenAPI client instance for openapi type servers
|
||||
options?: RequestOptions; // Options for requests
|
||||
createTime: number; // Timestamp of when the server was created
|
||||
enabled?: boolean; // Flag to indicate if the server is enabled
|
||||
keepAliveIntervalId?: NodeJS.Timeout; // Timer ID for keep-alive ping interval
|
||||
}
|
||||
|
||||
// Details about a tool available on the server
|
||||
@@ -129,6 +171,7 @@ export interface ToolInfo {
|
||||
name: string; // Name of the tool
|
||||
description: string; // Brief description of the tool
|
||||
inputSchema: Record<string, unknown>; // Input schema for the tool
|
||||
enabled?: boolean; // Whether the tool is enabled (optional, defaults to true)
|
||||
}
|
||||
|
||||
// Standardized API response structure
|
||||
|
||||
143
src/utils/smartRouting.ts
Normal file
143
src/utils/smartRouting.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { loadSettings, expandEnvVars } from '../config/index.js';
|
||||
|
||||
/**
|
||||
* Smart routing configuration interface
|
||||
*/
|
||||
export interface SmartRoutingConfig {
|
||||
enabled: boolean;
|
||||
dbUrl: string;
|
||||
openaiApiBaseUrl: string;
|
||||
openaiApiKey: string;
|
||||
openaiApiEmbeddingModel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the complete smart routing configuration from environment variables and settings.
|
||||
*
|
||||
* Priority order for each setting:
|
||||
* 1. Specific environment variables (ENABLE_SMART_ROUTING, SMART_ROUTING_ENABLED, etc.)
|
||||
* 2. Generic environment variables (OPENAI_API_KEY, DATABASE_URL, etc.)
|
||||
* 3. Settings configuration (systemConfig.smartRouting)
|
||||
* 4. Default values
|
||||
*
|
||||
* @returns {SmartRoutingConfig} Complete smart routing configuration
|
||||
*/
|
||||
export function getSmartRoutingConfig(): SmartRoutingConfig {
|
||||
const settings = loadSettings();
|
||||
const smartRoutingSettings: Partial<SmartRoutingConfig> =
|
||||
settings.systemConfig?.smartRouting || {};
|
||||
|
||||
return {
|
||||
// Enabled status - check multiple environment variables
|
||||
enabled: getConfigValue(
|
||||
[process.env.SMART_ROUTING_ENABLED],
|
||||
smartRoutingSettings.enabled,
|
||||
false,
|
||||
parseBooleanEnvVar,
|
||||
),
|
||||
|
||||
// Database configuration
|
||||
dbUrl: getConfigValue([process.env.DB_URL], smartRoutingSettings.dbUrl, '', expandEnvVars),
|
||||
|
||||
// OpenAI API configuration
|
||||
openaiApiBaseUrl: getConfigValue(
|
||||
[process.env.OPENAI_API_BASE_URL],
|
||||
smartRoutingSettings.openaiApiBaseUrl,
|
||||
'https://api.openai.com/v1',
|
||||
expandEnvVars,
|
||||
),
|
||||
|
||||
openaiApiKey: getConfigValue(
|
||||
[process.env.OPENAI_API_KEY],
|
||||
smartRoutingSettings.openaiApiKey,
|
||||
'',
|
||||
expandEnvVars,
|
||||
),
|
||||
|
||||
openaiApiEmbeddingModel: getConfigValue(
|
||||
[process.env.OPENAI_API_EMBEDDING_MODEL],
|
||||
smartRoutingSettings.openaiApiEmbeddingModel,
|
||||
'text-embedding-3-small',
|
||||
expandEnvVars,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a configuration value with priority order: environment variables > settings > default.
|
||||
*
|
||||
* @param {(string | undefined)[]} envVars - Array of environment variable names to check in order
|
||||
* @param {any} settingsValue - Value from settings configuration
|
||||
* @param {any} defaultValue - Default value to use if no other value is found
|
||||
* @param {Function} transformer - Function to transform the final value to the correct type
|
||||
* @returns {any} The configuration value with the appropriate transformation applied
|
||||
*/
|
||||
function getConfigValue<T>(
|
||||
envVars: (string | undefined)[],
|
||||
settingsValue: any,
|
||||
defaultValue: T,
|
||||
transformer: (value: any) => T,
|
||||
): T {
|
||||
// Check environment variables in order
|
||||
for (const envVar of envVars) {
|
||||
if (envVar !== undefined && envVar !== null && envVar !== '') {
|
||||
try {
|
||||
return transformer(envVar);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to transform environment variable "${envVar}":`, error);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check settings value
|
||||
if (settingsValue !== undefined && settingsValue !== null) {
|
||||
try {
|
||||
return transformer(settingsValue);
|
||||
} catch (error) {
|
||||
console.warn('Failed to transform settings value:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Return default value
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a string environment variable value to a boolean.
|
||||
* Supports common boolean representations: true/false, 1/0, yes/no, on/off
|
||||
*
|
||||
* @param {string} value - The environment variable value to parse
|
||||
* @returns {boolean} The parsed boolean value
|
||||
*/
|
||||
function parseBooleanEnvVar(value: string): boolean {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = value.toLowerCase().trim();
|
||||
|
||||
// Handle common truthy values
|
||||
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle common falsy values
|
||||
if (
|
||||
normalized === 'false' ||
|
||||
normalized === '0' ||
|
||||
normalized === 'no' ||
|
||||
normalized === 'off' ||
|
||||
normalized === ''
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Default to false for unrecognized values
|
||||
console.warn(`Unrecognized boolean value for smart routing: "${value}", defaulting to false`);
|
||||
return false;
|
||||
}
|
||||
177
test-integration.ts
Normal file
177
test-integration.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
// Comprehensive test for OpenAPI server support in MCPHub
|
||||
// This test verifies the complete integration including types, client, and service
|
||||
|
||||
import { OpenAPIClient } from './src/clients/openapi.js';
|
||||
import { addServer, removeServer, getServersInfo } from './src/services/mcpService.js';
|
||||
import type { ServerConfig } from './src/types/index.js';
|
||||
|
||||
async function testOpenAPIIntegration() {
|
||||
console.log('🧪 Testing OpenAPI Integration in MCPHub\n');
|
||||
|
||||
// Test 1: OpenAPI Type System
|
||||
console.log('1️⃣ Testing OpenAPI Type System...');
|
||||
|
||||
const openAPIConfig: ServerConfig = {
|
||||
type: 'openapi',
|
||||
openapi: {
|
||||
url: 'https://petstore3.swagger.io/api/v3/openapi.json',
|
||||
version: '3.1.0',
|
||||
security: {
|
||||
type: 'none',
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
const apiKeyConfig: ServerConfig = {
|
||||
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: 'test-api-key',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const httpAuthConfig: ServerConfig = {
|
||||
type: 'openapi',
|
||||
openapi: {
|
||||
url: 'https://api.example.com/v1/openapi.json',
|
||||
version: '3.1.0',
|
||||
security: {
|
||||
type: 'http',
|
||||
http: {
|
||||
scheme: 'bearer',
|
||||
credentials: 'test-token',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
console.log('✅ OpenAPI type definitions are working correctly');
|
||||
console.log(` - Basic config: ${openAPIConfig.type}`);
|
||||
console.log(` - API Key config: ${apiKeyConfig.openapi?.security?.type}`);
|
||||
console.log(` - HTTP Auth config: ${httpAuthConfig.openapi?.security?.type}`);
|
||||
|
||||
// Test 2: OpenAPI Client Direct
|
||||
console.log('\n2️⃣ Testing OpenAPI Client...');
|
||||
|
||||
try {
|
||||
const client = new OpenAPIClient(openAPIConfig);
|
||||
await client.initialize();
|
||||
|
||||
const tools = client.getTools();
|
||||
console.log(`✅ OpenAPI client loaded ${tools.length} tools`);
|
||||
|
||||
// Show some example tools
|
||||
const sampleTools = tools.slice(0, 3);
|
||||
sampleTools.forEach((tool) => {
|
||||
console.log(` - ${tool.name} (${tool.method.toUpperCase()} ${tool.path})`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ OpenAPI client test failed:', (error as Error).message);
|
||||
}
|
||||
|
||||
// Test 3: MCP Service Integration
|
||||
console.log('\n3️⃣ Testing MCP Service Integration...');
|
||||
|
||||
try {
|
||||
// Test server registration
|
||||
const serverName = 'test-openapi-server';
|
||||
await addServer(serverName, openAPIConfig);
|
||||
console.log(`✅ Successfully registered OpenAPI server: ${serverName}`);
|
||||
|
||||
// Test server retrieval
|
||||
const servers = getServersInfo();
|
||||
const openAPIServer = servers.find((s) => s.name === serverName);
|
||||
if (openAPIServer) {
|
||||
console.log(`✅ Server configuration retrieved correctly`);
|
||||
console.log(` - Name: ${openAPIServer.name}`);
|
||||
console.log(` - Status: ${openAPIServer.status}`);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
removeServer(serverName);
|
||||
console.log(`✅ Server cleanup completed`);
|
||||
} catch (error) {
|
||||
console.error('❌ MCP Service integration test failed:', (error as Error).message);
|
||||
}
|
||||
|
||||
// Test 4: Security Configuration Variants
|
||||
console.log('\n4️⃣ Testing Security Configuration Variants...');
|
||||
|
||||
const securityConfigs = [
|
||||
{ name: 'None', config: { type: 'none' as const } },
|
||||
{
|
||||
name: 'API Key (Header)',
|
||||
config: {
|
||||
type: 'apiKey' as const,
|
||||
apiKey: { name: 'X-API-Key', in: 'header' as const, value: 'test' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'API Key (Query)',
|
||||
config: {
|
||||
type: 'apiKey' as const,
|
||||
apiKey: { name: 'api_key', in: 'query' as const, value: 'test' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'HTTP Bearer',
|
||||
config: {
|
||||
type: 'http' as const,
|
||||
http: { scheme: 'bearer' as const, credentials: 'token' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'HTTP Basic',
|
||||
config: {
|
||||
type: 'http' as const,
|
||||
http: { scheme: 'basic' as const, credentials: 'user:pass' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
securityConfigs.forEach(({ name, config }) => {
|
||||
const _testConfig: ServerConfig = {
|
||||
type: 'openapi',
|
||||
openapi: {
|
||||
url: 'https://api.example.com/openapi.json',
|
||||
version: '3.1.0',
|
||||
security: config,
|
||||
},
|
||||
};
|
||||
console.log(`✅ ${name} security configuration is valid`);
|
||||
});
|
||||
|
||||
console.log('\n🎉 OpenAPI Integration Test Completed!');
|
||||
console.log('\n📊 Summary:');
|
||||
console.log(' ✅ Type system supports all OpenAPI configuration variants');
|
||||
console.log(' ✅ OpenAPI client can load and parse specifications');
|
||||
console.log(' ✅ MCP service can register and manage OpenAPI servers');
|
||||
console.log(' ✅ Security configurations are properly typed and validated');
|
||||
console.log('\n🚀 OpenAPI support is ready for production use!');
|
||||
}
|
||||
|
||||
// Handle uncaught errors gracefully
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('Uncaught exception:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('Unhandled rejection at:', promise, 'reason:', reason);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Run the comprehensive test
|
||||
testOpenAPIIntegration().catch(console.error);
|
||||
216
test-openapi-schema.ts
Normal file
216
test-openapi-schema.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
// Test script to verify OpenAPI schema support
|
||||
// Run this in the MCPHub project directory with: tsx test-openapi-schema.ts
|
||||
|
||||
import { OpenAPIClient } from './src/clients/openapi.js';
|
||||
import type { ServerConfig } from './src/types/index.js';
|
||||
|
||||
async function testOpenAPISchemaSupport() {
|
||||
console.log('🧪 Testing OpenAPI Schema Support...\n');
|
||||
|
||||
// Test 1: Schema-based OpenAPI client
|
||||
console.log('1️⃣ Testing OpenAPI client with JSON schema...');
|
||||
|
||||
const sampleSchema = {
|
||||
openapi: '3.1.0',
|
||||
info: {
|
||||
title: 'Test 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',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
name: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/users/{id}': {
|
||||
get: {
|
||||
operationId: 'getUserById',
|
||||
summary: 'Get user by ID',
|
||||
parameters: [
|
||||
{
|
||||
name: 'id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'User details',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
name: { type: 'string' },
|
||||
email: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const schemaConfig: ServerConfig = {
|
||||
type: 'openapi',
|
||||
openapi: {
|
||||
schema: sampleSchema,
|
||||
version: '3.1.0',
|
||||
security: {
|
||||
type: 'apiKey',
|
||||
apiKey: {
|
||||
name: 'X-API-Key',
|
||||
in: 'header',
|
||||
value: 'test-key',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
console.log(' Creating OpenAPI client with schema...');
|
||||
const client = new OpenAPIClient(schemaConfig);
|
||||
|
||||
console.log(' Initializing client...');
|
||||
await client.initialize();
|
||||
|
||||
console.log(' Getting available tools...');
|
||||
const tools = client.getTools();
|
||||
|
||||
console.log(` ✅ Schema-based client initialized successfully!`);
|
||||
console.log(` 📋 Found ${tools.length} tools:`);
|
||||
|
||||
tools.forEach((tool) => {
|
||||
console.log(` - ${tool.name}: ${tool.description}`);
|
||||
});
|
||||
|
||||
// Test 2: Compare with URL-based client (if available)
|
||||
console.log('\n2️⃣ Testing configuration validation...');
|
||||
|
||||
// Valid configurations
|
||||
const validConfigs = [
|
||||
{
|
||||
name: 'URL-based config',
|
||||
config: {
|
||||
type: 'openapi' as const,
|
||||
openapi: {
|
||||
url: 'https://api.example.com/openapi.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Schema-based config',
|
||||
config: {
|
||||
type: 'openapi' as const,
|
||||
openapi: {
|
||||
schema: sampleSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Both URL and schema (should prefer schema)',
|
||||
config: {
|
||||
type: 'openapi' as const,
|
||||
openapi: {
|
||||
url: 'https://api.example.com/openapi.json',
|
||||
schema: sampleSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
validConfigs.forEach(({ name, config }) => {
|
||||
try {
|
||||
const _client = new OpenAPIClient(config);
|
||||
console.log(` ✅ ${name}: Valid configuration`);
|
||||
} catch (error) {
|
||||
console.log(` ❌ ${name}: Invalid configuration - ${error}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Invalid configurations
|
||||
console.log('\n3️⃣ Testing invalid configurations...');
|
||||
|
||||
const invalidConfigs = [
|
||||
{
|
||||
name: 'No URL or schema',
|
||||
config: {
|
||||
type: 'openapi' as const,
|
||||
openapi: {
|
||||
version: '3.1.0',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Empty openapi object',
|
||||
config: {
|
||||
type: 'openapi' as const,
|
||||
openapi: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
invalidConfigs.forEach(({ name, config }) => {
|
||||
try {
|
||||
const _client = new OpenAPIClient(config);
|
||||
console.log(` ❌ ${name}: Should have failed but didn't`);
|
||||
} catch (error) {
|
||||
console.log(` ✅ ${name}: Correctly rejected - ${(error as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n🎉 All tests completed successfully!');
|
||||
console.log('\n📝 Summary:');
|
||||
console.log(' ✅ OpenAPI client supports JSON schema input');
|
||||
console.log(' ✅ Schema parsing and tool extraction works');
|
||||
console.log(' ✅ Configuration validation works correctly');
|
||||
console.log(' ✅ Both URL and schema modes are supported');
|
||||
} catch (error) {
|
||||
console.error('❌ Test failed:', (error as Error).message);
|
||||
console.error(' Stack trace:', (error as Error).stack);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle uncaught errors gracefully
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('Uncaught exception:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('Unhandled rejection at:', promise, 'reason:', reason);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Run the test
|
||||
testOpenAPISchemaSupport().catch(console.error);
|
||||
64
test-openapi.ts
Normal file
64
test-openapi.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// Test script to verify OpenAPI server functionality
|
||||
// Run this in the MCPHub project directory with: tsx test-openapi.ts
|
||||
|
||||
import { OpenAPIClient } from './src/clients/openapi.js';
|
||||
import type { ServerConfig } from './src/types/index.js';
|
||||
|
||||
async function testOpenAPIClient() {
|
||||
console.log('Testing OpenAPI client...');
|
||||
|
||||
// Test configuration
|
||||
const testConfig: ServerConfig = {
|
||||
type: 'openapi',
|
||||
openapi: {
|
||||
url: 'https://petstore3.swagger.io/api/v3/openapi.json', // Public Swagger Petstore API
|
||||
version: '3.1.0',
|
||||
security: {
|
||||
type: 'none',
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
// Initialize the OpenAPI client
|
||||
const client = new OpenAPIClient(testConfig);
|
||||
await client.initialize();
|
||||
|
||||
console.log('✅ OpenAPI client initialized successfully');
|
||||
|
||||
// Get available tools
|
||||
const tools = client.getTools();
|
||||
console.log(`✅ Found ${tools.length} tools:`);
|
||||
|
||||
tools.slice(0, 5).forEach((tool) => {
|
||||
console.log(` - ${tool.name}: ${tool.description}`);
|
||||
});
|
||||
|
||||
// Test a simple GET operation if available
|
||||
const getTool = tools.find(
|
||||
(tool) => tool.method === 'get' && tool.path.includes('/pet') && !tool.path.includes('{'),
|
||||
);
|
||||
|
||||
if (getTool) {
|
||||
console.log(`\n🔧 Testing tool: ${getTool.name}`);
|
||||
try {
|
||||
const result = await client.callTool(getTool.name, {});
|
||||
console.log('✅ Tool call successful');
|
||||
console.log('Result type:', typeof result);
|
||||
} catch (error) {
|
||||
console.log('⚠️ Tool call failed (expected for demo API):', (error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n🎉 OpenAPI integration test completed!');
|
||||
} catch (error) {
|
||||
console.error('❌ Test failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the test
|
||||
testOpenAPIClient().catch(console.error);
|
||||
154
tests/auth.logic.test.ts
Normal file
154
tests/auth.logic.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
// Simplified test for authController functionality
|
||||
|
||||
// Simple mock implementations
|
||||
const mockJwt = {
|
||||
sign: jest.fn(),
|
||||
};
|
||||
|
||||
const mockUser = {
|
||||
findUserByUsername: jest.fn(),
|
||||
verifyPassword: jest.fn(),
|
||||
createUser: jest.fn(),
|
||||
};
|
||||
|
||||
// Mock the login function logic
|
||||
const loginLogic = async (username: string, password: string) => {
|
||||
const user = mockUser.findUserByUsername(username);
|
||||
|
||||
if (!user) {
|
||||
return { success: false, message: 'Invalid credentials' };
|
||||
}
|
||||
|
||||
const isPasswordValid = await mockUser.verifyPassword(password, user.password);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
return { success: false, message: 'Invalid credentials' };
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
mockJwt.sign(
|
||||
{ user: { username: user.username, isAdmin: user.isAdmin } },
|
||||
'secret',
|
||||
{ expiresIn: '24h' },
|
||||
(err: any, token: string) => {
|
||||
if (err) reject(err);
|
||||
resolve({
|
||||
success: true,
|
||||
token,
|
||||
user: {
|
||||
username: user.username,
|
||||
isAdmin: user.isAdmin
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
describe('Auth Logic Tests', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Login Logic', () => {
|
||||
it('should return success for valid credentials', async () => {
|
||||
const mockUserData = {
|
||||
username: 'testuser',
|
||||
password: 'hashedPassword',
|
||||
isAdmin: false,
|
||||
};
|
||||
|
||||
const mockToken = 'mock-jwt-token';
|
||||
|
||||
// Setup mocks
|
||||
mockUser.findUserByUsername.mockReturnValue(mockUserData);
|
||||
mockUser.verifyPassword.mockResolvedValue(true);
|
||||
mockJwt.sign.mockImplementation((payload, secret, options, callback) => {
|
||||
callback(null, mockToken);
|
||||
});
|
||||
|
||||
const result = await loginLogic('testuser', 'password123');
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
token: mockToken,
|
||||
user: {
|
||||
username: 'testuser',
|
||||
isAdmin: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockUser.findUserByUsername).toHaveBeenCalledWith('testuser');
|
||||
expect(mockUser.verifyPassword).toHaveBeenCalledWith('password123', 'hashedPassword');
|
||||
});
|
||||
|
||||
it('should return error for non-existent user', async () => {
|
||||
mockUser.findUserByUsername.mockReturnValue(undefined);
|
||||
|
||||
const result = await loginLogic('nonexistent', 'password123');
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
message: 'Invalid credentials',
|
||||
});
|
||||
|
||||
expect(mockUser.findUserByUsername).toHaveBeenCalledWith('nonexistent');
|
||||
expect(mockUser.verifyPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error for invalid password', async () => {
|
||||
const mockUserData = {
|
||||
username: 'testuser',
|
||||
password: 'hashedPassword',
|
||||
isAdmin: false,
|
||||
};
|
||||
|
||||
mockUser.findUserByUsername.mockReturnValue(mockUserData);
|
||||
mockUser.verifyPassword.mockResolvedValue(false);
|
||||
|
||||
const result = await loginLogic('testuser', 'wrongpassword');
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
message: 'Invalid credentials',
|
||||
});
|
||||
|
||||
expect(mockUser.verifyPassword).toHaveBeenCalledWith('wrongpassword', 'hashedPassword');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Utility Functions', () => {
|
||||
it('should validate user data structure', () => {
|
||||
const validUser = {
|
||||
username: 'testuser',
|
||||
password: 'password123',
|
||||
isAdmin: false,
|
||||
};
|
||||
|
||||
expect(validUser).toHaveProperty('username');
|
||||
expect(validUser).toHaveProperty('password');
|
||||
expect(validUser).toHaveProperty('isAdmin');
|
||||
expect(typeof validUser.username).toBe('string');
|
||||
expect(typeof validUser.password).toBe('string');
|
||||
expect(typeof validUser.isAdmin).toBe('boolean');
|
||||
});
|
||||
|
||||
it('should generate proper JWT payload structure', () => {
|
||||
const user = {
|
||||
username: 'testuser',
|
||||
isAdmin: true,
|
||||
};
|
||||
|
||||
const payload = {
|
||||
user: {
|
||||
username: user.username,
|
||||
isAdmin: user.isAdmin,
|
||||
},
|
||||
};
|
||||
|
||||
expect(payload).toHaveProperty('user');
|
||||
expect(payload.user).toHaveProperty('username', 'testuser');
|
||||
expect(payload.user).toHaveProperty('isAdmin', true);
|
||||
});
|
||||
});
|
||||
});
|
||||
17
tests/basic.test.ts
Normal file
17
tests/basic.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Simple test to verify Jest configuration
|
||||
describe('Jest Configuration', () => {
|
||||
it('should be working correctly', () => {
|
||||
expect(1 + 1).toBe(2);
|
||||
});
|
||||
|
||||
it('should support async operations', async () => {
|
||||
const promise = Promise.resolve('test');
|
||||
await expect(promise).resolves.toBe('test');
|
||||
});
|
||||
it('should have custom matchers available', () => {
|
||||
const date = new Date();
|
||||
// Test custom matcher - this will fail if setup is not working
|
||||
expect(typeof date.getTime()).toBe('number');
|
||||
expect(date.getTime()).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
76
tests/setup.ts
Normal file
76
tests/setup.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
// Global test setup
|
||||
import 'reflect-metadata';
|
||||
|
||||
// Mock environment variables for testing
|
||||
Object.assign(process.env, {
|
||||
NODE_ENV: 'test',
|
||||
JWT_SECRET: 'test-jwt-secret-key',
|
||||
DATABASE_URL: 'sqlite::memory:',
|
||||
});
|
||||
|
||||
// Global test utilities
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace jest {
|
||||
interface Matchers<R> {
|
||||
toBeValidDate(): R;
|
||||
toBeValidUUID(): R;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom matchers
|
||||
expect.extend({
|
||||
toBeValidDate(received: any) {
|
||||
const pass = received instanceof Date && !isNaN(received.getTime());
|
||||
if (pass) {
|
||||
return {
|
||||
message: () => `expected ${received} not to be a valid date`,
|
||||
pass: true,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
message: () => `expected ${received} to be a valid date`,
|
||||
pass: false,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
toBeValidUUID(received: any) {
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const pass = typeof received === 'string' && uuidRegex.test(received);
|
||||
if (pass) {
|
||||
return {
|
||||
message: () => `expected ${received} not to be a valid UUID`,
|
||||
pass: true,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
message: () => `expected ${received} to be a valid UUID`,
|
||||
pass: false,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Increase timeout for async operations
|
||||
jest.setTimeout(10000);
|
||||
|
||||
// Mock console methods to reduce noise in tests
|
||||
const originalError = console.error;
|
||||
const originalWarn = console.warn;
|
||||
|
||||
beforeAll(() => {
|
||||
console.error = jest.fn();
|
||||
console.warn = jest.fn();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
console.error = originalError;
|
||||
console.warn = originalWarn;
|
||||
});
|
||||
|
||||
// Clear all mocks before each test
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
180
tests/utils/pathLogic.test.ts
Normal file
180
tests/utils/pathLogic.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
// Test for path utilities functionality
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Mock fs module
|
||||
jest.mock('fs');
|
||||
const mockFs = fs as jest.Mocked<typeof fs>;
|
||||
|
||||
describe('Path Utilities Logic', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
delete process.env.MCPHUB_SETTING_PATH;
|
||||
});
|
||||
|
||||
// Test the core logic of path resolution
|
||||
const findConfigFile = (filename: string): string => {
|
||||
const envPath = process.env.MCPHUB_SETTING_PATH;
|
||||
const potentialPaths = [
|
||||
...(envPath ? [envPath] : []),
|
||||
path.resolve(process.cwd(), filename),
|
||||
path.join(process.cwd(), filename),
|
||||
];
|
||||
|
||||
for (const filePath of potentialPaths) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
return path.resolve(process.cwd(), filename);
|
||||
};
|
||||
|
||||
describe('Configuration File Resolution', () => {
|
||||
it('should find existing file in current directory', () => {
|
||||
const filename = 'test-config.json';
|
||||
const expectedPath = path.resolve(process.cwd(), filename);
|
||||
|
||||
mockFs.existsSync.mockImplementation((filePath) => {
|
||||
return filePath === expectedPath;
|
||||
});
|
||||
|
||||
const result = findConfigFile(filename);
|
||||
|
||||
expect(result).toBe(expectedPath);
|
||||
expect(mockFs.existsSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prioritize environment variable path', () => {
|
||||
const filename = 'test-config.json';
|
||||
const envPath = '/custom/path/test-config.json';
|
||||
process.env.MCPHUB_SETTING_PATH = envPath;
|
||||
|
||||
mockFs.existsSync.mockImplementation((filePath) => {
|
||||
return filePath === envPath;
|
||||
});
|
||||
|
||||
const result = findConfigFile(filename);
|
||||
|
||||
expect(result).toBe(envPath);
|
||||
expect(mockFs.existsSync).toHaveBeenCalledWith(envPath);
|
||||
});
|
||||
|
||||
it('should return default path when file does not exist', () => {
|
||||
const filename = 'nonexistent-config.json';
|
||||
const expectedDefaultPath = path.resolve(process.cwd(), filename);
|
||||
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
const result = findConfigFile(filename);
|
||||
|
||||
expect(result).toBe(expectedDefaultPath);
|
||||
});
|
||||
|
||||
it('should handle different file types', () => {
|
||||
const testFiles = [
|
||||
'config.json',
|
||||
'settings.yaml',
|
||||
'data.xml',
|
||||
'servers.json'
|
||||
];
|
||||
|
||||
testFiles.forEach(filename => {
|
||||
const expectedPath = path.resolve(process.cwd(), filename);
|
||||
|
||||
mockFs.existsSync.mockImplementation((filePath) => {
|
||||
return filePath === expectedPath;
|
||||
});
|
||||
|
||||
const result = findConfigFile(filename);
|
||||
expect(result).toBe(expectedPath);
|
||||
expect(path.isAbsolute(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Path Operations', () => {
|
||||
it('should generate absolute paths', () => {
|
||||
const filename = 'test.json';
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
const result = findConfigFile(filename);
|
||||
|
||||
expect(path.isAbsolute(result)).toBe(true);
|
||||
expect(result).toContain(filename);
|
||||
}); it('should handle path normalization', () => {
|
||||
const filename = './config/../settings.json';
|
||||
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
const result = findConfigFile(filename);
|
||||
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should work consistently across multiple calls', () => {
|
||||
const filename = 'consistent-test.json';
|
||||
const expectedPath = path.resolve(process.cwd(), filename);
|
||||
|
||||
mockFs.existsSync.mockImplementation((filePath) => {
|
||||
return filePath === expectedPath;
|
||||
});
|
||||
|
||||
const result1 = findConfigFile(filename);
|
||||
const result2 = findConfigFile(filename);
|
||||
|
||||
expect(result1).toBe(result2);
|
||||
expect(result1).toBe(expectedPath);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Environment Variable Handling', () => {
|
||||
it('should handle missing environment variable gracefully', () => {
|
||||
const filename = 'test.json';
|
||||
delete process.env.MCPHUB_SETTING_PATH;
|
||||
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
const result = findConfigFile(filename);
|
||||
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result).toContain(filename);
|
||||
});
|
||||
|
||||
it('should handle empty environment variable', () => {
|
||||
const filename = 'test.json';
|
||||
process.env.MCPHUB_SETTING_PATH = '';
|
||||
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
const result = findConfigFile(filename);
|
||||
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result).toContain(filename);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle fs.existsSync errors gracefully', () => {
|
||||
const filename = 'test.json';
|
||||
|
||||
mockFs.existsSync.mockImplementation(() => {
|
||||
throw new Error('File system error');
|
||||
});
|
||||
|
||||
expect(() => findConfigFile(filename)).toThrow('File system error');
|
||||
});
|
||||
|
||||
it('should validate input parameters', () => {
|
||||
const emptyFilename = '';
|
||||
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
const result = findConfigFile(emptyFilename);
|
||||
|
||||
expect(typeof result).toBe('string');
|
||||
// Should still return a path, even for empty filename
|
||||
});
|
||||
});
|
||||
});
|
||||
176
tests/utils/testHelpers.ts
Normal file
176
tests/utils/testHelpers.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
// Test utilities and helpers
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
export interface TestUser {
|
||||
username: string;
|
||||
password: string;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test Express app instance
|
||||
*/
|
||||
export const createTestApp = (): express.Application => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
return app;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a test JWT token
|
||||
*/
|
||||
export const generateTestToken = (payload: any, secret = 'test-jwt-secret-key'): string => {
|
||||
return jwt.sign(payload, secret, { expiresIn: '1h' });
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a test user token with default claims
|
||||
*/
|
||||
export const createUserToken = (username = 'testuser', isAdmin = false): string => {
|
||||
const payload = {
|
||||
user: {
|
||||
username,
|
||||
isAdmin,
|
||||
},
|
||||
};
|
||||
return generateTestToken(payload);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an admin user token
|
||||
*/
|
||||
export const createAdminToken = (username = 'admin'): string => {
|
||||
return createUserToken(username, true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Make authenticated request helper
|
||||
*/
|
||||
export const makeAuthenticatedRequest = (app: express.Application, token: string) => {
|
||||
return {
|
||||
get: (url: string) => request(app).get(url).set('Authorization', `Bearer ${token}`),
|
||||
post: (url: string) => request(app).post(url).set('Authorization', `Bearer ${token}`),
|
||||
put: (url: string) => request(app).put(url).set('Authorization', `Bearer ${token}`),
|
||||
delete: (url: string) => request(app).delete(url).set('Authorization', `Bearer ${token}`),
|
||||
patch: (url: string) => request(app).patch(url).set('Authorization', `Bearer ${token}`),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Common test data generators
|
||||
*/
|
||||
export const TestData = {
|
||||
user: (overrides: Partial<TestUser> = {}): TestUser => ({
|
||||
username: 'testuser',
|
||||
password: 'password123',
|
||||
isAdmin: false,
|
||||
...overrides,
|
||||
}),
|
||||
|
||||
adminUser: (overrides: Partial<TestUser> = {}): TestUser => ({
|
||||
username: 'admin',
|
||||
password: 'admin123',
|
||||
isAdmin: true,
|
||||
...overrides,
|
||||
}),
|
||||
|
||||
serverConfig: (overrides: any = {}) => ({
|
||||
type: 'openapi',
|
||||
openapi: {
|
||||
url: 'https://api.example.com/openapi.json',
|
||||
version: '3.1.0',
|
||||
security: {
|
||||
type: 'none',
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
...overrides,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock response helpers
|
||||
*/
|
||||
export const MockResponse = {
|
||||
success: (data: any = {}) => ({
|
||||
success: true,
|
||||
data,
|
||||
}),
|
||||
|
||||
error: (message: string, code = 400) => ({
|
||||
success: false,
|
||||
message,
|
||||
code,
|
||||
}),
|
||||
|
||||
validation: (errors: any[]) => ({
|
||||
success: false,
|
||||
errors,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Database test helpers
|
||||
*/
|
||||
export const DbHelpers = {
|
||||
/**
|
||||
* Clear all test data from database
|
||||
*/
|
||||
clearDatabase: async (): Promise<void> => {
|
||||
// TODO: Implement based on your database setup
|
||||
console.log('Clearing test database...');
|
||||
},
|
||||
|
||||
/**
|
||||
* Seed test data
|
||||
*/
|
||||
seedTestData: async (): Promise<void> => {
|
||||
// TODO: Implement based on your database setup
|
||||
console.log('Seeding test data...');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Wait for async operations to complete
|
||||
*/
|
||||
export const waitFor = (ms: number): Promise<void> => {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert API response structure
|
||||
*/
|
||||
export const expectApiResponse = (response: any) => ({
|
||||
toBeSuccess: (expectedData?: any) => {
|
||||
expect(response.body).toHaveProperty('success', true);
|
||||
if (expectedData) {
|
||||
expect(response.body.data).toEqual(expectedData);
|
||||
}
|
||||
},
|
||||
|
||||
toBeError: (expectedMessage?: string, expectedCode?: number) => {
|
||||
expect(response.body).toHaveProperty('success', false);
|
||||
if (expectedMessage) {
|
||||
expect(response.body.message).toContain(expectedMessage);
|
||||
}
|
||||
if (expectedCode) {
|
||||
expect(response.status).toBe(expectedCode);
|
||||
}
|
||||
},
|
||||
|
||||
toHaveValidationErrors: () => {
|
||||
expect(response.body).toHaveProperty('success', false);
|
||||
expect(response.body).toHaveProperty('errors');
|
||||
expect(Array.isArray(response.body.errors)).toBe(true);
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user