Compare commits

...

47 Commits

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

16
.coveragerc Normal file
View File

@@ -0,0 +1,16 @@
# Test coverage configuration
# This file tells Jest what to include/exclude from coverage reports
# Coverage patterns
- "src/**/*.{ts,tsx}"
# Exclusions
- "!src/**/*.d.ts"
- "!src/index.ts"
- "!src/**/__tests__/**"
- "!src/**/*.test.{ts,tsx}"
- "!src/**/*.spec.{ts,tsx}"
- "!**/node_modules/**"
- "!coverage/**"
- "!dist/**"
- "!build/**"

View File

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

50
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,50 @@
# MCPHub Coding Instructions
## Project Overview
MCPHub is a TypeScript/Node.js MCP server management hub that provides unified access through HTTP endpoints.
**Core Components:**
- **Backend**: Express.js + TypeScript + ESM (`src/server.ts`)
- **Frontend**: React/Vite + Tailwind CSS (`frontend/`)
- **MCP Integration**: Connects multiple MCP servers (`src/services/mcpService.ts`)
## Development Environment
```bash
pnpm install
pnpm dev # Start both backend and frontend
pnpm backend:dev # Backend only
pnpm frontend:dev # Frontend only
```
## Project Conventions
### File Structure
- `src/services/` - Core business logic
- `src/controllers/` - HTTP request handlers
- `src/types/index.ts` - TypeScript type definitions
- `src/config/index.ts` - Configuration management
### Key Notes
- Use ESM modules: Import with `.js` extensions, not `.ts`
- Configuration file: `mcp_settings.json`
- Endpoint formats: `/mcp/{group|server}` and `/mcp/$smart`
- All code comments must be written in English
- Frontend uses i18n with resource files in `locales/` folder
- Server-side code should use appropriate abstraction layers for extensibility and replaceability
## Development Process
- For complex features, implement step by step and wait for confirmation before proceeding to the next step
- After implementing features, no separate summary documentation is needed - update README.md and README.zh.md as appropriate
### Development Entry Points
- **MCP Servers**: Modify `src/services/mcpService.ts`
- **API Endpoints**: Add routes in `src/routes/`, controllers in `src/controllers/`
- **Frontend Features**: Start from `frontend/src/pages/`
- **Testing**: Follow existing patterns in `tests/`

View File

@@ -8,6 +8,9 @@ on:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
matrix:
variant: ${{ startsWith(github.ref, 'refs/tags/') && fromJSON('["base", "full"]') || fromJSON('["base"]') }}
@@ -30,16 +33,27 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
if: endsWith(github.repository, 'mcphub')
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
if: endsWith(github.repository, 'mcphubx')
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: samanhappy/mcphub
images: |
${{ endsWith(github.repository, 'mcphub') && github.repository || '' }}
${{ endsWith(github.repository, 'mcphubx') && format('ghcr.io/{0}', github.repository) || '' }}
tags: |
type=raw,value=edge${{ matrix.variant == 'full' && '-full' || '' }},enable=${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
type=semver,pattern={{version}}${{ matrix.variant == 'full' && '-full' || '' }},enable=${{ startsWith(github.ref, 'refs/tags/') }}
@@ -48,6 +62,7 @@ jobs:
latest=false
- name: Build and Push Docker Image
if: endsWith(github.repository, 'mcphub') || endsWith(github.repository, 'mcphubx')
uses: docker/build-push-action@v5
with:
context: .

112
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,112 @@
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Enable Corepack
run: corepack enable
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run linter
run: pnpm lint
- name: Run type checking
run: pnpm backend:build
- name: Run tests
run: pnpm test:ci
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
file: ./coverage/lcov.info
flags: unittests
name: codecov-umbrella
# build:
# runs-on: ubuntu-latest
# needs: test
# steps:
# - name: Checkout code
# uses: actions/checkout@v4
# - name: Setup Node.js
# uses: actions/setup-node@v4
# with:
# node-version: '20.x'
# - name: Enable Corepack
# run: corepack enable
# - name: Install dependencies
# run: pnpm install --frozen-lockfile
# - name: Build application
# run: pnpm build
# - name: Verify build artifacts
# run: node scripts/verify-dist.js
# integration-test:
# runs-on: ubuntu-latest
# needs: test
# services:
# postgres:
# image: postgres:15
# env:
# POSTGRES_PASSWORD: postgres
# POSTGRES_DB: mcphub_test
# options: >-
# --health-cmd pg_isready
# --health-interval 10s
# --health-timeout 5s
# --health-retries 5
# steps:
# - name: Checkout code
# uses: actions/checkout@v4
# - name: Setup Node.js
# uses: actions/setup-node@v4
# with:
# node-version: '20.x'
# - name: Enable Corepack
# run: corepack enable
# - name: Install dependencies
# run: pnpm install --frozen-lockfile
# - name: Build application
# run: pnpm build
# - name: Run integration tests
# run: |
# export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/mcphub_test"
# node test-integration.ts
# env:
# NODE_ENV: test

View File

@@ -7,6 +7,7 @@ on:
jobs:
publish-npm:
runs-on: ubuntu-latest
if: endsWith(github.repository, 'mcphub')
steps:
- name: Checkout
uses: actions/checkout@v4

2
.gitignore vendored
View File

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

View File

@@ -21,6 +21,9 @@ ENV REQUEST_TIMEOUT=$REQUEST_TIMEOUT
ARG BASE_PATH=""
ENV BASE_PATH=$BASE_PATH
ARG READONLY=false
ENV READONLY=$READONLY
ENV PNPM_HOME=/usr/local/share/pnpm
ENV PATH=$PNPM_HOME:$PATH
RUN mkdir -p $PNPM_HOME && \

View File

@@ -57,7 +57,7 @@ Create a `mcp_settings.json` file to customize your server settings:
**Recommended**: Mount your custom config:
```bash
docker run -p 3000:3000 -v $(pwd)/mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
docker run -p 3000:3000 -v ./mcp_settings.json:/app/mcp_settings.json -v ./data:/app/data samanhappy/mcphub
```
or run with default settings:

View File

@@ -57,7 +57,7 @@ MCPHub 通过将多个 MCPModel Context Protocol服务器组织为灵活
**推荐**:挂载自定义配置:
```bash
docker run -p 3000:3000 -v $(pwd)/mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
docker run -p 3000:3000 -v ./mcp_settings.json:/app/mcp_settings.json -v ./data:/app/data samanhappy/mcphub
```
或使用默认配置运行:

View File

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

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

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

172
docs/testing-framework.md Normal file
View File

@@ -0,0 +1,172 @@
# 测试框架和自动化测试实现报告
## 概述
本项目已成功引入现代化的测试框架和自动化测试流程。实现了基于Jest的测试环境支持TypeScript、ES模块并包含完整的CI/CD配置。
## 已实现的功能
### 1. 测试框架配置
- **Jest配置**: 使用`jest.config.cjs`配置文件支持ES模块和TypeScript
- **覆盖率报告**: 配置了代码覆盖率收集和报告
- **测试环境**: 支持Node.js环境的单元测试和集成测试
- **模块映射**: 配置了路径别名支持
### 2. 测试工具和辅助函数
创建了完善的测试工具库 (`tests/utils/testHelpers.ts`):
- **认证工具**: JWT token生成和管理
- **HTTP测试**: Supertest集成用于API测试
- **数据生成**: 测试数据工厂函数
- **响应断言**: 自定义API响应验证器
- **环境管理**: 测试环境变量配置
### 3. 测试用例实现
已实现的测试场景:
#### 基础配置测试 (`tests/basic.test.ts`)
- Jest配置验证
- 异步操作支持测试
- 自定义匹配器验证
#### 认证逻辑测试 (`tests/auth.logic.test.ts`)
- 用户登录逻辑
- 密码验证
- JWT生成和验证
- 错误处理场景
- 用户数据验证
#### 路径工具测试 (`tests/utils/pathLogic.test.ts`)
- 配置文件路径解析
- 环境变量处理
- 文件系统操作
- 错误处理和边界条件
- 跨平台路径处理
### 4. CI/CD配置
GitHub Actions配置 (`.github/workflows/ci.yml`):
- **多Node.js版本支持**: 18.x和20.x
- **自动化测试流程**:
- 代码检查 (ESLint)
- 类型检查 (TypeScript)
- 单元测试执行
- 覆盖率报告
- **构建验证**: 应用构建和产物验证
- **集成测试**: 包含数据库环境的集成测试
### 5. 测试脚本
`package.json`中添加的测试命令:
```json
{
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:verbose": "jest --verbose",
"test:ci": "jest --ci --coverage --watchAll=false"
}
```
## 测试结果
当前测试统计:
- **测试套件**: 3个
- **测试用例**: 19个
- **通过率**: 100%
- **执行时间**: ~15秒
### 测试覆盖的功能模块
1. **认证系统**: 用户登录、JWT处理、密码验证
2. **配置管理**: 文件路径解析、环境变量处理
3. **基础设施**: Jest配置、测试工具验证
## 技术特点
### 现代化特性
- **ES模块支持**: 完全支持ES2022模块语法
- **TypeScript集成**: 类型安全的测试编写
- **异步测试**: Promise和async/await支持
- **模拟系统**: Jest mock功能的深度使用
- **参数化测试**: 数据驱动的测试用例
### 最佳实践
- **测试隔离**: 每个测试用例独立运行
- **Mock管理**: 统一的mock清理和重置
- **错误处理**: 完整的错误场景测试
- **边界测试**: 输入验证和边界条件覆盖
- **文档化**: 清晰的测试用例命名和描述
## 后续扩展计划
### 短期目标
1. **API测试**: 为REST API端点添加集成测试
2. **数据库测试**: 添加数据模型和存储层测试
3. **中间件测试**: 认证和权限中间件测试
4. **服务层测试**: 核心业务逻辑测试
### 中期目标
1. **端到端测试**: 使用Playwright或Cypress
2. **性能测试**: API响应时间和负载测试
3. **安全测试**: 输入验证和安全漏洞测试
4. **契约测试**: API契约验证
### 长期目标
1. **测试数据管理**: 测试数据库和fixture管理
2. **视觉回归测试**: UI组件的视觉测试
3. **监控集成**: 生产环境测试监控
4. **自动化测试报告**: 详细的测试报告和趋势分析
## 开发指南
### 添加新测试用例
1.`tests/`目录下创建对应的测试文件
2. 使用`testHelpers.ts`中的工具函数
3. 遵循命名约定: `*.test.ts``*.spec.ts`
4. 确保测试用例具有清晰的描述和断言
### 运行测试
```bash
# 运行所有测试
pnpm test
# 监听模式
pnpm test:watch
# 生成覆盖率报告
pnpm test:coverage
# CI模式运行
pnpm test:ci
```
### Mock最佳实践
-`beforeEach`中清理所有mock
- 使用具体的mock实现而不是空函数
- 验证mock被正确调用
- 保持mock的一致性和可维护性
## 结论
本项目已成功建立了完整的现代化测试框架,具备以下优势:
1. **高度可扩展**: 易于添加新的测试用例和测试类型
2. **开发友好**: 丰富的工具函数和清晰的结构
3. **CI/CD就绪**: 完整的自动化流水线配置
4. **质量保证**: 代码覆盖率和持续测试验证
这个测试框架为项目的持续发展和质量保证提供了坚实的基础,支持敏捷开发和持续集成的最佳实践。

View File

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

View File

@@ -1,13 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCP Hub Dashboard</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
</head>
<body class="bg-gray-100">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -9,6 +9,7 @@ import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/Dashboard';
import ServersPage from './pages/ServersPage';
import GroupsPage from './pages/GroupsPage';
import UsersPage from './pages/UsersPage';
import SettingsPage from './pages/SettingsPage';
import MarketPage from './pages/MarketPage';
import LogsPage from './pages/LogsPage';
@@ -31,6 +32,7 @@ function App() {
<Route path="/" element={<DashboardPage />} />
<Route path="/servers" element={<ServersPage />} />
<Route path="/groups" element={<GroupsPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/market" element={<MarketPage />} />
<Route path="/market/:serverName" element={<MarketPage />} />
<Route path="/logs" element={<LogsPage />} />

View File

@@ -2,8 +2,8 @@ import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useGroupData } from '@/hooks/useGroupData'
import { useServerData } from '@/hooks/useServerData'
import { GroupFormData, Server } from '@/types'
import { ToggleGroup } from './ui/ToggleGroup'
import { GroupFormData, Server, IGroupServerConfig } from '@/types'
import { ServerToolConfig } from './ServerToolConfig'
interface AddGroupFormProps {
onAdd: () => void
@@ -21,7 +21,7 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
const [formData, setFormData] = useState<GroupFormData>({
name: '',
description: '',
servers: []
servers: [] as IGroupServerConfig[]
})
useEffect(() => {
@@ -50,9 +50,8 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
}
const result = await createGroup(formData.name, formData.description, formData.servers)
if (!result) {
setError(t('groups.createError'))
if (!result || !result.success) {
setError(result?.message || t('groups.createError'))
setIsSubmitting(false)
return
}
@@ -66,64 +65,68 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
<div className="p-6">
<div className="bg-white rounded-lg shadow-lg max-w-2xl w-full max-h-[90vh] flex flex-col">
<div className="p-6 flex-shrink-0">
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('groups.addNew')}</h2>
{error && (
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded-md border border-gray-200">
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="name">
{t('groups.name')} *
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
placeholder={t('groups.namePlaceholder')}
required
/>
</div>
<ToggleGroup
className="mb-6"
label={t('groups.servers')}
noOptionsText={t('groups.noServerOptions')}
values={formData.servers}
options={availableServers.map(server => ({
value: server.name,
label: server.name
}))}
onChange={(servers) => setFormData(prev => ({ ...prev, servers }))}
/>
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
disabled={isSubmitting}
>
{t('common.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
disabled={isSubmitting}
>
{isSubmitting ? t('common.submitting') : t('common.create')}
</button>
</div>
</form>
</div>
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
<div className="flex-1 overflow-y-auto px-6">
<div className="space-y-4">
<div>
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="name">
{t('groups.name')} *
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder={t('groups.namePlaceholder')}
required
/>
</div>
<div>
<label className="block text-gray-700 text-sm font-bold mb-2">
{t('groups.configureTools')}
</label>
<ServerToolConfig
servers={availableServers}
value={formData.servers as IGroupServerConfig[]}
onChange={(servers) => setFormData(prev => ({ ...prev, servers }))}
className="border border-gray-200 rounded-lg p-4 bg-gray-50"
/>
</div>
</div>
</div>
<div className="flex justify-end space-x-3 p-6 pt-4 border-t border-gray-200 flex-shrink-0">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-gray-600 hover:text-gray-800 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
disabled={isSubmitting}
>
{t('common.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:opacity-50 transition-colors"
disabled={isSubmitting}
>
{isSubmitting ? t('common.submitting') : t('common.create')}
</button>
</div>
</form>
</div>
</div>
)

View File

@@ -1,7 +1,8 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ServerForm from './ServerForm'
import { getApiUrl } from '../utils/runtime';
import { apiPost } from '../utils/fetchInterceptor'
import { detectVariables } from '../utils/variableDetection'
interface AddServerFormProps {
onAdd: () => void
@@ -11,35 +12,34 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
const { t } = useTranslation()
const [modalVisible, setModalVisible] = useState(false)
const [error, setError] = useState<string | null>(null)
const [confirmationVisible, setConfirmationVisible] = useState(false)
const [pendingPayload, setPendingPayload] = useState<any>(null)
const [detectedVariables, setDetectedVariables] = useState<string[]>([])
const toggleModal = () => {
setModalVisible(!modalVisible)
setError(null) // Clear any previous errors when toggling modal
setConfirmationVisible(false) // Close confirmation dialog
setPendingPayload(null) // Clear pending payload
}
const handleSubmit = async (payload: any) => {
const handleConfirmSubmit = async () => {
if (pendingPayload) {
await submitServer(pendingPayload)
setConfirmationVisible(false)
setPendingPayload(null)
}
}
const submitServer = async (payload: any) => {
try {
setError(null)
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/servers'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || ''
},
body: JSON.stringify(payload),
})
const result = await apiPost('/servers', payload)
const result = await response.json()
if (!response.ok) {
if (!result.success) {
// Use specific error message from the response if available
if (result && result.message) {
setError(result.message)
} else if (response.status === 400) {
setError(t('server.invalidData'))
} else if (response.status === 409) {
setError(t('server.alreadyExists', { serverName: payload.name }))
} else {
setError(t('server.addError'))
}
@@ -65,11 +65,31 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
}
}
const handleSubmit = async (payload: any) => {
try {
// Check for variables in the payload
const variables = detectVariables(payload)
if (variables.length > 0) {
// Show confirmation dialog
setDetectedVariables(variables)
setPendingPayload(payload)
setConfirmationVisible(true)
} else {
// Submit directly if no variables found
await submitServer(payload)
}
} catch (err) {
console.error('Error processing server submission:', err)
setError(t('errors.serverAdd'))
}
}
return (
<div>
<button
onClick={toggleModal}
className="w-full bg-blue-100 text-blue-800 rounded hover:bg-blue-200 py-2 px-4 flex items-center justify-center"
className="w-full bg-blue-100 text-blue-800 rounded hover:bg-blue-200 py-2 px-4 flex items-center justify-center btn-primary"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
@@ -87,6 +107,60 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
/>
</div>
)}
{confirmationVisible && (
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center p-4">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{t('server.confirmVariables')}
</h3>
<p className="text-gray-600 mb-4">
{t('server.variablesDetected')}
</p>
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 mb-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h4 className="text-sm font-medium text-yellow-800">
{t('server.detectedVariables')}:
</h4>
<ul className="mt-1 text-sm text-yellow-700">
{detectedVariables.map((variable, index) => (
<li key={index} className="font-mono">
${`{${variable}}`}
</li>
))}
</ul>
</div>
</div>
</div>
<p className="text-gray-600 text-sm mb-6">
{t('server.confirmVariablesMessage')}
</p>
<div className="flex justify-end space-x-3">
<button
onClick={() => {
setConfirmationVisible(false)
setPendingPayload(null)
}}
className="px-4 py-2 text-gray-600 border border-gray-300 rounded hover:bg-gray-50 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={handleConfirmSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 btn-primary"
>
{t('server.confirmAndAdd')}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -31,17 +31,17 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
// Validate passwords match
if (formData.newPassword !== confirmPassword) {
setError(t('auth.passwordsNotMatch'));
return;
}
setIsLoading(true);
try {
const response = await changePassword(formData);
if (response.success) {
setSuccess(true);
if (onSuccess) {
@@ -60,7 +60,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
return (
<div className="p-6 bg-white rounded-lg shadow-md">
<h2 className="text-xl font-bold mb-4">{t('auth.changePassword')}</h2>
{success ? (
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
{t('auth.changePasswordSuccess')}
@@ -72,7 +72,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
{error}
</div>
)}
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="currentPassword">
{t('auth.currentPassword')}
@@ -81,13 +81,13 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
type="password"
id="currentPassword"
name="currentPassword"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
value={formData.currentPassword}
onChange={handleChange}
required
/>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="newPassword">
{t('auth.newPassword')}
@@ -96,14 +96,14 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
type="password"
id="newPassword"
name="newPassword"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
value={formData.newPassword}
onChange={handleChange}
required
minLength={6}
/>
</div>
<div className="mb-6">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="confirmPassword">
{t('auth.confirmPassword')}
@@ -112,14 +112,14 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
type="password"
id="confirmPassword"
name="confirmPassword"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
value={confirmPassword}
onChange={handleChange}
required
minLength={6}
/>
</div>
<div className="flex justify-end space-x-2">
{onCancel && (
<button
@@ -134,7 +134,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
<button
type="submit"
disabled={isLoading}
className="py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
className="py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 btn-primary"
>
{isLoading ? (
<span className="flex items-center">

View File

@@ -0,0 +1,394 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { apiPost, apiGet, apiPut, fetchWithInterceptors } from '@/utils/fetchInterceptor';
import { getApiUrl } from '@/utils/runtime';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
interface DxtUploadFormProps {
onSuccess: (serverConfig: any) => void;
onCancel: () => void;
}
interface DxtUploadResponse {
success: boolean;
data?: {
manifest: any;
extractDir: string;
};
message?: string;
}
const DxtUploadForm: React.FC<DxtUploadFormProps> = ({ onSuccess, onCancel }) => {
const { t } = useTranslation();
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [showServerForm, setShowServerForm] = useState(false);
const [manifestData, setManifestData] = useState<any>(null);
const [extractDir, setExtractDir] = useState<string>('');
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [pendingServerName, setPendingServerName] = useState<string>('');
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const files = e.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
if (file.name.endsWith('.dxt')) {
setSelectedFile(file);
setError(null);
} else {
setError(t('dxt.invalidFileType'));
}
}
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
const file = files[0];
if (file.name.endsWith('.dxt')) {
setSelectedFile(file);
setError(null);
} else {
setError(t('dxt.invalidFileType'));
}
}
};
const handleUpload = async () => {
if (!selectedFile) {
setError(t('dxt.noFileSelected'));
return;
}
setIsUploading(true);
setError(null);
try {
const formData = new FormData();
formData.append('dxtFile', selectedFile);
const response = await fetchWithInterceptors(getApiUrl('/dxt/upload'), {
method: 'POST',
body: formData,
});
const result: DxtUploadResponse = await response.json();
if (!response.ok) {
throw new Error(result.message || `HTTP error! Status: ${response.status}`);
}
if (result.success && result.data) {
setManifestData(result.data.manifest);
setExtractDir(result.data.extractDir);
setShowServerForm(true);
} else {
throw new Error(result.message || t('dxt.uploadFailed'));
}
} catch (err) {
console.error('DXT upload error:', err);
setError(err instanceof Error ? err.message : t('dxt.uploadFailed'));
} finally {
setIsUploading(false);
}
};
const handleInstallServer = async (serverName: string, forceOverride: boolean = false) => {
setIsUploading(true);
setError(null);
try {
// Convert DXT manifest to MCPHub stdio server configuration
const serverConfig = convertDxtToMcpConfig(manifestData, extractDir, serverName);
// First, check if server exists
if (!forceOverride) {
const checkResult = await apiGet('/servers');
if (checkResult.success) {
const existingServer = checkResult.data?.find((server: any) => server.name === serverName);
if (existingServer) {
// Server exists, show confirmation dialog
setPendingServerName(serverName);
setShowConfirmDialog(true);
setIsUploading(false);
return;
}
}
}
// Install or override the server
let result;
if (forceOverride) {
result = await apiPut(`/servers/${encodeURIComponent(serverName)}`, {
name: serverName,
config: serverConfig,
});
} else {
result = await apiPost('/servers', {
name: serverName,
config: serverConfig,
});
}
if (result.success) {
onSuccess(serverConfig);
} else {
throw new Error(result.message || t('dxt.installFailed'));
}
} catch (err) {
console.error('DXT install error:', err);
setError(err instanceof Error ? err.message : t('dxt.installFailed'));
setIsUploading(false);
}
};
const handleConfirmOverride = () => {
setShowConfirmDialog(false);
if (pendingServerName) {
handleInstallServer(pendingServerName, true);
}
};
const handleCancelOverride = () => {
setShowConfirmDialog(false);
setPendingServerName('');
setIsUploading(false);
};
const convertDxtToMcpConfig = (manifest: any, extractPath: string, _serverName: string) => {
const mcpConfig = manifest.server?.mcp_config || {};
// Convert DXT manifest to MCPHub stdio configuration
const config: any = {
type: 'stdio',
command: mcpConfig.command || 'node',
args: (mcpConfig.args || []).map((arg: string) =>
arg.replace('${__dirname}', extractPath)
),
};
// Add environment variables if they exist
if (mcpConfig.env && Object.keys(mcpConfig.env).length > 0) {
config.env = { ...mcpConfig.env };
// Replace ${__dirname} in environment variables
Object.keys(config.env).forEach(key => {
if (typeof config.env[key] === 'string') {
config.env[key] = config.env[key].replace('${__dirname}', extractPath);
}
});
}
return config;
};
if (showServerForm && manifestData) {
return (
<>
<ConfirmDialog
isOpen={showConfirmDialog}
onClose={handleCancelOverride}
onConfirm={handleConfirmOverride}
title={t('dxt.serverExistsTitle')}
message={t('dxt.serverExistsConfirm', { serverName: pendingServerName })}
confirmText={t('dxt.override')}
cancelText={t('common.cancel')}
variant="warning"
/>
<div className={`fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 ${showConfirmDialog ? 'opacity-50 pointer-events-none' : ''}`}>
<div className="bg-white shadow rounded-lg p-6 w-full max-w-2xl max-h-screen overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-gray-900">{t('dxt.installServer')}</h2>
<button onClick={onCancel} className="text-gray-500 hover:text-gray-700">
</button>
</div>
{error && (
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4 rounded">
<p className="text-red-700">{error}</p>
</div>
)}
<div className="space-y-6">
{/* Extension Info */}
<div className="bg-gray-50 p-4 rounded-lg">
<h3 className="font-medium text-gray-900 mb-2">{t('dxt.extensionInfo')}</h3>
<div className="space-y-2 text-sm">
<div><strong>{t('dxt.name')}:</strong> {manifestData.display_name || manifestData.name}</div>
<div><strong>{t('dxt.version')}:</strong> {manifestData.version}</div>
<div><strong>{t('dxt.description')}:</strong> {manifestData.description}</div>
{manifestData.author && (
<div><strong>{t('dxt.author')}:</strong> {manifestData.author.name}</div>
)}
{manifestData.tools && manifestData.tools.length > 0 && (
<div>
<strong>{t('dxt.tools')}:</strong>
<ul className="list-disc list-inside ml-4">
{manifestData.tools.map((tool: any, index: number) => (
<li key={index}>{tool.name} - {tool.description}</li>
))}
</ul>
</div>
)}
</div>
</div>
{/* Server Configuration */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('dxt.serverName')}
</label>
<input
type="text"
id="serverName"
defaultValue={manifestData.name}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
placeholder={t('dxt.serverNamePlaceholder')}
/>
</div>
{/* Action Buttons */}
<div className="flex justify-end space-x-4">
<button
onClick={onCancel}
disabled={isUploading}
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={() => {
const nameInput = document.getElementById('serverName') as HTMLInputElement;
const serverName = nameInput?.value.trim() || manifestData.name;
handleInstallServer(serverName);
}}
disabled={isUploading}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center btn-primary"
>
{isUploading ? (
<>
<svg className="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{t('dxt.installing')}
</>
) : (
t('dxt.install')
)}
</button>
</div>
</div>
</div>
</div>
</>
);
}
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white shadow rounded-lg p-6 w-full max-w-lg">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-gray-900">{t('dxt.uploadTitle')}</h2>
<button onClick={onCancel} className="text-gray-500 hover:text-gray-700">
</button>
</div>
{error && (
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4 rounded">
<p className="text-red-700">{error}</p>
</div>
)}
{/* File Drop Zone */}
<div
className={`relative border-2 border-dashed rounded-lg p-8 text-center transition-colors ${isDragging
? 'border-blue-500 bg-blue-50'
: selectedFile
? 'border-gray-500 '
: 'border-gray-300 hover:border-gray-400'
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{selectedFile ? (
<div className="space-y-2">
<svg className="mx-auto h-12 w-12 text-green-200" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-sm text-gray-900 font-medium">{selectedFile.name}</p>
<p className="text-xs text-gray-500">{(selectedFile.size / 1024 / 1024).toFixed(2)} MB</p>
</div>
) : (
<div className="space-y-2">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<div>
<p className="text-sm text-gray-900">{t('dxt.dropFileHere')}</p>
<p className="text-xs text-gray-500">{t('dxt.orClickToSelect')}</p>
</div>
</div>
)}
<input
type="file"
accept=".dxt"
onChange={handleFileSelect}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
</div>
<div className="mt-6 flex justify-end space-x-4">
<button
onClick={onCancel}
disabled={isUploading}
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={handleUpload}
disabled={!selectedFile || isUploading}
className="px-4 py-2 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center btn-primary"
>
{isUploading ? (
<>
<svg className="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{t('dxt.uploading')}
</>
) : (
t('dxt.upload')
)}
</button>
</div>
</div>
</div>
);
};
export default DxtUploadForm;

View File

@@ -1,9 +1,9 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Group, GroupFormData, Server } from '@/types'
import { Group, GroupFormData, Server, IGroupServerConfig } from '@/types'
import { useGroupData } from '@/hooks/useGroupData'
import { useServerData } from '@/hooks/useServerData'
import { ToggleGroup } from './ui/ToggleGroup'
import { ServerToolConfig } from './ServerToolConfig'
interface EditGroupFormProps {
group: Group
@@ -38,18 +38,6 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
}))
}
const handleServerToggle = (serverName: string) => {
setFormData(prev => {
const isSelected = prev.servers.includes(serverName)
return {
...prev,
servers: isSelected
? prev.servers.filter(name => name !== serverName)
: [...prev.servers, serverName]
}
})
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
@@ -67,9 +55,9 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
description: formData.description,
servers: formData.servers
})
if (!result) {
setError(t('groups.updateError'))
if (!result || !result.success) {
setError(result?.message || t('groups.updateError'))
setIsSubmitting(false)
return
}
@@ -83,64 +71,68 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
<div className="p-6">
<div className="bg-white rounded-lg shadow-lg max-w-2xl w-full max-h-[90vh] flex flex-col">
<div className="p-6 flex-shrink-0">
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('groups.edit')}</h2>
{error && (
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded-md border border-gray-200">
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="name">
{t('groups.name')} *
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
placeholder={t('groups.namePlaceholder')}
required
/>
</div>
<ToggleGroup
className="mb-6"
label={t('groups.servers')}
noOptionsText={t('groups.noServerOptions')}
values={formData.servers}
options={availableServers.map(server => ({
value: server.name,
label: server.name
}))}
onChange={(servers) => setFormData(prev => ({ ...prev, servers }))}
/>
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
disabled={isSubmitting}
>
{t('common.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
disabled={isSubmitting}
>
{isSubmitting ? t('common.submitting') : t('common.save')}
</button>
</div>
</form>
</div>
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
<div className="flex-1 overflow-y-auto px-6">
<div className="space-y-4">
<div>
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="name">
{t('groups.name')} *
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder={t('groups.namePlaceholder')}
required
/>
</div>
<div>
<label className="block text-gray-700 text-sm font-bold mb-2">
{t('groups.configureTools')}
</label>
<ServerToolConfig
servers={availableServers}
value={formData.servers as IGroupServerConfig[]}
onChange={(servers) => setFormData(prev => ({ ...prev, servers }))}
className="border border-gray-200 rounded-lg p-4 bg-gray-50"
/>
</div>
</div>
</div>
<div className="flex justify-end space-x-3 p-6 pt-4 border-t border-gray-200 flex-shrink-0">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-gray-600 hover:text-gray-800 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
disabled={isSubmitting}
>
{t('common.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:opacity-50 transition-colors"
disabled={isSubmitting}
>
{isSubmitting ? t('common.submitting') : t('common.save')}
</button>
</div>
</form>
</div>
</div>
)

View File

@@ -1,7 +1,7 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Server } from '@/types'
import { getApiUrl } from '../utils/runtime'
import { apiPut } from '../utils/fetchInterceptor'
import ServerForm from './ServerForm'
interface EditServerFormProps {
@@ -17,26 +17,12 @@ const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => {
const handleSubmit = async (payload: any) => {
try {
setError(null)
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/servers/${server.name}`), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || ''
},
body: JSON.stringify(payload),
})
const result = await apiPut(`/servers/${server.name}`, payload)
const result = await response.json()
if (!response.ok) {
if (!result.success) {
// Use specific error message from the response if available
if (result && result.message) {
setError(result.message)
} else if (response.status === 404) {
setError(t('server.notFound', { serverName: server.name }))
} else if (response.status === 400) {
setError(t('server.invalidData'))
} else {
setError(t('server.updateError', { serverName: server.name }))
}

View File

@@ -1,9 +1,10 @@
import { useState } from 'react'
import { useState, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Group, Server } from '@/types'
import { Edit, Trash, Copy, Check } from '@/components/icons/LucideIcons'
import { Group, Server, IGroupServerConfig } from '@/types'
import { Edit, Trash, Copy, Check, Link, FileCode, DropdownIcon, Wrench } from '@/components/icons/LucideIcons'
import DeleteDialog from '@/components/ui/DeleteDialog'
import { useToast } from '@/contexts/ToastContext'
import { useSettingsData } from '@/hooks/useSettingsData'
interface GroupCardProps {
group: Group
@@ -20,8 +21,26 @@ const GroupCard = ({
}: GroupCardProps) => {
const { t } = useTranslation()
const { showToast } = useToast()
const { installConfig } = useSettingsData()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [copied, setCopied] = useState(false)
const [showCopyDropdown, setShowCopyDropdown] = useState(false)
const [expandedServer, setExpandedServer] = useState<string | null>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setShowCopyDropdown(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [])
const handleEdit = () => {
onEdit(group)
@@ -36,16 +55,18 @@ const GroupCard = ({
setShowDeleteDialog(false)
}
const copyToClipboard = () => {
const copyToClipboard = (text: string) => {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(group.id).then(() => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true)
setShowCopyDropdown(false)
showToast(t('common.copySuccess'), 'success')
setTimeout(() => setCopied(false), 2000)
})
} else {
// Fallback for HTTP or unsupported clipboard API
const textArea = document.createElement('textarea')
textArea.value = group.id
textArea.value = text
// Avoid scrolling to bottom
textArea.style.position = 'fixed'
textArea.style.left = '-9999px'
@@ -55,6 +76,8 @@ const GroupCard = ({
try {
document.execCommand('copy')
setCopied(true)
setShowCopyDropdown(false)
showToast(t('common.copySuccess'), 'success')
setTimeout(() => setCopied(false), 2000)
} catch (err) {
showToast(t('common.copyFailed') || 'Copy failed', 'error')
@@ -64,24 +87,92 @@ const GroupCard = ({
}
}
const handleCopyId = () => {
copyToClipboard(group.id)
}
const handleCopyUrl = () => {
copyToClipboard(`${installConfig.baseUrl}/mcp/${group.id}`)
}
const handleCopyJson = () => {
const jsonConfig = {
mcpServers: {
mcphub: {
url: `${installConfig.baseUrl}/mcp/${group.id}`,
headers: {
Authorization: "Bearer <your-access-token>"
}
}
}
}
copyToClipboard(JSON.stringify(jsonConfig, null, 2))
}
// Helper function to normalize group servers to get server names
const getServerNames = (servers: string[] | IGroupServerConfig[]): string[] => {
return servers.map(server => typeof server === 'string' ? server : server.name);
};
// Helper function to get server configuration
const getServerConfig = (serverName: string): IGroupServerConfig | undefined => {
const server = group.servers.find(s =>
typeof s === 'string' ? s === serverName : s.name === serverName
);
if (typeof server === 'string') {
return { name: server, tools: 'all' };
}
return server;
};
// Get servers that belong to this group
const groupServers = servers.filter(server => group.servers.includes(server.name))
const serverNames = getServerNames(group.servers);
const groupServers = servers.filter(server => serverNames.includes(server.name));
return (
<div className="bg-white shadow rounded-lg p-6">
<div className="bg-white shadow rounded-lg p-6 ">
<div className="flex justify-between items-center mb-4">
<div>
<div className="flex items-center">
<h2 className="text-xl font-semibold text-gray-800">{group.name}</h2>
<div className="flex items-center ml-3">
<span className="text-xs text-gray-500 mr-1">{group.id}</span>
<button
onClick={copyToClipboard}
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
title={t('common.copy')}
>
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
</button>
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setShowCopyDropdown(!showCopyDropdown)}
className="p-1 text-gray-400 hover:text-gray-600 transition-colors flex items-center"
title={t('common.copy')}
>
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
<DropdownIcon size={12} className="ml-1" />
</button>
{showCopyDropdown && (
<div className="absolute top-full left-0 mt-1 bg-white shadow-lg rounded-md border border-gray-200 py-1 z-10 min-w-[140px]">
<button
onClick={handleCopyId}
className="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center"
>
<Copy size={12} className="mr-2" />
{t('common.copyId')}
</button>
<button
onClick={handleCopyUrl}
className="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center"
>
<Link size={12} className="mr-2" />
{t('common.copyUrl')}
</button>
<button
onClick={handleCopyJson}
className="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center"
>
<FileCode size={12} className="mr-2" />
{t('common.copyJson')}
</button>
</div>
)}
</div>
</div>
</div>
{group.description && (
@@ -89,7 +180,7 @@ const GroupCard = ({
)}
</div>
<div className="flex items-center space-x-3">
<div className="bg-blue-50 text-blue-700 px-3 py-1 rounded-full text-sm">
<div className="bg-blue-50 text-blue-700 px-3 py-1 rounded-full text-sm btn-secondary">
{t('groups.serverCount', { count: group.servers.length })}
</div>
<button
@@ -113,18 +204,68 @@ const GroupCard = ({
{groupServers.length === 0 ? (
<p className="text-gray-500 italic">{t('groups.noServers')}</p>
) : (
<div className="flex flex-wrap gap-2 mt-2">
{groupServers.map(server => (
<div
key={server.name}
className="inline-flex items-center px-3 py-1 bg-gray-50 rounded"
>
<span className="font-medium text-gray-700 text-sm">{server.name}</span>
<span className={`ml-2 inline-block h-2 w-2 rounded-full ${server.status === 'connected' ? 'bg-green-500' :
server.status === 'connecting' ? 'bg-yellow-500' : 'bg-red-500'
}`}></span>
</div>
))}
<div className="flex flex-wrap gap-2">
{groupServers.map(server => {
const serverConfig = getServerConfig(server.name);
const hasToolRestrictions = serverConfig && serverConfig.tools !== 'all' && Array.isArray(serverConfig.tools);
const toolCount = hasToolRestrictions && Array.isArray(serverConfig?.tools)
? serverConfig.tools.length
: (server.tools?.length || 0); // Show total tool count when all tools are selected
const isExpanded = expandedServer === server.name;
// Get tools list for display
const getToolsList = () => {
if (hasToolRestrictions && Array.isArray(serverConfig?.tools)) {
return serverConfig.tools;
} else if (server.tools && server.tools.length > 0) {
return server.tools.map(tool => tool.name);
}
return [];
};
const handleServerClick = () => {
setExpandedServer(isExpanded ? null : server.name);
};
return (
<div key={server.name} className="relative">
<div
className="flex items-center space-x-2 bg-gray-50 rounded-lg px-3 py-2 cursor-pointer hover:bg-gray-100 transition-colors"
onClick={handleServerClick}
>
<span className="font-medium text-gray-700 text-sm">{server.name}</span>
<span className={`inline-block h-2 w-2 rounded-full ${server.status === 'connected' ? 'bg-green-500' :
server.status === 'connecting' ? 'bg-yellow-500' : 'bg-red-500'
}`}></span>
{toolCount > 0 && (
<span className="text-xs text-blue-600 bg-blue-100 px-2 py-0.5 rounded flex items-center gap-1">
<Wrench size={12} />
{toolCount}
</span>
)}
</div>
{isExpanded && (
<div className="absolute top-full left-0 mt-1 bg-white shadow-lg rounded-md border border-gray-200 p-3 z-10 min-w-[300px] max-w-[400px]">
<div className="text-gray-600 text-xs mb-2">
{hasToolRestrictions ? t('groups.selectedTools') : t('groups.allTools')}:
</div>
<div className="flex flex-wrap gap-1">
{getToolsList().map((toolName, index) => (
<span
key={index}
className="inline-block bg-gray-100 text-gray-700 px-2 py-1 rounded text-xs"
>
{toolName}
</span>
))}
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>

View File

@@ -48,25 +48,26 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
// Get badge color based on log type
const getLogTypeColor = (type: string) => {
switch (type) {
case 'error': return 'bg-red-400';
case 'warn': return 'bg-yellow-400';
case 'debug': return 'bg-purple-400';
default: return 'bg-blue-400';
case 'error': return 'bg-red-400/80 text-white';
case 'warn': return 'bg-yellow-400/80 text-gray-900';
case 'debug': return 'bg-purple-400/80 text-white';
case 'info': return 'bg-blue-400/80 text-white';
default: return 'bg-blue-400/80 text-white';
}
};
// Get badge color based on log source
const getSourceColor = (source: string) => {
switch (source) {
case 'main': return 'bg-green-400';
case 'child': return 'bg-orange-400';
default: return 'bg-gray-400';
case 'main': return 'bg-green-400/80 text-white';
case 'child': return 'bg-orange-400/80 text-white';
default: return 'bg-gray-400/80 text-white';
}
};
return (
<div className="flex flex-col h-full">
<div className="bg-card p-3 rounded-t-md border-b flex flex-wrap items-center justify-between gap-2">
<div className="bg-card p-3 rounded-t-md flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-sm">{t('logs.filters')}:</span>
@@ -74,14 +75,14 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
<input
type="text"
placeholder={t('logs.search')}
className="px-2 py-1 text-sm border rounded"
className="shadow appearance-none border border-gray-200 rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
{/* Log type filters */}
<div className="flex gap-1 items-center">
{(['info', 'error', 'warn', 'debug'] as const).map(type => (
{(['debug', 'info', 'error', 'warn'] as const).map(type => (
<Badge
key={type}
variant={typeFilter.includes(type) ? 'default' : 'outline'}
@@ -134,6 +135,7 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
variant="outline"
size="sm"
onClick={onClear}
className='btn-secondary'
disabled={isLoading || logs.length === 0}
>
{t('logs.clearLogs')}
@@ -164,7 +166,7 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
filteredLogs.map((log, index) => (
<div
key={`${log.timestamp}-${index}`}
className={`py-1 border-b border-gray-100 dark:border-gray-800 ${log.type === 'error' ? 'text-red-500' :
className={`py-1 ${log.type === 'error' ? 'text-red-500' :
log.type === 'warn' ? 'text-yellow-500' : ''
}`}
>

View File

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

View File

@@ -2,11 +2,14 @@ import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { MarketServer, MarketServerInstallation } from '@/types';
import ServerForm from './ServerForm';
import { detectVariables } from '../utils/variableDetection';
import { ServerConfig } from '@/types';
interface MarketServerDetailProps {
server: MarketServer;
onBack: () => void;
onInstall: (server: MarketServer) => void;
onInstall: (server: MarketServer, config: ServerConfig) => void;
installing?: boolean;
isInstalled?: boolean;
}
@@ -21,6 +24,9 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
const { t } = useTranslation();
const [modalVisible, setModalVisible] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirmationVisible, setConfirmationVisible] = useState(false);
const [pendingPayload, setPendingPayload] = useState<any>(null);
const [detectedVariables, setDetectedVariables] = useState<string[]>([]);
// Helper function to determine button state
const getButtonProps = () => {
@@ -38,7 +44,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
};
} else {
return {
className: "bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white",
className: "bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white btn-primary",
disabled: false,
text: t('market.install')
};
@@ -48,6 +54,27 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
const toggleModal = () => {
setModalVisible(!modalVisible);
setError(null); // Clear any previous errors when toggling modal
setConfirmationVisible(false);
setPendingPayload(null);
};
const handleConfirmInstall = async () => {
if (pendingPayload) {
await proceedWithInstall(pendingPayload);
setConfirmationVisible(false);
setPendingPayload(null);
}
};
const proceedWithInstall = async (payload: any) => {
try {
setError(null);
onInstall(server, payload.config);
setModalVisible(false);
} catch (err) {
console.error('Error installing server:', err);
setError(t('errors.serverInstall'));
}
};
const handleInstall = () => {
@@ -70,24 +97,32 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
} else if (server.installations.default) {
return server.installations.default;
}
// If none of the preferred types are available, get the first available installation type
const installTypes = Object.keys(server.installations);
if (installTypes.length > 0) {
return server.installations[installTypes[0]];
}
return undefined;
};
const handleSubmit = async (payload: any) => {
try {
setError(null);
// Pass the server object to the parent component for installation
onInstall(server);
setModalVisible(false);
// Check for variables in the payload
const variables = detectVariables(payload);
if (variables.length > 0) {
// Show confirmation dialog
setDetectedVariables(variables);
setPendingPayload(payload);
setConfirmationVisible(true);
} else {
// Install directly if no variables found
await proceedWithInstall(payload);
}
} catch (err) {
console.error('Error installing server:', err);
console.error('Error processing server installation:', err);
setError(t('errors.serverInstall'));
}
};
@@ -112,15 +147,15 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
<div className="flex justify-between items-start mb-4">
<div>
<h2 className="text-2xl font-bold text-gray-900 flex items-center flex-wrap">
{server.display_name}
{server.display_name}
<span className="text-sm font-normal text-gray-500 ml-2">({server.name})</span>
<span className="text-sm font-normal text-gray-600 ml-4">
{t('market.author')}: {server.author.name} {t('market.license')}: {server.license}
{t('market.author')}: {server.author.name} {t('market.license')}: {server.license}
<a
href={server.repository.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline ml-1"
className="text-blue-500 hover:underline ml-1"
>
{t('market.repository')}
</a>
@@ -130,7 +165,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
<div className="flex items-center">
{server.is_official && (
<span className="bg-blue-100 text-blue-800 text-sm font-medium px-4 py-2 rounded mr-2 flex items-center">
<span className="bg-blue-100 text-blue-800 text-sm font-normal px-4 py-2 rounded mr-2 flex items-center label-primary">
{t('market.official')}
</span>
)}
@@ -167,7 +202,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
<h3 className="text-lg font-semibold mb-3">{t('market.arguments')}</h3>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
{t('market.argumentName')}
@@ -196,7 +231,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
{arg.required ? (
<span className="text-green-600"></span>
) : (
<span className="text-red-600"></span>
<span className="text-gray-600"></span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
@@ -226,7 +261,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
element.classList.toggle('hidden');
}
}}
className="text-sm text-blue-600 hover:underline focus:outline-none ml-2"
className="text-sm text-blue-500 font-normal hover:underline focus:outline-none ml-2"
>
{t('market.viewSchema')}
</button>
@@ -279,19 +314,73 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
initialData={{
name: server.name,
status: 'disconnected',
config: preferredInstallation
config: preferredInstallation
? {
command: preferredInstallation.command || '',
args: preferredInstallation.args || [],
env: preferredInstallation.env || {}
}
command: preferredInstallation.command || '',
args: preferredInstallation.args || [],
env: preferredInstallation.env || {}
}
: undefined
}}
/>
</div>
)}
{confirmationVisible && (
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center p-4">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{t('server.confirmVariables')}
</h3>
<p className="text-gray-600 mb-4">
{t('server.variablesDetected')}
</p>
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 mb-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h4 className="text-sm font-medium text-yellow-800">
{t('server.detectedVariables')}:
</h4>
<ul className="mt-1 text-sm text-yellow-700">
{detectedVariables.map((variable, index) => (
<li key={index} className="font-mono">
${`{${variable}}`}
</li>
))}
</ul>
</div>
</div>
</div>
<p className="text-gray-600 text-sm mb-6">
{t('market.confirmVariablesMessage')}
</p>
<div className="flex justify-end space-x-3">
<button
onClick={() => {
setConfirmationVisible(false)
setPendingPayload(null)
}}
className="px-4 py-2 text-gray-600 border border-gray-300 rounded hover:bg-gray-50 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={handleConfirmInstall}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 btn-primary"
>
{t('market.confirmAndInstall')}
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default MarketServerDetail;
export default MarketServerDetail;

View File

@@ -0,0 +1,95 @@
import React from 'react';
import { useAuth } from '../contexts/AuthContext';
interface PermissionCheckerProps {
permissions: string | string[];
fallback?: React.ReactNode;
children: React.ReactNode;
}
/**
* Permission checker component for conditional rendering
* @param permissions Required permissions, supports single permission string or permission array
* @param fallback Content to show when permission is denied, defaults to null
* @param children Content to show when permission is granted
*/
export const PermissionChecker: React.FC<PermissionCheckerProps> = ({
permissions,
fallback = null,
children,
}) => {
const hasPermission = usePermissionCheck(permissions);
return hasPermission ? <>{children}</> : <>{fallback}</>;
};
/**
* Permission check hook
* @param requiredPermissions Permissions to check
* @returns Whether user has permission
*/
export const usePermissionCheck = (requiredPermissions: string | string[]): boolean => {
const { auth } = useAuth();
if (!auth.isAuthenticated || !auth.user) {
return false;
}
const userPermissions = auth.user.permissions || [];
if (requiredPermissions === 'x' && !userPermissions.includes('x')) {
return false;
}
// If user has '*' permission, they have all permissions
if (userPermissions.includes('*')) {
return true;
}
// If user is admin, they have all permissions by default
if (auth.user.isAdmin) {
return true;
}
// Normalize required permissions to array
const permissionsToCheck = Array.isArray(requiredPermissions)
? requiredPermissions
: [requiredPermissions];
// Check if user has any of the required permissions
return permissionsToCheck.some(permission =>
userPermissions.includes(permission)
);
};
/**
* Permission check hook - requires all permissions
* @param requiredPermissions Array of permissions to check
* @returns Whether user has all permissions
*/
export const usePermissionCheckAll = (requiredPermissions: string[]): boolean => {
const { auth } = useAuth();
if (!auth.isAuthenticated || !auth.user) {
return false;
}
const userPermissions = auth.user.permissions || [];
// If user has '*' permission, they have all permissions
if (userPermissions.includes('*')) {
return true;
}
// If user is admin, they have all permissions by default
if (auth.user.isAdmin) {
return true;
}
// Check if user has all required permissions
return requiredPermissions.every(permission =>
userPermissions.includes(permission)
);
};
export default PermissionChecker;

View File

@@ -7,20 +7,20 @@ interface ProtectedRouteProps {
redirectPath?: string;
}
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
redirectPath = '/login'
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
redirectPath = '/login'
}) => {
const { t } = useTranslation();
const { auth } = useAuth();
if (auth.loading) {
return <div className="flex items-center justify-center h-screen">{t('app.loading')}</div>;
}
if (!auth.isAuthenticated) {
return <Navigate to={redirectPath} replace />;
}
return <Outlet />;
};

View File

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

View File

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

View File

@@ -0,0 +1,317 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { IGroupServerConfig, Server, Tool } from '@/types';
import { cn } from '@/utils/cn';
interface ServerToolConfigProps {
servers: Server[];
value: string[] | IGroupServerConfig[];
onChange: (value: IGroupServerConfig[]) => void;
className?: string;
}
export const ServerToolConfig: React.FC<ServerToolConfigProps> = ({
servers,
value,
onChange,
className
}) => {
const { t } = useTranslation();
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
// Normalize current value to IGroupServerConfig[] format
const normalizedValue: IGroupServerConfig[] = React.useMemo(() => {
return value.map(item => {
if (typeof item === 'string') {
return { name: item, tools: 'all' as const };
}
return { ...item, tools: item.tools || 'all' as const };
});
}, [value]);
// Get available servers (enabled only)
const availableServers = React.useMemo(() =>
servers.filter(server => server.enabled !== false),
[servers]
);
// Clean up expanded servers when servers are removed from configuration
// But keep servers that were explicitly expanded even if they have no configuration
React.useEffect(() => {
const configuredServerNames = new Set(normalizedValue.map(config => config.name));
const availableServerNames = new Set(availableServers.map(server => server.name));
setExpandedServers(prev => {
const newSet = new Set<string>();
prev.forEach(serverName => {
// Keep expanded if server is configured OR if server exists and user manually expanded it
if (configuredServerNames.has(serverName) || availableServerNames.has(serverName)) {
newSet.add(serverName);
}
});
return newSet;
});
}, [normalizedValue, availableServers]);
const toggleServer = (serverName: string) => {
const existingIndex = normalizedValue.findIndex(config => config.name === serverName);
if (existingIndex >= 0) {
// Remove server - this will also remove all its tools
const newValue = normalizedValue.filter(config => config.name !== serverName);
onChange(newValue);
// Don't auto-collapse the server when it's unchecked - let user control expansion manually
} else {
// Add server with all tools by default
const newValue = [...normalizedValue, { name: serverName, tools: 'all' as const }];
onChange(newValue);
// Don't auto-expand the server when it's checked - let user control expansion manually
}
};
const toggleServerExpanded = (serverName: string) => {
setExpandedServers(prev => {
const newSet = new Set(prev);
if (newSet.has(serverName)) {
newSet.delete(serverName);
} else {
newSet.add(serverName);
}
return newSet;
});
};
const updateServerTools = (serverName: string, tools: string[] | 'all', keepExpanded = false) => {
if (Array.isArray(tools) && tools.length === 0) {
// If no tools are selected, remove the server entirely
const newValue = normalizedValue.filter(config => config.name !== serverName);
onChange(newValue);
// Only collapse the server if not explicitly asked to keep it expanded
if (!keepExpanded) {
setExpandedServers(prev => {
const newSet = new Set(prev);
newSet.delete(serverName);
return newSet;
});
}
} else {
// Update server tools or add server if it doesn't exist
const existingServerIndex = normalizedValue.findIndex(config => config.name === serverName);
if (existingServerIndex >= 0) {
// Update existing server
const newValue = normalizedValue.map(config =>
config.name === serverName ? { ...config, tools } : config
);
onChange(newValue);
} else {
// Add new server with specified tools
const newValue = [...normalizedValue, { name: serverName, tools }];
onChange(newValue);
}
}
};
const toggleTool = (serverName: string, toolName: string) => {
const server = availableServers.find(s => s.name === serverName);
if (!server) return;
const allToolNames = server.tools?.map(tool => tool.name.replace(`${serverName}-`, '')) || [];
const serverConfig = normalizedValue.find(config => config.name === serverName);
if (!serverConfig) {
// Server not selected yet, add it with only this tool
const newValue = [...normalizedValue, { name: serverName, tools: [toolName] }];
onChange(newValue);
// Don't auto-expand - let user control expansion manually
return;
}
if (serverConfig.tools === 'all') {
// Switch from 'all' to specific tools, excluding the toggled tool
const newTools = allToolNames.filter(name => name !== toolName);
updateServerTools(serverName, newTools);
// If all tools are deselected, the server will be removed and collapsed in updateServerTools
} else if (Array.isArray(serverConfig.tools)) {
const currentTools = serverConfig.tools;
if (currentTools.includes(toolName)) {
// Remove tool
const newTools = currentTools.filter(name => name !== toolName);
updateServerTools(serverName, newTools);
// If all tools are deselected, the server will be removed and collapsed in updateServerTools
} else {
// Add tool
const newTools = [...currentTools, toolName];
// If all tools are selected, switch to 'all'
if (newTools.length === allToolNames.length) {
updateServerTools(serverName, 'all');
} else {
updateServerTools(serverName, newTools);
}
}
}
};
const isServerSelected = (serverName: string) => {
const serverConfig = normalizedValue.find(config => config.name === serverName);
if (!serverConfig) return false;
// Server is considered "fully selected" if tools is 'all'
return serverConfig.tools === 'all';
};
const isServerPartiallySelected = (serverName: string) => {
const serverConfig = normalizedValue.find(config => config.name === serverName);
if (!serverConfig) return false;
// Server is partially selected if it has specific tools selected (not 'all' and not empty)
return Array.isArray(serverConfig.tools) && serverConfig.tools.length > 0;
};
const isToolSelected = (serverName: string, toolName: string) => {
const serverConfig = normalizedValue.find(config => config.name === serverName);
if (!serverConfig) return false;
if (serverConfig.tools === 'all') return true;
if (Array.isArray(serverConfig.tools)) {
return serverConfig.tools.includes(toolName);
}
return false;
};
const getServerTools = (serverName: string): Tool[] => {
const server = availableServers.find(s => s.name === serverName);
return server?.tools || [];
};
return (
<div className={cn("space-y-4", className)}>
<div className="space-y-3">
{availableServers.map(server => {
const isSelected = isServerSelected(server.name);
const isPartiallySelected = isServerPartiallySelected(server.name);
const isExpanded = expandedServers.has(server.name);
const serverTools = getServerTools(server.name);
const serverConfig = normalizedValue.find(config => config.name === server.name);
return (
<div key={server.name} className="border border-gray-200 rounded-lg hover:border-gray-300 hover:bg-gray-50 transition-colors">
<div
className="flex items-center justify-between p-3 cursor-pointer rounded-lg transition-colors"
onClick={() => toggleServerExpanded(server.name)}
>
<div
className="flex items-center space-x-3"
onClick={(e) => {
e.stopPropagation();
toggleServer(server.name);
}}
>
<input
type="checkbox"
checked={isSelected || isPartiallySelected}
onChange={() => toggleServer(server.name)}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
/>
<span className="font-medium text-gray-900 cursor-pointer select-none">
{server.name}
</span>
</div>
<div className="flex items-center space-x-3">
{serverConfig && serverConfig.tools !== 'all' && Array.isArray(serverConfig.tools) && (
<span className="text-sm text-green-600">
({t('groups.toolsSelected')} {serverConfig.tools.length}/{serverTools.length})
</span>
)}
{serverConfig && serverConfig.tools === 'all' && (
<span className="text-sm text-green-600">
({t('groups.allTools')} {serverTools.length}/{serverTools.length})
</span>
)}
{serverTools.length > 0 && (
<button
type="button"
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
>
<svg
className={cn("w-5 h-5 transition-transform", isExpanded && "rotate-180")}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
)}
</div>
</div>
{isExpanded && serverTools.length > 0 && (
<div className="border-t border-gray-200 bg-gray-50 p-3">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-gray-700">
{t('groups.toolSelection')}
</span>
<button
type="button"
onClick={() => {
const isAllSelected = serverConfig?.tools === 'all';
if (isAllSelected || (Array.isArray(serverConfig?.tools) && serverConfig.tools.length === serverTools.length)) {
// If all tools are selected, deselect all (remove server) but keep expanded
updateServerTools(server.name, [], true);
} else {
// Select all tools (add server if not present)
updateServerTools(server.name, 'all');
// Don't auto-expand - let user control expansion manually
}
}}
className="text-sm text-blue-600 hover:text-blue-800 transition-colors"
>
{(serverConfig?.tools === 'all' ||
(Array.isArray(serverConfig?.tools) && serverConfig.tools.length === serverTools.length))
? t('groups.selectNone')
: t('groups.selectAll')}
</button>
</div>
<div className="grid grid-cols-1 gap-2 max-h-32 overflow-y-auto">
{serverTools.map(tool => {
const toolName = tool.name.replace(`${server.name}-`, '');
const isToolChecked = isToolSelected(server.name, toolName);
return (
<label key={tool.name} className="flex items-center space-x-2 text-sm">
<input
type="checkbox"
checked={isToolChecked}
onChange={() => toggleTool(server.name, toolName)}
className="w-3 h-3 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
/>
<span className="text-gray-700">
{toolName}
</span>
{tool.description && (
<span className="text-gray-400 text-xs truncate">
{tool.description}
</span>
)}
</label>
);
})}
</div>
</div>
)}
</div>
);
})}
</div>
{availableServers.length === 0 && (
<p className="text-gray-500 text-sm">{t('groups.noServerOptions')}</p>
)}
</div>
);
};

View File

@@ -0,0 +1,27 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
export const LanguageIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
const { t } = useTranslation();
return (
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
stroke="currentColor"
strokeWidth={2}
{...props}
>
<title>{t('common.language')}</title>
<circle cx="12" cy="12" r="10" />
<path strokeLinecap="round" strokeLinejoin="round" d="M2 12h20" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z" />
</svg>
);
};
export default LanguageIcon;

View File

@@ -13,7 +13,11 @@ import {
Loader,
CheckCircle,
XCircle,
AlertCircle
AlertCircle,
Link,
FileCode,
ChevronDown as DropdownIcon,
Wrench
} from 'lucide-react'
export {
@@ -31,7 +35,11 @@ export {
Loader,
CheckCircle,
XCircle,
AlertCircle
AlertCircle,
Link,
FileCode,
DropdownIcon,
Wrench
}
const LucideIcons = {
@@ -49,7 +57,10 @@ const LucideIcons = {
Loader,
CheckCircle,
XCircle,
AlertCircle
AlertCircle,
Link,
FileCode,
DropdownIcon
}
export default LucideIcons

View File

@@ -0,0 +1,42 @@
// Permission components unified export
export { PermissionChecker, usePermissionCheck, usePermissionCheckAll } from './PermissionChecker';
export { PERMISSIONS } from '../constants/permissions';
// Convenient permission check Hook
export { useAuth } from '../contexts/AuthContext';
// Permission utility functions
export const hasPermission = (
userPermissions: string[] = [],
requiredPermissions: string | string[],
): boolean => {
if (requiredPermissions === 'x' && !userPermissions.includes('x')) {
return false;
}
// If user has '*' permission, it means they have all permissions
if (userPermissions.includes('*')) {
return true;
}
// Normalize required permissions to array
const permissionsToCheck = Array.isArray(requiredPermissions)
? requiredPermissions
: [requiredPermissions];
// Check if user has any of the required permissions
return permissionsToCheck.some((permission) => userPermissions.includes(permission));
};
export const hasAllPermissions = (
userPermissions: string[] = [],
requiredPermissions: string[],
): boolean => {
// If user has '*' permission, it means they have all permissions
if (userPermissions.includes('*')) {
return true;
}
// Check if user has all required permissions
return requiredPermissions.every((permission) => userPermissions.includes(permission));
};

View File

@@ -1,27 +1,19 @@
import React, { useState } from 'react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@/contexts/AuthContext';
import ThemeSwitch from '@/components/ui/ThemeSwitch';
import LanguageSwitch from '@/components/ui/LanguageSwitch';
import GitHubIcon from '@/components/icons/GitHubIcon';
import SponsorIcon from '@/components/icons/SponsorIcon';
import WeChatIcon from '@/components/icons/WeChatIcon';
import DiscordIcon from '@/components/icons/DiscordIcon';
import SponsorDialog from '@/components/ui/SponsorDialog';
import WeChatDialog from '@/components/ui/WeChatDialog';
interface HeaderProps {
onToggleSidebar: () => void;
}
const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
const { t, i18n } = useTranslation();
const { auth } = useAuth();
const [sponsorDialogOpen, setSponsorDialogOpen] = useState(false);
const [wechatDialogOpen, setWechatDialogOpen] = useState(false);
const { t } = useTranslation();
return (
<header className="bg-white dark:bg-gray-800 shadow-sm z-10">
<div className="flex justify-between items-center px-4 py-3">
<div className="flex justify-between items-center px-3 py-3">
<div className="flex items-center">
{/* 侧边栏切换按钮 */}
<button
@@ -38,53 +30,27 @@ const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
<h1 className="ml-4 text-xl font-bold text-gray-900 dark:text-white">{t('app.title')}</h1>
</div>
{/* Theme Switch and Version */}
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-500 dark:text-gray-400">
{/* Theme Switch and Language Switcher and Version */}
<div className="flex items-center space-x-1">
<span className="text-sm text-gray-500 dark:text-gray-400 mr-2">
{import.meta.env.PACKAGE_VERSION === 'dev'
? import.meta.env.PACKAGE_VERSION
: `v${import.meta.env.PACKAGE_VERSION}`}
</span>
<a
href="https://github.com/samanhappy/mcphub"
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
className="p-2 rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
aria-label="GitHub Repository"
>
<GitHubIcon className="h-5 w-5" />
</a>
{i18n.language === 'zh' ? (
<button
onClick={() => setWechatDialogOpen(true)}
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 focus:outline-none"
aria-label={t('wechat.label')}
>
<WeChatIcon className="h-5 w-5" />
</button>
) : (
<a
href="https://discord.gg/qMKNsn5Q"
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
aria-label={t('discord.label')}
>
<DiscordIcon className="h-5 w-5" />
</a>
)}
<button
onClick={() => setSponsorDialogOpen(true)}
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 focus:outline-none"
aria-label={t('sponsor.label')}
>
<SponsorIcon className="h-5 w-5" />
</button>
<ThemeSwitch />
<LanguageSwitch />
</div>
</div>
<SponsorDialog open={sponsorDialogOpen} onOpenChange={setSponsorDialogOpen} />
<WeChatDialog open={wechatDialogOpen} onOpenChange={setWechatDialogOpen} />
</header>
);
};

View File

@@ -1,6 +1,8 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { NavLink, useLocation } from 'react-router-dom';
import { NavLink } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import { usePermissionCheck } from '../PermissionChecker';
import UserProfileMenu from '@/components/ui/UserProfileMenu';
interface SidebarProps {
@@ -15,11 +17,11 @@ interface MenuItem {
const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
const { t } = useTranslation();
const location = useLocation();
const { auth } = useAuth();
// Application version from package.json (accessed via Vite environment variables)
const appVersion = import.meta.env.PACKAGE_VERSION as string;
// Menu item configuration
const menuItems: MenuItem[] = [
{
@@ -50,6 +52,15 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
</svg>
),
},
...(auth.user?.isAdmin && usePermissionCheck('x') ? [{
path: '/users',
label: t('nav.users'),
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
</svg>
),
}] : []),
{
path: '/market',
label: t('nav.market'),
@@ -71,10 +82,9 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
];
return (
<aside
className={`bg-white dark:bg-gray-800 shadow-sm transition-all duration-300 ease-in-out flex flex-col h-full relative ${
collapsed ? 'w-16' : 'w-64'
}`}
<aside
className={`bg-white dark:bg-gray-800 shadow-sm transition-all duration-300 ease-in-out flex flex-col h-full relative ${collapsed ? 'w-16' : 'w-64'
}`}
>
{/* Scrollable navigation area */}
<div className="overflow-y-auto flex-grow">
@@ -83,12 +93,11 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`flex items-center px-3 py-2 rounded-md transition-colors ${
isActive
? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`
className={({ isActive }) =>
`flex items-center px-2.5 py-2 rounded-lg transition-colors duration-200
${isActive
? 'bg-blue-50 text-blue-700'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-100'}`
}
end={item.path === '/'}
>
@@ -98,7 +107,7 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
))}
</nav>
</div>
{/* User profile menu fixed at the bottom */}
<div className="p-3 bg-white dark:bg-gray-800">
<UserProfileMenu collapsed={collapsed} version={appVersion} />

View File

@@ -93,7 +93,7 @@ const AboutDialog: React.FC<AboutDialogProps> = ({ isOpen, onClose, version }) =
<button
onClick={checkForUpdates}
disabled={isChecking}
className={`mt-4 inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium
className={`mt-4 inline-flex items-center px-4 py-2 border border-gray-200 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium btn-secondary
${isChecking
? 'text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-800'
: 'text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600'

View File

@@ -1,6 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { ServerStatus } from '@/types';
import { cn } from '../../utils/cn';
type BadgeVariant = 'default' | 'secondary' | 'outline' | 'destructive';
@@ -19,11 +18,11 @@ const badgeVariants = {
destructive: 'bg-red-500 text-white hover:bg-red-600',
};
export function Badge({
children,
variant = 'default',
className,
onClick
export function Badge({
children,
variant = 'default',
className,
onClick
}: BadgeProps) {
return (
<span
@@ -43,11 +42,11 @@ export function Badge({
// For backward compatibility with existing code
export const StatusBadge = ({ status }: { status: 'connected' | 'disconnected' | 'connecting' }) => {
const { t } = useTranslation();
const colors = {
connecting: 'bg-yellow-100 text-yellow-800',
connected: 'bg-green-100 text-green-800',
disconnected: 'bg-red-100 text-red-800',
connecting: 'status-badge-connecting',
connected: 'status-badge-online',
disconnected: 'status-badge-offline',
};
// Map status to translation keys

View File

@@ -0,0 +1,142 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: 'danger' | 'warning' | 'info';
}
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText,
cancelText,
variant = 'warning'
}) => {
const { t } = useTranslation();
if (!isOpen) return null;
const getVariantStyles = () => {
switch (variant) {
case 'danger':
return {
icon: (
<svg className="w-6 h-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
),
confirmClass: 'bg-red-600 hover:bg-red-700 text-white',
};
case 'warning':
return {
icon: (
<svg className="w-6 h-6 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
),
confirmClass: 'bg-yellow-600 hover:bg-yellow-700 text-white',
};
case 'info':
return {
icon: (
<svg className="w-6 h-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
confirmClass: 'bg-blue-600 hover:bg-blue-700 text-white',
};
default:
return {
icon: null,
confirmClass: 'bg-blue-600 hover:bg-blue-700 text-white',
};
}
};
const { icon, confirmClass } = getVariantStyles();
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
} else if (e.key === 'Enter') {
onConfirm();
}
};
return (
<div
className="fixed inset-0 bg-black/50 z-[100] flex items-center justify-center p-4"
onClick={handleBackdropClick}
onKeyDown={handleKeyDown}
tabIndex={-1}
>
<div
className="bg-white rounded-lg shadow-xl max-w-md w-full transform transition-all duration-200 ease-out"
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
aria-describedby="confirm-dialog-message"
>
<div className="p-6">
<div className="flex items-start space-x-3">
{icon && (
<div className="flex-shrink-0">
{icon}
</div>
)}
<div className="flex-1">
{title && (
<h3
id="confirm-dialog-title"
className="text-lg font-medium text-gray-900 mb-2"
>
{title}
</h3>
)}
<p
id="confirm-dialog-message"
className="text-gray-600 leading-relaxed"
>
{message}
</p>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
onClick={onClose}
className="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-md transition-colors duration-150 btn-secondary"
autoFocus
>
{cancelText || t('common.cancel')}
</button>
<button
onClick={onConfirm}
className={`px-4 py-2 rounded-md transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 ${confirmClass} ${variant === 'danger' ? 'btn-danger' : variant === 'warning' ? 'btn-warning' : 'btn-primary'}`}
>
{confirmText || t('common.confirm')}
</button>
</div>
</div>
</div>
</div>
);
};
export default ConfirmDialog;

View File

@@ -6,9 +6,10 @@ interface DeleteDialogProps {
onConfirm: () => void
serverName: string
isGroup?: boolean
isUser?: boolean
}
const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName, isGroup = false }: DeleteDialogProps) => {
const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName, isGroup = false, isUser = false }: DeleteDialogProps) => {
const { t } = useTranslation()
if (!isOpen) return null
@@ -18,23 +19,29 @@ const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName, isGroup = false
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
<div className="p-6">
<h3 className="text-lg font-medium text-gray-900 mb-3">
{isGroup ? t('groups.confirmDelete') : t('server.confirmDelete')}
{isUser
? t('users.confirmDelete')
: isGroup
? t('groups.confirmDelete')
: t('server.confirmDelete')}
</h3>
<p className="text-gray-500 mb-6">
{isGroup
? t('groups.deleteWarning', { name: serverName })
: t('server.deleteWarning', { name: serverName })}
{isUser
? t('users.deleteWarning', { username: serverName })
: isGroup
? t('groups.deleteWarning', { name: serverName })
: t('server.deleteWarning', { name: serverName })}
</p>
<div className="flex justify-end space-x-3">
<button
onClick={onClose}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
className="px-4 py-2 text-gray-600 hover:text-gray-800 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={onConfirm}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 btn-danger"
>
{t('common.delete')}
</button>

View File

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

View File

@@ -0,0 +1,83 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import LanguageIcon from '@/components/icons/LanguageIcon';
const LanguageSwitch: React.FC = () => {
const { i18n } = useTranslation();
const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false);
const [currentLanguage, setCurrentLanguage] = useState(i18n.language);
// Available languages
const availableLanguages = [
{ code: 'en', label: 'English' },
{ code: 'zh', label: '中文' }
];
// Update current language when it changes
useEffect(() => {
setCurrentLanguage(i18n.language);
}, [i18n.language]);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!target.closest('.language-dropdown')) {
setLanguageDropdownOpen(false);
}
};
if (languageDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [languageDropdownOpen]);
const handleLanguageChange = (lang: string) => {
localStorage.setItem('i18nextLng', lang);
setLanguageDropdownOpen(false);
window.location.reload();
};
// Always show dropdown for language selection
const handleLanguageToggle = () => {
setLanguageDropdownOpen(!languageDropdownOpen);
};
return (
<div className="relative language-dropdown">
<button
onClick={handleLanguageToggle}
className="p-2 rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none"
aria-label="Language Switcher"
>
<LanguageIcon className="h-5 w-5" />
</button>
{/* Show dropdown when opened */}
{languageDropdownOpen && (
<div className="absolute right-0 mt-2 w-24 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-50">
<div>
{availableLanguages.map((lang) => (
<button
key={lang.code}
onClick={() => handleLanguageChange(lang.code)}
className={`flex items-center w-full px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${currentLanguage.startsWith(lang.code)
? 'bg-blue-50 text-blue-700'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-100'
}`}
>
{lang.label}
</button>
))}
</div>
</div>
)}
</div>
);
};
export default LanguageSwitch;

View File

@@ -6,34 +6,33 @@ interface PaginationProps {
onPageChange: (page: number) => void;
}
const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
onPageChange
const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
onPageChange
}) => {
// Generate page buttons
const getPageButtons = () => {
const buttons = [];
const maxDisplayedPages = 5; // Maximum number of page buttons to display
// Always display first page
buttons.push(
<button
key="first"
onClick={() => onPageChange(1)}
className={`px-3 py-1 mx-1 rounded ${
currentPage === 1
? 'bg-blue-500 text-white'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
className={`px-3 py-1 mx-1 rounded ${currentPage === 1
? 'bg-blue-500 text-white btn-primary'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
}`}
>
1
</button>
);
// Start range
let startPage = Math.max(2, currentPage - Math.floor(maxDisplayedPages / 2));
const startPage = Math.max(2, currentPage - Math.floor(maxDisplayedPages / 2));
// If we're showing ellipsis after first page
if (startPage > 2) {
buttons.push(
@@ -42,24 +41,23 @@ const Pagination: React.FC<PaginationProps> = ({
</span>
);
}
// Middle pages
for (let i = startPage; i <= Math.min(totalPages - 1, startPage + maxDisplayedPages - 3); i++) {
buttons.push(
<button
key={i}
onClick={() => onPageChange(i)}
className={`px-3 py-1 mx-1 rounded ${
currentPage === i
? 'bg-blue-500 text-white'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
className={`px-3 py-1 mx-1 rounded ${currentPage === i
? 'bg-blue-500 text-white btn-primary'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
}`}
>
{i}
</button>
);
}
// If we're showing ellipsis before last page
if (startPage + maxDisplayedPages - 3 < totalPages - 1) {
buttons.push(
@@ -68,24 +66,23 @@ const Pagination: React.FC<PaginationProps> = ({
</span>
);
}
// Always display last page if there's more than one page
if (totalPages > 1) {
buttons.push(
<button
key="last"
onClick={() => onPageChange(totalPages)}
className={`px-3 py-1 mx-1 rounded ${
currentPage === totalPages
? 'bg-blue-500 text-white'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
className={`px-3 py-1 mx-1 rounded ${currentPage === totalPages
? 'bg-blue-500 text-white btn-primary'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
}`}
>
{totalPages}
</button>
);
}
return buttons;
};
@@ -99,25 +96,23 @@ const Pagination: React.FC<PaginationProps> = ({
<button
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className={`px-3 py-1 rounded mr-2 ${
currentPage === 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
className={`px-3 py-1 rounded mr-2 ${currentPage === 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
}`}
>
&laquo; Prev
</button>
<div className="flex">{getPageButtons()}</div>
<button
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className={`px-3 py-1 rounded ml-2 ${
currentPage === totalPages
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
className={`px-3 py-1 rounded ml-2 ${currentPage === totalPages
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
}`}
>
Next &raquo;
</button>

View File

@@ -1,50 +1,25 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useTheme } from '@/contexts/ThemeContext';
import { Sun, Moon, Monitor } from 'lucide-react';
import { Sun, Moon } from 'lucide-react';
const ThemeSwitch: React.FC = () => {
const { t } = useTranslation();
const { theme, setTheme } = useTheme();
const toggleTheme = () => {
setTheme(theme === 'light' ? 'dark' : 'light');
};
return (
<div className="flex items-center space-x-2">
<div className="flex bg-gray-200 dark:bg-gray-700 rounded-lg p-1">
<button
onClick={() => setTheme('light')}
className={`flex items-center justify-center rounded-md p-1.5 ${theme === 'light'
? 'bg-white text-yellow-600 shadow'
: 'text-black dark:text-gray-300 hover:text-yellow-600 dark:hover:text-yellow-500'
}`}
title={t('theme.light')}
aria-label={t('theme.light')}
>
<Sun size={18} />
</button>
<button
onClick={() => setTheme('dark')}
className={`flex items-center justify-center rounded-md p-1.5 ${theme === 'dark'
? 'bg-gray-800 text-blue-400 shadow'
: 'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400'
}`}
title={t('theme.dark')}
aria-label={t('theme.dark')}
>
<Moon size={18} />
</button>
{/* <button
onClick={() => setTheme('system')}
className={`flex items-center justify-center rounded-md p-1.5 ${theme === 'system'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow'
: 'text-black dark:text-gray-300 hover:text-green-600 dark:hover:text-green-400'
}`}
title={t('theme.system')}
aria-label={t('theme.system')}
>
<Monitor size={18} />
</button> */}
</div>
</div>
<button
onClick={toggleTheme}
className="p-2 rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none"
title={theme === 'light' ? t('theme.dark') : t('theme.light')}
aria-label={theme === 'light' ? t('theme.dark') : t('theme.light')}
>
{theme === 'light' ? <Moon className="h-5 w-5" /> : <Sun className="h-5 w-5" />}
</button>
);
};

View File

@@ -9,7 +9,6 @@ interface ToggleGroupItemProps {
}
export const ToggleGroupItem: React.FC<ToggleGroupItemProps> = ({
value,
isSelected,
onClick,
children
@@ -21,8 +20,8 @@ export const ToggleGroupItem: React.FC<ToggleGroupItemProps> = ({
aria-checked={isSelected}
className={cn(
"flex w-full items-center justify-between p-2 rounded transition-colors cursor-pointer",
isSelected
? "bg-blue-50 text-blue-700 hover:bg-blue-100 border-l-4 border-blue-500"
isSelected
? "bg-blue-50 text-blue-700 hover:bg-blue-100 border-l-4 border-blue-500"
: "hover:bg-gray-50 text-gray-700"
)}
onClick={onClick}
@@ -72,7 +71,7 @@ export const ToggleGroup: React.FC<ToggleGroupProps> = ({
<label className="block text-gray-700 text-sm font-bold mb-2">
{label}
</label>
<div className="border rounded shadow max-h-60 overflow-y-auto">
<div className="border border-gray-200 rounded shadow max-h-60 overflow-y-auto">
{options.length === 0 ? (
<p className="text-gray-500 text-sm p-3">{noOptionsText}</p>
) : (
@@ -118,7 +117,7 @@ export const Switch: React.FC<SwitchProps> = ({
disabled={disabled}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500",
checked ? "bg-blue-600" : "bg-gray-200",
checked ? "bg-blue-200" : "bg-gray-100",
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
)}
onClick={() => !disabled && onCheckedChange(!checked)}

View File

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

View File

@@ -65,7 +65,6 @@ const ToolResult: React.FC<ToolResultProps> = ({ result, onClose }) => {
// For other structured content, try to parse as JSON
try {
const jsonString = typeof item === 'string' ? item : JSON.stringify(item, null, 2);
const parsed = typeof item === 'string' ? JSON.parse(item) : item;
return (
@@ -97,9 +96,9 @@ const ToolResult: React.FC<ToolResultProps> = ({ result, onClose }) => {
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
{result.success ? (
<CheckCircle size={20} className="text-green-500" />
<CheckCircle size={20} className="text-status-green" />
) : (
<XCircle size={20} className="text-red-500" />
<XCircle size={20} className="text-status-red" />
)}
<div>
<h4 className="text-sm font-medium text-gray-900">

View File

@@ -4,6 +4,11 @@ import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import { User, Settings, LogOut, Info } from 'lucide-react';
import AboutDialog from './AboutDialog';
import SponsorDialog from './SponsorDialog';
import WeChatDialog from './WeChatDialog';
import WeChatIcon from '@/components/icons/WeChatIcon';
import DiscordIcon from '@/components/icons/DiscordIcon';
import SponsorIcon from '@/components/icons/SponsorIcon';
import { checkLatestVersion, compareVersions } from '@/utils/version';
interface UserProfileMenuProps {
@@ -12,12 +17,14 @@ interface UserProfileMenuProps {
}
const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version }) => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const { auth, logout } = useAuth();
const [isOpen, setIsOpen] = useState(false);
const [showNewVersionInfo, setShowNewVersionInfo] = useState(false);
const [showAboutDialog, setShowAboutDialog] = useState(false);
const [sponsorDialogOpen, setSponsorDialogOpen] = useState(false);
const [wechatDialogOpen, setWechatDialogOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
// Check for new version on login and component mount
@@ -65,6 +72,16 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
setIsOpen(false);
};
const handleSponsorClick = () => {
setSponsorDialogOpen(true);
setIsOpen(false);
};
const handleWeChatClick = () => {
setWechatDialogOpen(true);
setIsOpen(false);
};
return (
<div ref={menuRef} className="relative">
<button
@@ -73,7 +90,7 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
}`}
>
<div className="flex-shrink-0 relative">
<div className="w-7 h-7 flex items-center justify-center rounded-full border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700">
<div className="w-5 h-5 flex items-center justify-center rounded-full border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700">
<User className="h-4 w-4 text-gray-700 dark:text-gray-300" />
</div>
{showNewVersionInfo && (
@@ -90,7 +107,35 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
</button>
{isOpen && (
<div className="absolute top-0 transform -translate-y-full left-0 w-48 bg-white dark:bg-gray-800 shadow-lg rounded-md py-1 z-50">
<div className="absolute top-0 transform -translate-y-full left-0 w-full min-w-max bg-white border border-gray-200 dark:bg-gray-800 z-50">
<button
onClick={handleSponsorClick}
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<SponsorIcon className="h-4 w-4 mr-2" />
{t('sponsor.label')}
</button>
{i18n.language === 'zh' ? (
<button
onClick={handleWeChatClick}
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<WeChatIcon className="h-4 w-4 mr-2" />
{t('wechat.label')}
</button>
) : (
<a
href="https://discord.gg/qMKNsn5Q"
target="_blank"
rel="noopener noreferrer"
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<DiscordIcon className="h-4 w-4 mr-2" />
{t('discord.label')}
</a>
)}
<button
onClick={handleSettingsClick}
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
@@ -108,6 +153,9 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
<span className="absolute top-2 right-4 block w-2 h-2 bg-red-500 rounded-full"></span>
)}
</button>
<div className="border-t border-gray-200 dark:border-gray-600"></div>
<button
onClick={handleLogoutClick}
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
@@ -124,6 +172,12 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
onClose={() => setShowAboutDialog(false)}
version={version}
/>
{/* Sponsor dialog */}
<SponsorDialog open={sponsorDialogOpen} onOpenChange={setSponsorDialogOpen} />
{/* WeChat dialog */}
<WeChatDialog open={wechatDialogOpen} onOpenChange={setWechatDialogOpen} />
</div>
);
};

View File

@@ -0,0 +1,9 @@
// Predefined permission constants
export const PERMISSIONS = {
// Settings page permissions
SETTINGS_SMART_ROUTING: 'settings:smart_routing',
SETTINGS_SKIP_AUTH: 'settings:skip_auth',
SETTINGS_INSTALL_CONFIG: 'settings:install_config',
} as const;
export default PERMISSIONS;

View File

@@ -1,10 +1,10 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { AuthState, IUser } from '../types';
import { AuthState } from '../types';
import * as authService from '../services/authService';
import { getPublicConfig } from '../services/configService';
// Initial auth state
const initialState: AuthState = {
token: null,
isAuthenticated: false,
loading: true,
user: null,
@@ -21,7 +21,7 @@ const AuthContext = createContext<{
auth: initialState,
login: async () => false,
register: async () => false,
logout: () => {},
logout: () => { },
});
// Auth provider component
@@ -31,8 +31,27 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
// Load user if token exists
useEffect(() => {
const loadUser = async () => {
// First check if authentication should be skipped
const { skipAuth, permissions } = await getPublicConfig();
if (skipAuth) {
// If authentication is disabled, set user as authenticated with a dummy user
setAuth({
isAuthenticated: true,
loading: false,
user: {
username: 'guest',
isAdmin: true,
permissions,
},
error: null,
});
return;
}
// Normal authentication flow
const token = authService.getToken();
if (!token) {
setAuth({
...initialState,
@@ -40,13 +59,12 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
});
return;
}
try {
const response = await authService.getCurrentUser();
if (response.success && response.user) {
setAuth({
token,
isAuthenticated: true,
loading: false,
user: response.user,
@@ -67,7 +85,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
});
}
};
loadUser();
}, []);
@@ -75,10 +93,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const login = async (username: string, password: string): Promise<boolean> => {
try {
const response = await authService.login({ username, password });
if (response.success && response.token && response.user) {
setAuth({
token: response.token,
isAuthenticated: true,
loading: false,
user: response.user,
@@ -105,16 +122,15 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
// Register function
const register = async (
username: string,
password: string,
username: string,
password: string,
isAdmin = false
): Promise<boolean> => {
try {
const response = await authService.register({ username, password, isAdmin });
if (response.success && response.token && response.user) {
setAuth({
token: response.token,
isAuthenticated: true,
loading: false,
user: response.user,

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Group, ApiResponse } from '@/types';
import { getApiUrl } from '../utils/runtime';
import { Group, ApiResponse, IGroupServerConfig } from '@/types';
import { apiGet, apiPost, apiPut, apiDelete } from '../utils/fetchInterceptor';
export const useGroupData = () => {
const { t } = useTranslation();
@@ -13,18 +13,7 @@ export const useGroupData = () => {
const fetchGroups = useCallback(async () => {
try {
setLoading(true);
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/groups'), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<Group[]> = await response.json();
const data: ApiResponse<Group[]> = await apiGet('/groups');
if (data && data.success && Array.isArray(data.data)) {
setGroups(data.data);
@@ -49,27 +38,22 @@ export const useGroupData = () => {
}, []);
// Create a new group with server associations
const createGroup = async (name: string, description?: string, servers: string[] = []) => {
const createGroup = async (
name: string,
description?: string,
servers: string[] | IGroupServerConfig[] = [],
) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/groups'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({ name, description, servers }),
});
const result: ApiResponse<Group> = await apiPost('/groups', { name, description, servers });
console.log('Group created successfully:', result);
const result: ApiResponse<Group> = await response.json();
if (!response.ok) {
setError(result.message || t('groups.createError'));
return null;
if (!result || !result.success) {
setError(result?.message || t('groups.createError'));
return result;
}
triggerRefresh();
return result.data || null;
return result || null;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create group');
return null;
@@ -79,28 +63,17 @@ export const useGroupData = () => {
// Update an existing group with server associations
const updateGroup = async (
id: string,
data: { name?: string; description?: string; servers?: string[] },
data: { name?: string; description?: string; servers?: string[] | IGroupServerConfig[] },
) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/groups/${id}`), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify(data),
});
const result: ApiResponse<Group> = await response.json();
if (!response.ok) {
setError(result.message || t('groups.updateError'));
return null;
const result: ApiResponse<Group> = await apiPut(`/groups/${id}`, data);
if (!result || !result.success) {
setError(result?.message || t('groups.updateError'));
return result;
}
triggerRefresh();
return result.data || null;
return result || null;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update group');
return null;
@@ -108,22 +81,14 @@ export const useGroupData = () => {
};
// Update servers in a group (for batch updates)
const updateGroupServers = async (groupId: string, servers: string[]) => {
const updateGroupServers = async (groupId: string, servers: string[] | IGroupServerConfig[]) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/groups/${groupId}/servers/batch`), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({ servers }),
const result: ApiResponse<Group> = await apiPut(`/groups/${groupId}/servers/batch`, {
servers,
});
const result: ApiResponse<Group> = await response.json();
if (!response.ok) {
setError(result.message || t('groups.updateError'));
if (!result || !result.success) {
setError(result?.message || t('groups.updateError'));
return null;
}
@@ -138,46 +103,29 @@ export const useGroupData = () => {
// Delete a group
const deleteGroup = async (id: string) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/groups/${id}`), {
method: 'DELETE',
headers: {
'x-auth-token': token || '',
},
});
const result = await response.json();
if (!response.ok) {
setError(result.message || t('groups.deleteError'));
return false;
const result = await apiDelete(`/groups/${id}`);
if (!result || !result.success) {
setError(result?.message || t('groups.deleteError'));
return result;
}
triggerRefresh();
return true;
return result;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete group');
return false;
return null;
}
};
// Add server to a group
const addServerToGroup = async (groupId: string, serverName: string) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/groups/${groupId}/servers`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({ serverName }),
const result: ApiResponse<Group> = await apiPost(`/groups/${groupId}/servers`, {
serverName,
});
const result: ApiResponse<Group> = await response.json();
if (!response.ok) {
setError(result.message || t('groups.serverAddError'));
if (!result || !result.success) {
setError(result?.message || t('groups.serverAddError'));
return null;
}
@@ -192,18 +140,12 @@ export const useGroupData = () => {
// Remove server from group
const removeServerFromGroup = async (groupId: string, serverName: string) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/groups/${groupId}/servers/${serverName}`), {
method: 'DELETE',
headers: {
'x-auth-token': token || '',
},
});
const result: ApiResponse<Group> = await apiDelete(
`/groups/${groupId}/servers/${serverName}`,
);
const result: ApiResponse<Group> = await response.json();
if (!response.ok) {
setError(result.message || t('groups.serverRemoveError'));
if (!result || !result.success) {
setError(result?.message || t('groups.serverRemoveError'));
return null;
}

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { MarketServer, ApiResponse } from '@/types';
import { getApiUrl } from '../utils/runtime';
import { MarketServer, ApiResponse, ServerConfig } from '@/types';
import { apiGet, apiPost } from '../utils/fetchInterceptor';
export const useMarketData = () => {
const { t } = useTranslation();
@@ -26,18 +26,7 @@ export const useMarketData = () => {
const fetchMarketServers = useCallback(async () => {
try {
setLoading(true);
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/market/servers'), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<MarketServer[]> = await response.json();
const data: ApiResponse<MarketServer[]> = await apiGet('/market/servers');
if (data && data.success && Array.isArray(data.data)) {
setAllServers(data.data);
@@ -87,18 +76,7 @@ export const useMarketData = () => {
// Fetch all categories
const fetchCategories = useCallback(async () => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/market/categories'), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<string[]> = await response.json();
const data: ApiResponse<string[]> = await apiGet('/market/categories');
if (data && data.success && Array.isArray(data.data)) {
setCategories(data.data);
@@ -113,18 +91,7 @@ export const useMarketData = () => {
// Fetch all tags
const fetchTags = useCallback(async () => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/market/tags'), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<string[]> = await response.json();
const data: ApiResponse<string[]> = await apiGet('/market/tags');
if (data && data.success && Array.isArray(data.data)) {
setTags(data.data);
@@ -141,18 +108,7 @@ export const useMarketData = () => {
async (name: string) => {
try {
setLoading(true);
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/market/servers/${name}`), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<MarketServer> = await response.json();
const data: ApiResponse<MarketServer> = await apiGet(`/market/servers/${name}`);
if (data && data.success && data.data) {
setCurrentServer(data.data);
@@ -186,22 +142,10 @@ export const useMarketData = () => {
return;
}
const token = localStorage.getItem('mcphub_token');
const response = await fetch(
getApiUrl(`/market/servers/search?query=${encodeURIComponent(query)}`),
{
headers: {
'x-auth-token': token || '',
},
},
const data: ApiResponse<MarketServer[]> = await apiGet(
`/market/servers/search?query=${encodeURIComponent(query)}`,
);
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<MarketServer[]> = await response.json();
if (data && data.success && Array.isArray(data.data)) {
setAllServers(data.data);
setCurrentPage(1);
@@ -233,22 +177,10 @@ export const useMarketData = () => {
return;
}
const token = localStorage.getItem('mcphub_token');
const response = await fetch(
getApiUrl(`/market/categories/${encodeURIComponent(category)}`),
{
headers: {
'x-auth-token': token || '',
},
},
const data: ApiResponse<MarketServer[]> = await apiGet(
`/market/categories/${encodeURIComponent(category)}`,
);
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<MarketServer[]> = await response.json();
if (data && data.success && Array.isArray(data.data)) {
setAllServers(data.data);
setCurrentPage(1);
@@ -280,18 +212,9 @@ export const useMarketData = () => {
return;
}
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/market/tags/${encodeURIComponent(tag)}`), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<MarketServer[]> = await response.json();
const data: ApiResponse<MarketServer[]> = await apiGet(
`/market/tags/${encodeURIComponent(tag)}`,
);
if (data && data.success && Array.isArray(data.data)) {
setAllServers(data.data);
@@ -314,18 +237,7 @@ export const useMarketData = () => {
// Fetch installed servers
const fetchInstalledServers = useCallback(async () => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/servers'), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data = await response.json();
const data = await apiGet<{ success: boolean; data: any[] }>('/servers');
if (data && data.success && Array.isArray(data.data)) {
// Extract server names
@@ -347,7 +259,7 @@ export const useMarketData = () => {
// Install server to the local environment
const installServer = useCallback(
async (server: MarketServer) => {
async (server: MarketServer, customConfig: ServerConfig) => {
try {
const installType = server.installations?.npm
? 'npm'
@@ -362,30 +274,27 @@ export const useMarketData = () => {
const installation = server.installations[installType];
// Prepare server configuration
// Prepare server configuration, merging with customConfig
const serverConfig = {
name: server.name,
config: {
command: installation.command,
args: installation.args,
env: installation.env || {},
},
config:
customConfig.type === 'stdio'
? {
command: customConfig.command || installation.command || '',
args: customConfig.args || installation.args || [],
env: { ...installation.env, ...customConfig.env },
}
: customConfig,
};
// Call the createServer API
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/servers'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify(serverConfig),
});
const result = await apiPost<{ success: boolean; message?: string }>(
'/servers',
serverConfig,
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `Status: ${response.status}`);
if (!result.success) {
throw new Error(result.message || 'Failed to install server');
}
// Update installed servers list after successful installation

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Server, ApiResponse } from '@/types';
import { getApiUrl } from '../utils/runtime';
import { apiGet, apiPost, apiDelete } from '../utils/fetchInterceptor';
// Configuration options
const CONFIG = {
@@ -44,13 +44,7 @@ export const useServerData = () => {
const fetchServers = async () => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/servers'), {
headers: {
'x-auth-token': token || '',
},
});
const data = await response.json();
const data = await apiGet('/servers');
if (data && data.success && Array.isArray(data.data)) {
setServers(data.data);
@@ -97,13 +91,7 @@ export const useServerData = () => {
// Initialization phase request function
const fetchInitialData = async () => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/servers'), {
headers: {
'x-auth-token': token || '',
},
});
const data = await response.json();
const data = await apiGet('/servers');
// Handle API response wrapper object, extract data field
if (data && data.success && Array.isArray(data.data)) {
@@ -203,14 +191,8 @@ export const useServerData = () => {
const handleServerEdit = async (server: Server) => {
try {
// Fetch settings to get the full server config before editing
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/settings'), {
headers: {
'x-auth-token': token || '',
},
});
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> = await response.json();
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> =
await apiGet('/settings');
if (
settingsData &&
@@ -240,17 +222,10 @@ export const useServerData = () => {
const handleServerRemove = async (serverName: string) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/servers/${serverName}`), {
method: 'DELETE',
headers: {
'x-auth-token': token || '',
},
});
const result = await response.json();
const result = await apiDelete(`/servers/${serverName}`);
if (!response.ok) {
setError(result.message || t('server.deleteError', { serverName }));
if (!result || !result.success) {
setError(result?.message || t('server.deleteError', { serverName }));
return false;
}
@@ -264,21 +239,11 @@ export const useServerData = () => {
const handleServerToggle = async (server: Server, enabled: boolean) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/servers/${server.name}/toggle`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({ enabled }),
});
const result = await apiPost(`/servers/${server.name}/toggle`, { enabled });
const result = await response.json();
if (!response.ok) {
if (!result || !result.success) {
console.error('Failed to toggle server:', result);
setError(t('server.toggleError', { serverName: server.name }));
setError(result?.message || t('server.toggleError', { serverName: server.name }));
return false;
}

View File

@@ -2,7 +2,7 @@ import { useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { ApiResponse } from '@/types';
import { useToast } from '@/contexts/ToastContext';
import { getApiUrl } from '../utils/runtime';
import { apiGet, apiPut } from '../utils/fetchInterceptor';
// Define types for the settings data
interface RoutingConfig {
@@ -10,11 +10,13 @@ interface RoutingConfig {
enableGroupNameRoute: boolean;
enableBearerAuth: boolean;
bearerAuthKey: string;
skipAuth: boolean;
}
interface InstallConfig {
pythonIndexUrl: string;
npmRegistry: string;
baseUrl: string;
}
interface SmartRoutingConfig {
@@ -46,6 +48,7 @@ export const useSettingsData = () => {
enableGroupNameRoute: true,
enableBearerAuth: false,
bearerAuthKey: '',
skipAuth: false,
});
const [tempRoutingConfig, setTempRoutingConfig] = useState<TempRoutingConfig>({
@@ -55,6 +58,7 @@ export const useSettingsData = () => {
const [installConfig, setInstallConfig] = useState<InstallConfig>({
pythonIndexUrl: '',
npmRegistry: '',
baseUrl: 'http://localhost:3000',
});
const [smartRoutingConfig, setSmartRoutingConfig] = useState<SmartRoutingConfig>({
@@ -80,18 +84,7 @@ export const useSettingsData = () => {
setError(null);
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/settings'), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data: ApiResponse<SystemSettings> = await response.json();
const data: ApiResponse<SystemSettings> = await apiGet('/settings');
if (data.success && data.data?.systemConfig?.routing) {
setRoutingConfig({
@@ -99,12 +92,14 @@ export const useSettingsData = () => {
enableGroupNameRoute: data.data.systemConfig.routing.enableGroupNameRoute ?? true,
enableBearerAuth: data.data.systemConfig.routing.enableBearerAuth ?? false,
bearerAuthKey: data.data.systemConfig.routing.bearerAuthKey || '',
skipAuth: data.data.systemConfig.routing.skipAuth ?? false,
});
}
if (data.success && data.data?.systemConfig?.install) {
setInstallConfig({
pythonIndexUrl: data.data.systemConfig.install.pythonIndexUrl || '',
npmRegistry: data.data.systemConfig.install.npmRegistry || '',
baseUrl: data.data.systemConfig.install.baseUrl || 'http://localhost:3000',
});
}
if (data.success && data.data?.systemConfig?.smartRouting) {
@@ -128,34 +123,17 @@ export const useSettingsData = () => {
}, [t]); // 移除 showToast 依赖
// Update routing configuration
const updateRoutingConfig = async <T extends keyof RoutingConfig>(
key: T,
value: RoutingConfig[T],
) => {
const updateRoutingConfig = async (key: keyof RoutingConfig, value: any) => {
setLoading(true);
setError(null);
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/system-config'), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
const data = await apiPut('/system-config', {
routing: {
[key]: value,
},
body: JSON.stringify({
routing: {
[key]: value,
},
}),
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
setRoutingConfig({
...routingConfig,
@@ -164,7 +142,7 @@ export const useSettingsData = () => {
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(t('errors.failedToUpdateRouteConfig'));
showToast(data.message || t('errors.failedToUpdateRouteConfig'));
return false;
}
} catch (error) {
@@ -183,26 +161,12 @@ export const useSettingsData = () => {
setError(null);
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/system-config'), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
const data = await apiPut('/system-config', {
install: {
[key]: value,
},
body: JSON.stringify({
install: {
[key]: value,
},
}),
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
setInstallConfig({
...installConfig,
@@ -211,7 +175,7 @@ export const useSettingsData = () => {
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(t('errors.failedToUpdateSystemConfig'));
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
return false;
}
} catch (error) {
@@ -233,27 +197,12 @@ export const useSettingsData = () => {
setError(null);
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/system-config'), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
const data = await apiPut('/system-config', {
smartRouting: {
[key]: value,
},
body: JSON.stringify({
smartRouting: {
[key]: value,
},
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `HTTP error! Status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
setSmartRoutingConfig({
...smartRoutingConfig,
@@ -283,25 +232,10 @@ export const useSettingsData = () => {
setError(null);
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/system-config'), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({
smartRouting: updates,
}),
const data = await apiPut('/system-config', {
smartRouting: updates,
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `HTTP error! Status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
setSmartRoutingConfig({
...smartRoutingConfig,
@@ -331,24 +265,10 @@ export const useSettingsData = () => {
setError(null);
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/system-config'), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({
routing: updates,
}),
const data = await apiPut('/system-config', {
routing: updates,
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
setRoutingConfig({
...routingConfig,
@@ -357,7 +277,7 @@ export const useSettingsData = () => {
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(t('errors.failedToUpdateRouteConfig'));
showToast(data.message || t('errors.failedToUpdateRouteConfig'));
return false;
}
} catch (error) {

View File

@@ -2,9 +2,9 @@ import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
// Import translations
import enTranslation from './locales/en.json';
import zhTranslation from './locales/zh.json';
// Import shared translations from root locales directory
import enTranslation from '../../locales/en.json';
import zhTranslation from '../../locales/zh.json';
i18n
// Detect user language
@@ -15,18 +15,18 @@ i18n
.init({
resources: {
en: {
translation: enTranslation
translation: enTranslation,
},
zh: {
translation: zhTranslation
}
translation: zhTranslation,
},
},
fallbackLng: 'en',
debug: process.env.NODE_ENV === 'development',
// Common namespace used for all translations
defaultNS: 'translation',
interpolation: {
escapeValue: false, // React already safe from XSS
},
@@ -36,7 +36,7 @@ i18n
order: ['localStorage', 'cookie', 'htmlTag', 'navigator'],
// Cache the language in localStorage
caches: ['localStorage', 'cookie'],
}
},
});
export default i18n;
export default i18n;

View File

@@ -1,11 +1,22 @@
/* Use project's custom Tailwind import */
@import "tailwindcss";
@import 'tailwindcss';
/* Add some custom styles to verify CSS is working correctly */
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
font-family:
'Inter',
'PingFang SC',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
'Roboto',
'Oxygen',
'Ubuntu',
'Cantarell',
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@@ -13,7 +24,7 @@ body {
/* Dark mode override styles - these will apply when dark class is on html element */
.dark body {
background-color: #111827;
background-color: #1f2a37;
color: #e5e7eb;
}
@@ -37,30 +48,435 @@ body {
color: #d1d5db !important;
}
.dark .text-gray-500 {
/* .dark .text-gray-500 {
color: #9ca3af !important;
}
} */
.dark .border-gray-300 {
border-color: #4b5563 !important;
border-color: #2f3b4c !important;
}
.dark .border-gray-200 {
border-color: #2f3b4c !important;
}
.dark .divide-gray-200 > :not([hidden]) ~ :not([hidden]) {
border-color: #2f3b4c !important;
}
.dark .bg-gray-100 {
background-color: #374151 !important;
}
/* Specific hover effects for dark mode */
.dark .hover\:bg-gray-100:hover {
background-color: rgba(110, 127, 156, 0.15) !important;
}
.dark .hover\:text-gray-900:hover {
color: rgb(190, 188, 185) !important;
}
.dark .bg-gray-50 {
background-color: #1f2937 !important;
}
.dark .text-blue-700 {
color: white !important;
}
.dark .bg-blue-50 {
background-color: #4b5563 !important;
}
.dark .bg-blue-200 {
background-color: #576476 !important;
}
.dark .shadow {
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.4), 0 1px 2px 0 rgba(0, 0, 0, 0.24) !important;
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.15),
0 2px 6px rgba(0, 0, 0, 0.1) !important;
}
.bg-custom-blue {
background-color: #4a90e2;
background-color: #4a90e2;
}
.text-custom-white {
color: #ffffff;
}
}
.status-badge-online {
background-color: white !important;
color: rgba(129, 199, 132, 0.9) !important;
border: 1px solid #a6d7b7;
}
/* Enhanced status badge styles for dark theme */
.dark .status-badge-online {
background-color: rgba(76, 175, 80, 0.15) !important;
color: rgba(129, 199, 132, 0.9) !important;
border: 1px solid rgba(76, 175, 80, 0.3);
}
.status-badge-offline {
background-color: white !important;
color: rgba(107, 114, 128, 0.9) !important;
border: 1px solid #d1d5db;
}
.dark .status-badge-offline {
background-color: rgba(107, 114, 128, 0.15) !important;
color: rgba(156, 163, 175, 0.9) !important;
border: 1px solid rgba(107, 114, 128, 0.3);
}
.status-badge-connecting {
background-color: white !important;
color: rgba(255, 213, 79, 0.9) !important;
border: 1px solid #ffd57f;
}
.dark .status-badge-connecting {
background-color: rgba(255, 193, 7, 0.15) !important;
color: rgba(255, 213, 79, 0.9) !important;
border: 1px solid rgba(255, 193, 7, 0.3);
}
/* Enhanced status icons for dark theme */
.dark .status-icon-blue {
background-color: rgba(59, 130, 246, 0.15) !important;
color: rgba(96, 165, 250, 0.9) !important;
}
.dark .status-icon-green {
background-color: rgba(76, 175, 80, 0.15) !important;
color: rgba(129, 199, 132, 0.9) !important;
}
.dark .status-icon-red {
background-color: rgba(244, 67, 54, 0.15) !important;
color: rgba(239, 154, 154, 0.9) !important;
}
.dark .status-icon-yellow {
background-color: rgba(255, 193, 7, 0.15) !important;
color: rgba(255, 213, 79, 0.9) !important;
}
/* Enhanced card hover effects */
.dashboard-card {
transition: all 0.3s ease;
border-radius: 12px;
}
.dashboard-card:hover {
transform: translateY(-2px);
box-shadow:
0 8px 25px rgba(0, 0, 0, 0.2),
0 4px 12px rgba(0, 0, 0, 0.15) !important;
}
/* Icon container hover effects */
.icon-container {
transition: all 0.3s ease;
}
.icon-container:hover {
transform: scale(1.05);
filter: brightness(1.1);
}
/* Progress bar enhancements */
.progress-bar-online {
background: linear-gradient(90deg, rgba(76, 175, 80, 0.8), rgba(129, 199, 132, 0.6));
}
.progress-bar-offline {
background: linear-gradient(90deg, rgba(244, 67, 54, 0.8), rgba(239, 154, 154, 0.6));
}
.progress-bar-connecting {
background: linear-gradient(90deg, rgba(255, 193, 7, 0.8), rgba(255, 213, 79, 0.6));
}
/* Table enhancements for dark theme */
.dark .table-container {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.dark thead {
background-color: #252d3a !important;
}
.dark tbody tr {
border-bottom: 1px solid #2f3b4c;
}
tbody tr:hover {
background-color: var(--color-gray-100) !important;
transition: background-color 0.2s ease;
}
.dark tbody tr:hover {
background-color: rgba(55, 65, 81, 0.5) !important;
transition: background-color 0.2s ease;
}
/* Error box enhancements for dark theme */
.dark .error-box {
background-color: rgba(244, 67, 54, 0.1) !important;
border-color: rgba(244, 67, 54, 0.3) !important;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.1);
}
.dark .error-box h3 {
color: rgba(239, 154, 154, 0.9) !important;
}
.dark .error-box p {
color: #d1d5db !important;
}
/* Loading container enhancements */
.loading-container {
border-radius: 12px;
backdrop-filter: blur(10px);
}
.dark .loading-container {
background-color: rgba(31, 41, 55, 0.8) !important;
border: 1px solid #2f3b4c;
}
.label-primary {
background-color: var(--color-blue-50) !important;
color: var(--color-blue-500) !important;
}
.dark .label-primary {
background-color: rgba(59, 130, 246, 0.15) !important;
color: rgba(96, 165, 250, 0.9) !important;
}
.label-secondary {
background-color: var(--color-green-50) !important;
color: var(--color-green-500) !important;
}
.dark .label-secondary {
background-color: rgba(76, 175, 80, 0.15) !important;
color: rgba(129, 199, 132, 0.9) !important;
}
.btn-primary {
background-color: #60a5fa !important;
color: #ffffff !important;
border: none;
border-radius: 8px;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(96, 165, 250, 0.2);
}
.btn-primary:hover {
background-color: #3b82f6 !important;
color: #ffffff !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
/* Enhanced button styles for dark theme */
.dark .btn-primary {
background-color: rgba(59, 130, 246, 0.15) !important;
color: rgba(96, 165, 250, 0.9) !important;
border: 1px solid rgba(59, 130, 246, 0.3);
transition: all 0.3s ease;
}
.dark .btn-primary:hover {
background-color: rgba(59, 130, 246, 0.25) !important;
color: rgba(96, 165, 250, 1) !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
}
.btn-secondary {
background-color: #f9fafb !important;
color: #374151 !important;
border: 1px solid #d1d5db !important;
border-radius: 8px;
font-size: 0.875rem;
}
.btn-secondary:hover {
background-color: #e5e7eb !important;
color: #374151 !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.dark .btn-secondary {
background-color: rgba(107, 114, 128, 0.15) !important;
color: rgba(156, 163, 175, 0.9) !important;
border: 1px solid rgba(107, 114, 128, 0.3) !important;
transition: all 0.3s ease;
}
.dark .btn-secondary:hover {
background-color: rgba(107, 114, 128, 0.25) !important;
color: rgba(156, 163, 175, 1) !important;
transform: translateY(-1px);
}
.btn-warning {
background-color: var(--color-yellow-100) !important;
color: var(--color-yellow-800) !important;
border: none;
border-radius: 8px;
transition: all 0.3s ease;
}
.btn-warning:hover {
background-color: var(--color-yellow-200) !important;
color: var(--color-yellow-800) !important;
}
.dark .btn-warning {
background-color: rgba(234, 179, 8, 0.15) !important;
color: rgba(250, 204, 21, 0.9) !important;
border: 1px solid rgba(234, 179, 8, 0.3);
transition: all 0.3s ease;
}
.dark .btn-warning:hover {
background-color: rgba(234, 179, 8, 0.25) !important;
color: rgba(250, 204, 21, 1) !important;
transform: translateY(-1px);
}
.btn-danger {
background-color: var(--color-red-100) !important;
color: var(--color-red-800) !important;
border: none;
border-radius: 8px;
transition: all 0.3s ease;
}
.btn-danger:hover {
background-color: var(--color-red-200) !important;
color: var(--color-red-800) !important;
}
.dark .btn-danger {
background-color: rgba(244, 67, 54, 0.15) !important;
color: rgba(239, 154, 154, 0.9) !important;
border: 1px solid rgba(244, 67, 54, 0.3);
transition: all 0.3s ease;
}
.dark .btn-danger:hover {
background-color: rgba(244, 67, 54, 0.25) !important;
color: rgba(239, 154, 154, 1) !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.2);
}
.form-input {
background-color: #f9fafb !important;
border-color: #d1d5db !important;
color: #374151 !important;
border-radius: 8px;
transition: all 0.3s ease;
}
.form-input:focus {
border-color: rgba(184, 193, 207, 0.5);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* Form input enhancements for dark theme */
.dark .form-input {
background-color: #1f2937 !important;
border-color: #2f3b4c !important;
color: #e5e7eb !important;
border-radius: 8px;
}
.dark .form-input:focus {
border-color: rgba(59, 130, 246, 0.5) !important;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1) !important;
}
.dark .form-input::placeholder {
color: #9ca3af !important;
}
/* Card spacing and layout improvements */
.page-card {
border-radius: 12px;
transition: all 0.3s ease;
}
.page-card:hover {
transform: translateY(-1px);
}
.dark .page-card {
background-color: #1f2937 !important;
border: 1px solid #2f3b4c;
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.15),
0 2px 6px rgba(0, 0, 0, 0.1);
}
/* Custom text color to match status-icon-red */
.text-status-red {
color: #991b1b; /* Tailwind red-800 for light mode */
}
.dark .text-status-red {
color: rgba(239, 154, 154, 0.9) !important;
}
.border-red {
border-color: #937d7d; /* Tailwind red-800 for light mode */
}
.dark .border-red {
border-color: rgba(188, 161, 161, 0.9) !important;
}
.dark .text-status-green {
color: rgba(129, 199, 132, 0.9) !important;
}
/* Empty state styling */
.dark .empty-state {
background-color: #1f2937 !important;
border: 1px solid #2f3b4c;
border-radius: 12px;
text-align: center;
padding: 3rem 2rem;
}
.dark .empty-state p {
color: #9ca3af !important;
}
/* Login page enhancements for dark theme */
.dark .login-container {
background-color: #1f2a37 !important;
}
.dark .login-card {
background-color: #1f2937 !important;
border: 1px solid #2f3b4c;
box-shadow:
0 8px 25px rgba(0, 0, 0, 0.2),
0 4px 12px rgba(0, 0, 0, 0.15);
border-radius: 12px;
}

View File

@@ -4,6 +4,8 @@ import App from './App';
import './index.css';
// Import the i18n configuration
import './i18n';
// Setup fetch interceptors
import './utils/setupInterceptors';
import { loadRuntimeConfig } from './utils/runtime';
// Load runtime configuration before starting the app

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useServerData } from '@/hooks/useServerData';
import { ServerStatus } from '@/types';
const DashboardPage: React.FC = () => {
const { t } = useTranslation();
@@ -22,26 +21,20 @@ const DashboardPage: React.FC = () => {
connecting: 'status.connecting'
}
// Calculate percentage for each status (for dashboard display)
const getStatusPercentage = (status: ServerStatus) => {
if (servers.length === 0) return 0;
return Math.round((servers.filter(server => server.status === status).length / servers.length) * 100);
};
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-8">{t('pages.dashboard.title')}</h1>
{error && (
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm">
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm error-box">
<div className="flex items-center justify-between">
<div>
<h3 className="text-red-600 text-lg font-medium">{t('app.error')}</h3>
<h3 className="text-status-red text-lg font-medium">{t('app.error')}</h3>
<p className="text-gray-600 mt-1">{error}</p>
</div>
<button
onClick={() => setError(null)}
className="ml-4 text-gray-500 hover:text-gray-700"
className="ml-4 text-gray-500 hover:text-gray-700 transition-colors duration-200"
aria-label={t('app.closeButton')}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
@@ -52,8 +45,8 @@ const DashboardPage: React.FC = () => {
</div>
)}
{isLoading ? (
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center">
{isLoading && (
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center loading-container">
<div className="flex flex-col items-center">
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
@@ -62,12 +55,14 @@ const DashboardPage: React.FC = () => {
<p className="text-gray-600">{t('app.loading')}</p>
</div>
</div>
) : (
)}
{!isLoading && (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
{/* Total servers */}
<div className="bg-white rounded-lg shadow p-6">
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
<div className="flex items-center">
<div className="p-3 rounded-full bg-blue-100 text-blue-800">
<div className="p-3 rounded-full bg-blue-100 text-blue-800 icon-container status-icon-blue">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
@@ -80,9 +75,9 @@ const DashboardPage: React.FC = () => {
</div>
{/* Online servers */}
<div className="bg-white rounded-lg shadow p-6">
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
<div className="flex items-center">
<div className="p-3 rounded-full bg-green-100 text-green-800">
<div className="p-3 rounded-full bg-green-100 text-green-800 icon-container status-icon-green">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
@@ -92,18 +87,12 @@ const DashboardPage: React.FC = () => {
<p className="text-3xl font-bold text-gray-900">{serverStats.online}</p>
</div>
</div>
<div className="mt-4 h-2 bg-gray-200 rounded-full">
<div
className="h-full bg-green-500 rounded-full"
style={{ width: `${getStatusPercentage('connected')}%` }}
></div>
</div>
</div>
{/* Offline servers */}
<div className="bg-white rounded-lg shadow p-6">
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
<div className="flex items-center">
<div className="p-3 rounded-full bg-red-100 text-red-800">
<div className="p-3 rounded-full bg-red-100 text-red-800 icon-container status-icon-red">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
@@ -113,18 +102,12 @@ const DashboardPage: React.FC = () => {
<p className="text-3xl font-bold text-gray-900">{serverStats.offline}</p>
</div>
</div>
<div className="mt-4 h-2 bg-gray-200 rounded-full">
<div
className="h-full bg-red-500 rounded-full"
style={{ width: `${getStatusPercentage('disconnected')}%` }}
></div>
</div>
</div>
{/* Connecting servers */}
<div className="bg-white rounded-lg shadow p-6">
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
<div className="flex items-center">
<div className="p-3 rounded-full bg-yellow-100 text-yellow-800">
<div className="p-3 rounded-full bg-yellow-100 text-yellow-800 icon-container status-icon-yellow">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
@@ -134,12 +117,7 @@ const DashboardPage: React.FC = () => {
<p className="text-3xl font-bold text-gray-900">{serverStats.connecting}</p>
</div>
</div>
<div className="mt-4 h-2 bg-gray-200 rounded-full">
<div
className="h-full bg-yellow-500 rounded-full"
style={{ width: `${getStatusPercentage('connecting')}%` }}
></div>
</div>
</div>
</div>
)}
@@ -148,20 +126,20 @@ const DashboardPage: React.FC = () => {
{servers.length > 0 && !isLoading && (
<div className="mt-8">
<h2 className="text-xl font-semibold text-gray-900 mb-4">{t('pages.dashboard.recentServers')}</h2>
<div className="bg-white shadow rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<div className="bg-white shadow rounded-lg overflow-hidden table-container">
<table className="min-w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('server.name')}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('server.status')}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('server.tools')}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('server.enabled')}
</th>
</tr>
@@ -173,11 +151,11 @@ const DashboardPage: React.FC = () => {
{server.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${server.status === 'connected'
? 'bg-green-100 text-green-800'
: server.status === 'disconnected'
? 'bg-red-100 text-red-800'
: 'bg-yellow-100 text-yellow-800'
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${server.status === 'connected'
? 'status-badge-online'
: server.status === 'disconnected'
? 'status-badge-offline'
: 'status-badge-connecting'
}`}>
{t(statusTranslations[server.status] || server.status)}
</span>
@@ -189,7 +167,7 @@ const DashboardPage: React.FC = () => {
{server.enabled !== false ? (
<span className="text-green-600"></span>
) : (
<span className="text-red-600"></span>
<span className="text-status-red"></span>
)}
</td>
</tr>

View File

@@ -9,16 +9,16 @@ import GroupCard from '@/components/GroupCard';
const GroupsPage: React.FC = () => {
const { t } = useTranslation();
const {
groups,
loading: groupsLoading,
error: groupError,
const {
groups,
loading: groupsLoading,
error: groupError,
setError: setGroupError,
deleteGroup,
triggerRefresh
} = useGroupData();
const { servers } = useServerData();
const [editingGroup, setEditingGroup] = useState<Group | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
@@ -32,9 +32,9 @@ const GroupsPage: React.FC = () => {
};
const handleDeleteGroup = async (groupId: string) => {
const success = await deleteGroup(groupId);
if (!success) {
setGroupError(t('groups.deleteError'));
const result = await deleteGroup(groupId);
if (!result || !result.success) {
setGroupError(result?.message || t('groups.deleteError'));
}
};
@@ -54,7 +54,7 @@ const GroupsPage: React.FC = () => {
<div className="flex space-x-4">
<button
onClick={handleAddGroup}
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center"
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 3a1 1 0 00-1 1v5H4a1 1 0 100 2h5v5a1 1 0 102 0v-5h5a1 1 0 100-2h-5V4a1 1 0 00-1-1z" clipRule="evenodd" />
@@ -65,13 +65,13 @@ const GroupsPage: React.FC = () => {
</div>
{groupError && (
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6">
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6 error-box rounded-lg">
<p>{groupError}</p>
</div>
)}
{groupsLoading ? (
<div className="bg-white shadow rounded-lg p-6">
<div className="bg-white shadow rounded-lg p-6 loading-container">
<div className="flex flex-col items-center justify-center">
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
@@ -81,7 +81,7 @@ const GroupsPage: React.FC = () => {
</div>
</div>
) : groups.length === 0 ? (
<div className="bg-white shadow rounded-lg p-6">
<div className="bg-white shadow rounded-lg p-6 empty-state">
<p className="text-gray-600">{t('groups.noGroups')}</p>
</div>
) : (

View File

@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../contexts/AuthContext';
import ThemeSwitch from '@/components/ui/ThemeSwitch';
import LanguageSwitch from '@/components/ui/LanguageSwitch';
const LoginPage: React.FC = () => {
const { t } = useTranslation();
@@ -26,7 +27,7 @@ const LoginPage: React.FC = () => {
}
const success = await login(username, password);
if (success) {
navigate('/');
} else {
@@ -40,18 +41,19 @@ const LoginPage: React.FC = () => {
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
<div className="absolute top-4 right-4">
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8 login-container">
<div className="absolute top-4 right-4 w-full max-w-xs flex justify-end">
<ThemeSwitch />
<LanguageSwitch />
</div>
<div className="max-w-md w-full space-y-8">
<div className="max-w-md w-full space-y-8 login-card p-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
{t('auth.loginTitle')}
</h2>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm -space-y-px">
<div className="space-y-4">
<div>
<label htmlFor="username" className="sr-only">
{t('auth.username')}
@@ -62,7 +64,7 @@ const LoginPage: React.FC = () => {
type="text"
autoComplete="username"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
className="appearance-none relative block w-full px-3 py-3 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm transition-all duration-200 form-input shadow-sm"
placeholder={t('auth.username')}
value={username}
onChange={(e) => setUsername(e.target.value)}
@@ -78,7 +80,7 @@ const LoginPage: React.FC = () => {
type="password"
autoComplete="current-password"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
className="appearance-none relative block w-full px-3 py-3 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm login-input transition-all duration-200 form-input shadow-sm"
placeholder={t('auth.password')}
value={password}
onChange={(e) => setPassword(e.target.value)}
@@ -87,14 +89,14 @@ const LoginPage: React.FC = () => {
</div>
{error && (
<div className="text-red-500 dark:text-red-400 text-sm text-center">{error}</div>
<div className="text-red-500 dark:text-red-400 text-sm text-center error-box p-2 rounded">{error}</div>
)}
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 login-button transition-all duration-200 btn-primary"
>
{loading ? t('auth.loggingIn') : t('auth.login')}
</button>

View File

@@ -11,9 +11,9 @@ const LogsPage: React.FC = () => {
return (
<div className="container mx-auto p-4">
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold">{t('pages.logs.title')}</h1>
<h1 className="text-2xl font-bold text-gray-900">{t('pages.logs.title')}</h1>
</div>
<div className="bg-card rounded-md shadow-sm">
<div className="bg-card rounded-md shadow-sm border border-gray-200 page-card">
<LogViewer
logs={logs}
isLoading={loading}

View File

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

View File

@@ -6,6 +6,7 @@ import ServerCard from '@/components/ServerCard';
import AddServerForm from '@/components/AddServerForm';
import EditServerForm from '@/components/EditServerForm';
import { useServerData } from '@/hooks/useServerData';
import DxtUploadForm from '@/components/DxtUploadForm';
const ServersPage: React.FC = () => {
const { t } = useTranslation();
@@ -23,6 +24,7 @@ const ServersPage: React.FC = () => {
} = useServerData();
const [editingServer, setEditingServer] = useState<Server | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [showDxtUpload, setShowDxtUpload] = useState(false);
const handleEditClick = async (server: Server) => {
const fullServerData = await handleServerEdit(server);
@@ -47,6 +49,12 @@ const ServersPage: React.FC = () => {
}
};
const handleDxtUploadSuccess = (_serverConfig: any) => {
// Close upload dialog and refresh servers
setShowDxtUpload(false);
triggerRefresh();
};
return (
<div>
<div className="flex justify-between items-center mb-8">
@@ -54,7 +62,7 @@ const ServersPage: React.FC = () => {
<div className="flex space-x-4">
<button
onClick={() => navigate('/market')}
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center"
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path d="M3 1a1 1 0 000 2h1.22l.305 1.222a.997.997 0 00.01.042l1.358 5.43-.893.892C3.74 11.846 4.632 14 6.414 14H15a1 1 0 000-2H6.414l1-1H14a1 1 0 00.894-.553l3-6A1 1 0 0017 3H6.28l-.31-1.243A1 1 0 005 1H3z" />
@@ -62,10 +70,19 @@ const ServersPage: React.FC = () => {
{t('nav.market')}
</button>
<AddServerForm onAdd={handleServerAdd} />
<button
onClick={() => setShowDxtUpload(true)}
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path d="M5.5 13a3.5 3.5 0 01-.369-6.98 4 4 0 117.753-1.977A4.5 4.5 0 1113.5 13H11V9.413l1.293 1.293a1 1 0 001.414-1.414l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 001.414 1.414L9 9.413V13H5.5z" />
</svg>
{t('dxt.upload')}
</button>
<button
onClick={handleRefresh}
disabled={isRefreshing}
className={`px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center ${isRefreshing ? 'opacity-70 cursor-not-allowed' : ''}`}
className={`px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200 ${isRefreshing ? 'opacity-70 cursor-not-allowed' : ''}`}
>
{isRefreshing ? (
<svg className="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
@@ -83,15 +100,14 @@ const ServersPage: React.FC = () => {
</div>
{error && (
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm">
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm error-box">
<div className="flex items-center justify-between">
<div>
<h3 className="text-red-600 text-lg font-medium">{t('app.error')}</h3>
<p className="text-gray-600 mt-1">{error}</p>
</div>
<button
onClick={() => setError(null)}
className="ml-4 text-gray-500 hover:text-gray-700"
className="ml-4 text-gray-500 hover:text-gray-700 transition-colors duration-200 btn-secondary"
aria-label={t('app.closeButton')}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
@@ -103,7 +119,7 @@ const ServersPage: React.FC = () => {
)}
{isLoading ? (
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center">
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center loading-container">
<div className="flex flex-col items-center">
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
@@ -113,7 +129,7 @@ const ServersPage: React.FC = () => {
</div>
</div>
) : servers.length === 0 ? (
<div className="bg-white shadow rounded-lg p-6">
<div className="bg-white shadow rounded-lg p-6 empty-state">
<p className="text-gray-600">{t('app.noServers')}</p>
</div>
) : (
@@ -125,6 +141,7 @@ const ServersPage: React.FC = () => {
onRemove={handleServerRemove}
onEdit={handleEditClick}
onToggle={handleServerToggle}
onRefresh={triggerRefresh}
/>
))}
</div>
@@ -137,6 +154,13 @@ const ServersPage: React.FC = () => {
onCancel={() => setEditingServer(null)}
/>
)}
{showDxtUpload && (
<DxtUploadForm
onSuccess={handleDxtUploadSuccess}
onCancel={() => setShowDxtUpload(false)}
/>
)}
</div>
);
};

View File

@@ -6,24 +6,22 @@ import { Switch } from '@/components/ui/ToggleGroup';
import { useSettingsData } from '@/hooks/useSettingsData';
import { useToast } from '@/contexts/ToastContext';
import { generateRandomKey } from '@/utils/key';
import { PermissionChecker } from '@/components/PermissionChecker';
import { PERMISSIONS } from '@/constants/permissions';
const SettingsPage: React.FC = () => {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const navigate = useNavigate();
const { showToast } = useToast();
const [currentLanguage, setCurrentLanguage] = useState(i18n.language);
// Update current language when it changes
useEffect(() => {
setCurrentLanguage(i18n.language);
}, [i18n.language]);
const [installConfig, setInstallConfig] = useState<{
pythonIndexUrl: string;
npmRegistry: string;
baseUrl: string;
}>({
pythonIndexUrl: '',
npmRegistry: '',
baseUrl: 'http://localhost:3000',
});
const [tempSmartRoutingConfig, setTempSmartRoutingConfig] = useState<{
@@ -85,7 +83,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) {
@@ -123,14 +121,14 @@ const SettingsPage: React.FC = () => {
await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey);
};
const handleInstallConfigChange = (key: 'pythonIndexUrl' | 'npmRegistry', value: string) => {
const handleInstallConfigChange = (key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl', value: string) => {
setInstallConfig({
...installConfig,
[key]: value
});
};
const saveInstallConfig = async (key: 'pythonIndexUrl' | 'npmRegistry') => {
const saveInstallConfig = async (key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl') => {
await updateInstallConfig(key, installConfig[key]);
};
@@ -193,166 +191,136 @@ const SettingsPage: React.FC = () => {
}, 2000);
};
const handleLanguageChange = (lang: string) => {
localStorage.setItem('i18nextLng', lang);
window.location.reload();
};
return (
<div className="container mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-8">{t('pages.settings.title')}</h1>
{/* Language Settings */}
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-gray-800">{t('pages.settings.language')}</h2>
<div className="flex space-x-3">
<button
className={`px-3 py-1.5 rounded-md transition-colors text-sm ${currentLanguage.startsWith('en')
? 'bg-blue-500 text-white'
: 'bg-blue-100 text-blue-800 hover:bg-blue-200'
}`}
onClick={() => handleLanguageChange('en')}
>
English
</button>
<button
className={`px-3 py-1.5 rounded-md transition-colors text-sm ${currentLanguage.startsWith('zh')
? 'bg-blue-500 text-white'
: 'bg-blue-100 text-blue-800 hover:bg-blue-200'
}`}
onClick={() => handleLanguageChange('zh')}
>
</button>
</div>
</div>
</div>
{/* Smart Routing Configuration Settings */}
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => toggleSection('smartRoutingConfig')}
>
<h2 className="font-semibold text-gray-800">{t('pages.settings.smartRouting')}</h2>
<span className="text-gray-500">
{sectionsVisible.smartRoutingConfig ? '▼' : '►'}
</span>
</div>
{sectionsVisible.smartRoutingConfig && (
<div className="space-y-4 mt-4">
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div>
<h3 className="font-medium text-gray-700">{t('settings.enableSmartRouting')}</h3>
<p className="text-sm text-gray-500">{t('settings.enableSmartRoutingDescription')}</p>
</div>
<Switch
disabled={loading}
checked={smartRoutingConfig.enabled}
onCheckedChange={(checked) => handleSmartRoutingEnabledChange(checked)}
/>
</div>
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">
<span className="text-red-500 px-1">*</span>{t('settings.dbUrl')}
</h3>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={tempSmartRoutingConfig.dbUrl}
onChange={(e) => handleSmartRoutingConfigChange('dbUrl', e.target.value)}
placeholder={t('settings.dbUrlPlaceholder')}
className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300"
disabled={loading}
/>
<button
onClick={() => saveSmartRoutingConfig('dbUrl')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
>
{t('common.save')}
</button>
</div>
</div>
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">
<span className="text-red-500 px-1">*</span>{t('settings.openaiApiKey')}
</h3>
</div>
<div className="flex items-center gap-3">
<input
type="password"
value={tempSmartRoutingConfig.openaiApiKey}
onChange={(e) => handleSmartRoutingConfigChange('openaiApiKey', e.target.value)}
placeholder={t('settings.openaiApiKeyPlaceholder')}
className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300"
disabled={loading}
/>
<button
onClick={() => saveSmartRoutingConfig('openaiApiKey')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
>
{t('common.save')}
</button>
</div>
</div>
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.openaiApiBaseUrl')}</h3>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={tempSmartRoutingConfig.openaiApiBaseUrl}
onChange={(e) => handleSmartRoutingConfigChange('openaiApiBaseUrl', e.target.value)}
placeholder={t('settings.openaiApiBaseUrlPlaceholder')}
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
disabled={loading}
/>
<button
onClick={() => saveSmartRoutingConfig('openaiApiBaseUrl')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
>
{t('common.save')}
</button>
</div>
</div>
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.openaiApiEmbeddingModel')}</h3>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={tempSmartRoutingConfig.openaiApiEmbeddingModel}
onChange={(e) => handleSmartRoutingConfigChange('openaiApiEmbeddingModel', e.target.value)}
placeholder={t('settings.openaiApiEmbeddingModelPlaceholder')}
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
disabled={loading}
/>
<button
onClick={() => saveSmartRoutingConfig('openaiApiEmbeddingModel')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
>
{t('common.save')}
</button>
</div>
</div>
<PermissionChecker permissions={PERMISSIONS.SETTINGS_SMART_ROUTING}>
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 page-card">
<div
className="flex justify-between items-center cursor-pointer transition-colors duration-200 hover:text-blue-600"
onClick={() => toggleSection('smartRoutingConfig')}
>
<h2 className="font-semibold text-gray-800">{t('pages.settings.smartRouting')}</h2>
<span className="text-gray-500 transition-transform duration-200">
{sectionsVisible.smartRoutingConfig ? '▼' : '►'}
</span>
</div>
)}
</div>
{sectionsVisible.smartRoutingConfig && (
<div className="space-y-4 mt-4">
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div>
<h3 className="font-medium text-gray-700">{t('settings.enableSmartRouting')}</h3>
<p className="text-sm text-gray-500">{t('settings.enableSmartRoutingDescription')}</p>
</div>
<Switch
disabled={loading}
checked={smartRoutingConfig.enabled}
onCheckedChange={(checked) => handleSmartRoutingEnabledChange(checked)}
/>
</div>
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">
<span className="text-red-500 px-1">*</span>{t('settings.dbUrl')}
</h3>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={tempSmartRoutingConfig.dbUrl}
onChange={(e) => handleSmartRoutingConfigChange('dbUrl', e.target.value)}
placeholder={t('settings.dbUrlPlaceholder')}
className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300 form-input"
disabled={loading}
/>
<button
onClick={() => saveSmartRoutingConfig('dbUrl')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
{t('common.save')}
</button>
</div>
</div>
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">
<span className="text-red-500 px-1">*</span>{t('settings.openaiApiKey')}
</h3>
</div>
<div className="flex items-center gap-3">
<input
type="password"
value={tempSmartRoutingConfig.openaiApiKey}
onChange={(e) => handleSmartRoutingConfigChange('openaiApiKey', e.target.value)}
placeholder={t('settings.openaiApiKeyPlaceholder')}
className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300"
disabled={loading}
/>
<button
onClick={() => saveSmartRoutingConfig('openaiApiKey')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
{t('common.save')}
</button>
</div>
</div>
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.openaiApiBaseUrl')}</h3>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={tempSmartRoutingConfig.openaiApiBaseUrl}
onChange={(e) => handleSmartRoutingConfigChange('openaiApiBaseUrl', e.target.value)}
placeholder={t('settings.openaiApiBaseUrlPlaceholder')}
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
disabled={loading}
/>
<button
onClick={() => saveSmartRoutingConfig('openaiApiBaseUrl')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
{t('common.save')}
</button>
</div>
</div>
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.openaiApiEmbeddingModel')}</h3>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={tempSmartRoutingConfig.openaiApiEmbeddingModel}
onChange={(e) => handleSmartRoutingConfigChange('openaiApiEmbeddingModel', e.target.value)}
placeholder={t('settings.openaiApiEmbeddingModelPlaceholder')}
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
disabled={loading}
/>
<button
onClick={() => saveSmartRoutingConfig('openaiApiEmbeddingModel')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
{t('common.save')}
</button>
</div>
</div>
</div>
)}
</div>
</PermissionChecker>
{/* Route Configuration Settings */}
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
@@ -392,13 +360,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,74 +398,114 @@ const SettingsPage: React.FC = () => {
/>
</div>
<PermissionChecker permissions={PERMISSIONS.SETTINGS_SKIP_AUTH}>
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div>
<h3 className="font-medium text-gray-700">{t('settings.skipAuth')}</h3>
<p className="text-sm text-gray-500">{t('settings.skipAuthDescription')}</p>
</div>
<Switch
disabled={loading}
checked={routingConfig.skipAuth}
onCheckedChange={(checked) => handleRoutingConfigChange('skipAuth', checked)}
/>
</div>
</PermissionChecker>
</div>
)}
</div>
{/* Installation Configuration Settings */}
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => toggleSection('installConfig')}
>
<h2 className="font-semibold text-gray-800">{t('settings.installConfig')}</h2>
<span className="text-gray-500">
{sectionsVisible.installConfig ? '▼' : '►'}
</span>
</div>
{sectionsVisible.installConfig && (
<div className="space-y-4 mt-4">
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.pythonIndexUrl')}</h3>
<p className="text-sm text-gray-500">{t('settings.pythonIndexUrlDescription')}</p>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={installConfig.pythonIndexUrl}
onChange={(e) => handleInstallConfigChange('pythonIndexUrl', e.target.value)}
placeholder={t('settings.pythonIndexUrlPlaceholder')}
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
disabled={loading}
/>
<button
onClick={() => saveInstallConfig('pythonIndexUrl')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
>
{t('common.save')}
</button>
</div>
</div>
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.npmRegistry')}</h3>
<p className="text-sm text-gray-500">{t('settings.npmRegistryDescription')}</p>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={installConfig.npmRegistry}
onChange={(e) => handleInstallConfigChange('npmRegistry', e.target.value)}
placeholder={t('settings.npmRegistryPlaceholder')}
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
disabled={loading}
/>
<button
onClick={() => saveInstallConfig('npmRegistry')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
>
{t('common.save')}
</button>
</div>
</div>
<PermissionChecker permissions={PERMISSIONS.SETTINGS_INSTALL_CONFIG}>
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => toggleSection('installConfig')}
>
<h2 className="font-semibold text-gray-800">{t('settings.installConfig')}</h2>
<span className="text-gray-500">
{sectionsVisible.installConfig ? '▼' : '►'}
</span>
</div>
)}
</div>
{sectionsVisible.installConfig && (
<div className="space-y-4 mt-4">
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.baseUrl')}</h3>
<p className="text-sm text-gray-500">{t('settings.baseUrlDescription')}</p>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={installConfig.baseUrl}
onChange={(e) => handleInstallConfigChange('baseUrl', e.target.value)}
placeholder={t('settings.baseUrlPlaceholder')}
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
disabled={loading}
/>
<button
onClick={() => saveInstallConfig('baseUrl')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
{t('common.save')}
</button>
</div>
</div>
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.pythonIndexUrl')}</h3>
<p className="text-sm text-gray-500">{t('settings.pythonIndexUrlDescription')}</p>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={installConfig.pythonIndexUrl}
onChange={(e) => handleInstallConfigChange('pythonIndexUrl', e.target.value)}
placeholder={t('settings.pythonIndexUrlPlaceholder')}
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
disabled={loading}
/>
<button
onClick={() => saveInstallConfig('pythonIndexUrl')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
{t('common.save')}
</button>
</div>
</div>
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.npmRegistry')}</h3>
<p className="text-sm text-gray-500">{t('settings.npmRegistryDescription')}</p>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={installConfig.npmRegistry}
onChange={(e) => handleInstallConfigChange('npmRegistry', e.target.value)}
placeholder={t('settings.npmRegistryPlaceholder')}
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
disabled={loading}
/>
<button
onClick={() => saveInstallConfig('npmRegistry')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
{t('common.save')}
</button>
</div>
</div>
</div>
)}
</div>
</PermissionChecker>
{/* Change Password */}
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">

View File

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

View File

@@ -4,45 +4,27 @@ import {
RegisterCredentials,
ChangePasswordCredentials,
} from '../types';
import { getApiUrl } from '../utils/runtime';
import { apiPost, apiGet } from '../utils/fetchInterceptor';
import { getToken, setToken, removeToken } from '../utils/interceptors';
// Token key in localStorage
const TOKEN_KEY = 'mcphub_token';
// Get token from localStorage
export const getToken = (): string | null => {
return localStorage.getItem(TOKEN_KEY);
};
// Set token in localStorage
export const setToken = (token: string): void => {
localStorage.setItem(TOKEN_KEY, token);
};
// Remove token from localStorage
export const removeToken = (): void => {
localStorage.removeItem(TOKEN_KEY);
};
// Export token management functions
export { getToken, setToken, removeToken };
// Login user
export const login = async (credentials: LoginCredentials): Promise<AuthResponse> => {
try {
console.log(getApiUrl('/auth/login'));
const response = await fetch(getApiUrl('/auth/login'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(credentials),
});
const response = await apiPost<AuthResponse>('/auth/login', credentials);
const data: AuthResponse = await response.json();
if (data.success && data.token) {
setToken(data.token);
// The auth API returns data directly, not wrapped in a data field
if (response.success && response.token) {
setToken(response.token);
return response;
}
return data;
return {
success: false,
message: response.message || 'Login failed',
};
} catch (error) {
console.error('Login error:', error);
return {
@@ -55,21 +37,17 @@ export const login = async (credentials: LoginCredentials): Promise<AuthResponse
// Register user
export const register = async (credentials: RegisterCredentials): Promise<AuthResponse> => {
try {
const response = await fetch(getApiUrl('/auth/register'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(credentials),
});
const response = await apiPost<AuthResponse>('/auth/register', credentials);
const data: AuthResponse = await response.json();
if (data.success && data.token) {
setToken(data.token);
if (response.success && response.token) {
setToken(response.token);
return response;
}
return data;
return {
success: false,
message: response.message || 'Registration failed',
};
} catch (error) {
console.error('Register error:', error);
return {
@@ -91,14 +69,8 @@ export const getCurrentUser = async (): Promise<AuthResponse> => {
}
try {
const response = await fetch(getApiUrl('/auth/user'), {
method: 'GET',
headers: {
'x-auth-token': token,
},
});
return await response.json();
const response = await apiGet<AuthResponse>('/auth/user');
return response;
} catch (error) {
console.error('Get current user error:', error);
return {
@@ -122,16 +94,8 @@ export const changePassword = async (
}
try {
const response = await fetch(getApiUrl('/auth/change-password'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token,
},
body: JSON.stringify(credentials),
});
return await response.json();
const response = await apiPost<AuthResponse>('/auth/change-password', credentials);
return response;
} catch (error) {
console.error('Change password error:', error);
return {

View File

@@ -0,0 +1,98 @@
import { apiGet, fetchWithInterceptors } from '../utils/fetchInterceptor';
import { getBasePath } from '../utils/runtime';
export interface SystemConfig {
routing?: {
enableGlobalRoute?: boolean;
enableGroupNameRoute?: boolean;
enableBearerAuth?: boolean;
bearerAuthKey?: string;
skipAuth?: boolean;
};
install?: {
pythonIndexUrl?: string;
npmRegistry?: string;
};
smartRouting?: {
enabled?: boolean;
dbUrl?: string;
openaiApiBaseUrl?: string;
openaiApiKey?: string;
openaiApiEmbeddingModel?: string;
};
}
export interface PublicConfigResponse {
success: boolean;
data?: {
skipAuth?: boolean;
permissions?: any;
};
message?: string;
}
export interface SystemConfigResponse {
success: boolean;
data?: {
systemConfig?: SystemConfig;
};
message?: string;
}
/**
* Get public configuration (skipAuth setting) without authentication
*/
export const getPublicConfig = async (): Promise<{ skipAuth: boolean; permissions?: any }> => {
try {
const basePath = getBasePath();
const response = await fetchWithInterceptors(`${basePath}/public-config`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (response.ok) {
const data: PublicConfigResponse = await response.json();
return { skipAuth: data.data?.skipAuth === true, permissions: data.data?.permissions || {} };
}
return { skipAuth: false };
} catch (error) {
console.debug('Failed to get public config:', error);
return { skipAuth: false };
}
};
/**
* Get system configuration without authentication
* This function tries to get the system configuration first without auth,
* and if that fails (likely due to auth requirements), it returns null
*/
export const getSystemConfigPublic = async (): Promise<SystemConfig | null> => {
try {
const response = await apiGet<SystemConfigResponse>('/settings');
if (response.success) {
return response.data?.systemConfig || null;
}
return null;
} catch (error) {
console.debug('Failed to get system config without auth:', error);
return null;
}
};
/**
* Check if authentication should be skipped based on system configuration
*/
export const shouldSkipAuth = async (): Promise<boolean> => {
try {
const config = await getPublicConfig();
return config.skipAuth;
} catch (error) {
console.debug('Failed to check skipAuth setting:', error);
return false;
}
};

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { getToken } from './authService'; // Import getToken function
import { apiGet, apiDelete } from '../utils/fetchInterceptor';
import { getApiUrl } from '../utils/runtime';
import { getToken } from '../utils/interceptors';
export interface LogEntry {
timestamp: number;
@@ -13,25 +14,13 @@ export interface LogEntry {
// Fetch all logs
export const fetchLogs = async (): Promise<LogEntry[]> => {
try {
// Get authentication token
const token = getToken();
if (!token) {
throw new Error('Authentication token not found. Please log in.');
const response = await apiGet<{ success: boolean; data: LogEntry[]; error?: string }>('/logs');
if (!response.success) {
throw new Error(response.error || 'Failed to fetch logs');
}
const response = await fetch(getApiUrl('/logs'), {
headers: {
'x-auth-token': token,
},
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Failed to fetch logs');
}
return result.data;
return response.data;
} catch (error) {
console.error('Error fetching logs:', error);
throw error;
@@ -41,23 +30,10 @@ export const fetchLogs = async (): Promise<LogEntry[]> => {
// Clear all logs
export const clearLogs = async (): Promise<void> => {
try {
// Get authentication token
const token = getToken();
if (!token) {
throw new Error('Authentication token not found. Please log in.');
}
const response = await apiDelete<{ success: boolean; error?: string }>('/logs');
const response = await fetch(getApiUrl('/logs'), {
method: 'DELETE',
headers: {
'x-auth-token': token,
},
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Failed to clear logs');
if (!response.success) {
throw new Error(response.error || 'Failed to clear logs');
}
} catch (error) {
console.error('Error clearing logs:', error);
@@ -84,12 +60,6 @@ export const useLogs = () => {
// Get the authentication token
const token = getToken();
if (!token) {
setError(new Error('Authentication token not found. Please log in.'));
setLoading(false);
return;
}
// Connect to SSE endpoint with auth token in URL
eventSource = new EventSource(getApiUrl(`/logs/stream?token=${token}`));

View File

@@ -1,5 +1,4 @@
import { getApiUrl } from '../utils/runtime';
import { getToken } from './authService';
import { apiPost, apiPut } from '../utils/fetchInterceptor';
export interface ToolCallRequest {
toolName: string;
@@ -25,42 +24,32 @@ export const callTool = async (
server?: string,
): Promise<ToolCallResult> => {
try {
const token = getToken();
if (!token) {
throw new Error('Authentication token not found. Please log in.');
}
// Construct the URL with optional server parameter
const url = server ? `/tools/call/${server}` : '/tools/call';
const response = await fetch(getApiUrl(url), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token,
Authorization: `Bearer ${token}`, // Add bearer auth for MCP routing
},
body: JSON.stringify({
const response = await apiPost<any>(
url,
{
toolName: request.toolName,
arguments: request.arguments,
}),
});
},
{
headers: {
Authorization: `Bearer ${localStorage.getItem('mcphub_token')}`, // Add bearer auth for MCP routing
},
},
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (!data.success) {
if (!response.success) {
return {
success: false,
error: data.message || 'Tool call failed',
error: response.message || 'Tool call failed',
};
}
return {
success: true,
content: data.data.content || [],
content: response.data?.content || [],
};
} catch (error) {
console.error('Error calling tool:', error);
@@ -70,3 +59,67 @@ 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 response = await apiPost<any>(
`/servers/${serverName}/tools/${toolName}/toggle`,
{ enabled },
{
headers: {
Authorization: `Bearer ${localStorage.getItem('mcphub_token')}`,
},
},
);
return {
success: response.success,
error: response.success ? undefined : response.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 response = await apiPut<any>(
`/servers/${serverName}/tools/${toolName}/description`,
{ description },
{
headers: {
Authorization: `Bearer ${localStorage.getItem('mcphub_token')}`,
},
},
);
return {
success: response.success,
error: response.success ? undefined : response.message,
};
} catch (error) {
console.error('Error updating tool description:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
};

View File

@@ -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
@@ -91,11 +137,17 @@ export interface Server {
}
// Group types
// Group server configuration - supports tool selection
export interface IGroupServerConfig {
name: string; // Server name
tools?: string[] | 'all'; // Array of specific tool names to include, or 'all' for all tools (default: 'all')
}
export interface Group {
id: string;
name: string;
description?: string;
servers: string[];
servers: string[] | IGroupServerConfig[]; // Supports both old and new format
}
// Environment variable types
@@ -111,16 +163,46 @@ export interface ServerFormData {
command: string;
arguments: string;
args?: string[]; // Added explicit args field
type?: 'stdio' | 'sse' | 'streamable-http'; // Added type field
type?: 'stdio' | 'sse' | 'streamable-http' | 'openapi'; // Added type field with openapi support
env: EnvVar[];
headers: EnvVar[];
options?: {
timeout?: number;
resetTimeoutOnProgress?: boolean;
maxTotalTimeout?: number;
};
// OpenAPI specific fields
openapi?: {
url?: string;
schema?: string; // JSON schema as string for form input
inputMode?: 'url' | 'schema'; // Mode to determine input type
version?: string;
securityType?: 'none' | 'apiKey' | 'http' | 'oauth2' | 'openIdConnect';
// API Key fields
apiKeyName?: string;
apiKeyIn?: 'header' | 'query' | 'cookie';
apiKeyValue?: string;
// HTTP auth fields
httpScheme?: 'basic' | 'bearer' | 'digest';
httpCredentials?: string;
// OAuth2 fields
oauth2TokenUrl?: string;
oauth2ClientId?: string;
oauth2ClientSecret?: string;
oauth2Token?: string;
// OpenID Connect fields
openIdConnectUrl?: string;
openIdConnectClientId?: string;
openIdConnectClientSecret?: string;
openIdConnectToken?: string;
};
}
// Group form data types
export interface GroupFormData {
name: string;
description: string;
servers: string[]; // Added servers array to include in form data
servers: string[] | IGroupServerConfig[]; // Updated to support new format
}
// API response types
@@ -134,6 +216,30 @@ export interface ApiResponse<T = any> {
export interface IUser {
username: string;
isAdmin?: boolean;
permissions?: string[];
}
// User management types
export interface User {
username: string;
isAdmin: boolean;
}
export interface UserFormData {
username: string;
password: string;
isAdmin: boolean;
}
export interface UserUpdateData {
isAdmin?: boolean;
newPassword?: string;
}
export interface UserStats {
totalUsers: number;
adminUsers: number;
regularUsers: number;
}
export interface AuthState {

View File

@@ -0,0 +1,174 @@
import { getApiUrl } from './runtime';
// Define the interceptor interface
export interface FetchInterceptor {
request?: (url: string, config: RequestInit) => Promise<{ url: string; config: RequestInit }>;
response?: (response: Response) => Promise<Response>;
error?: (error: Error) => Promise<Error>;
}
// Define the enhanced fetch response interface
export interface ApiResponse<T = any> {
success: boolean;
data?: T;
message?: string;
error?: string;
}
// Global interceptors store
const interceptors: FetchInterceptor[] = [];
// Add an interceptor
export const addInterceptor = (interceptor: FetchInterceptor): void => {
interceptors.push(interceptor);
};
// Remove an interceptor
export const removeInterceptor = (interceptor: FetchInterceptor): void => {
const index = interceptors.indexOf(interceptor);
if (index > -1) {
interceptors.splice(index, 1);
}
};
// Clear all interceptors
export const clearInterceptors = (): void => {
interceptors.length = 0;
};
// Enhanced fetch function with interceptors
export const fetchWithInterceptors = async (
input: string | URL | Request,
init: RequestInit = {},
): Promise<Response> => {
let url = input.toString();
let config = { ...init };
try {
// Apply request interceptors
for (const interceptor of interceptors) {
if (interceptor.request) {
const result = await interceptor.request(url, config);
url = result.url;
config = result.config;
}
}
// Make the actual fetch request
let response = await fetch(url, config);
// Apply response interceptors
for (const interceptor of interceptors) {
if (interceptor.response) {
response = await interceptor.response(response);
}
}
return response;
} catch (error) {
let processedError = error as Error;
// Apply error interceptors
for (const interceptor of interceptors) {
if (interceptor.error) {
processedError = await interceptor.error(processedError);
}
}
throw processedError;
}
};
// Convenience function for API calls with automatic URL construction
export const apiRequest = async <T = any>(endpoint: string, init: RequestInit = {}): Promise<T> => {
try {
const url = getApiUrl(endpoint);
const response = await fetchWithInterceptors(url, init);
// Try to parse JSON response
let data: T;
try {
data = await response.json();
} catch (parseError) {
// If JSON parsing fails, create a generic response
const genericResponse = {
success: response.ok,
message: response.ok
? 'Request successful'
: `HTTP ${response.status}: ${response.statusText}`,
};
data = genericResponse as T;
}
// If response is not ok, but no explicit error in parsed data
if (!response.ok && typeof data === 'object' && data !== null) {
const responseObj = data as any;
if (responseObj.success !== false) {
responseObj.success = false;
responseObj.message =
responseObj.message || `HTTP ${response.status}: ${response.statusText}`;
}
}
return data;
} catch (error) {
console.error('API request error:', error);
const errorResponse = {
success: false,
message: error instanceof Error ? error.message : 'An unknown error occurred',
};
return errorResponse as T;
}
};
// Convenience methods for common HTTP methods
export const apiGet = <T = any>(endpoint: string, init: Omit<RequestInit, 'method'> = {}) =>
apiRequest<T>(endpoint, { ...init, method: 'GET' });
export const apiPost = <T = any>(
endpoint: string,
data?: any,
init: Omit<RequestInit, 'method' | 'body'> = {},
) =>
apiRequest<T>(endpoint, {
...init,
method: 'POST',
headers: {
'Content-Type': 'application/json',
...init.headers,
},
body: data ? JSON.stringify(data) : undefined,
});
export const apiPut = <T = any>(
endpoint: string,
data?: any,
init: Omit<RequestInit, 'method' | 'body'> = {},
) =>
apiRequest<T>(endpoint, {
...init,
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...init.headers,
},
body: data ? JSON.stringify(data) : undefined,
});
export const apiDelete = <T = any>(endpoint: string, init: Omit<RequestInit, 'method'> = {}) =>
apiRequest<T>(endpoint, { ...init, method: 'DELETE' });
export const apiPatch = <T = any>(
endpoint: string,
data?: any,
init: Omit<RequestInit, 'method' | 'body'> = {},
) =>
apiRequest<T>(endpoint, {
...init,
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...init.headers,
},
body: data ? JSON.stringify(data) : undefined,
});

View File

@@ -0,0 +1,99 @@
import { addInterceptor, removeInterceptor, type FetchInterceptor } from './fetchInterceptor';
// Token key in localStorage
const TOKEN_KEY = 'mcphub_token';
// Get token from localStorage
export const getToken = (): string | null => {
return localStorage.getItem(TOKEN_KEY);
};
// Set token in localStorage
export const setToken = (token: string): void => {
localStorage.setItem(TOKEN_KEY, token);
};
// Remove token from localStorage
export const removeToken = (): void => {
localStorage.removeItem(TOKEN_KEY);
};
// Auth interceptor for automatically adding authorization headers
export const authInterceptor: FetchInterceptor = {
request: async (url: string, config: RequestInit) => {
const headers = new Headers(config.headers);
const language = localStorage.getItem('i18nextLng') || 'en';
headers.set('Accept-Language', language);
const token = getToken();
if (token) {
headers.set('x-auth-token', token);
}
return {
url,
config: {
...config,
headers,
},
};
},
response: async (response: Response) => {
// Handle unauthorized responses
if (response.status === 401) {
// Token might be expired or invalid, remove it
removeToken();
// You could also trigger a redirect to login page here
// window.location.href = '/login';
}
return response;
},
error: async (error: Error) => {
console.error('Auth interceptor error:', error);
return error;
},
};
// Install the auth interceptor
export const installAuthInterceptor = (): void => {
addInterceptor(authInterceptor);
};
// Uninstall the auth interceptor
export const uninstallAuthInterceptor = (): void => {
removeInterceptor(authInterceptor);
};
// Logging interceptor for development
export const loggingInterceptor: FetchInterceptor = {
request: async (url: string, config: RequestInit) => {
console.log(`🚀 [${config.method || 'GET'}] ${url}`, config);
return { url, config };
},
response: async (response: Response) => {
console.log(`✅ [${response.status}] ${response.url}`);
return response;
},
error: async (error: Error) => {
console.error(`❌ Fetch error:`, error);
return error;
},
};
// Install the logging interceptor (only in development)
export const installLoggingInterceptor = (): void => {
if (process.env.NODE_ENV === 'development') {
addInterceptor(loggingInterceptor);
}
};
// Uninstall the logging interceptor
export const uninstallLoggingInterceptor = (): void => {
removeInterceptor(loggingInterceptor);
};

View File

@@ -56,7 +56,7 @@ export const loadRuntimeConfig = async (): Promise<RuntimeConfig> => {
const currentPath = window.location.pathname;
const possibleConfigPaths = [
// If we're already on a subpath, try to use it
currentPath.replace(/\/[^\/]*$/, '') + '/config',
currentPath.replace(/\/[^/]*$/, '') + '/config',
// Try root config
'/config',
// Try with potential base paths

View File

@@ -0,0 +1,19 @@
import { installAuthInterceptor, installLoggingInterceptor } from './interceptors';
/**
* Setup all default interceptors for the application
* This should be called once when the app initializes
*/
export const setupInterceptors = (): void => {
// Install auth interceptor for automatic token handling
installAuthInterceptor();
// Install logging interceptor in development mode
installLoggingInterceptor();
};
/**
* Initialize interceptors automatically when this module is imported
* This ensures interceptors are set up as early as possible
*/
setupInterceptors();

View File

@@ -0,0 +1,27 @@
// Utility function to detect ${} variables in server configurations
export const detectVariables = (payload: any): string[] => {
const variables = new Set<string>();
const variableRegex = /\$\{([^}]+)\}/g;
const checkString = (str: string) => {
let match;
while ((match = variableRegex.exec(str)) !== null) {
variables.add(match[1]);
}
};
const checkObject = (obj: any, path: string = '') => {
if (typeof obj === 'string') {
checkString(obj);
} else if (Array.isArray(obj)) {
obj.forEach((item, index) => checkObject(item, `${path}[${index}]`));
} else if (obj && typeof obj === 'object') {
Object.entries(obj).forEach(([key, value]) => {
checkObject(value, path ? `${path}.${key}` : key);
});
}
};
checkObject(payload);
return Array.from(variables);
};

View File

@@ -39,6 +39,14 @@ export default defineConfig({
target: 'http://localhost:3000',
changeOrigin: true,
},
[`${basePath}/config`]: {
target: 'http://localhost:3000',
changeOrigin: true,
},
[`${basePath}/public-config`]: {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
});

47
jest.config.cjs Normal file
View File

@@ -0,0 +1,47 @@
module.exports = {
preset: 'ts-jest/presets/default-esm',
testEnvironment: 'node',
roots: ['<rootDir>/src', '<rootDir>/tests'],
testMatch: [
'<rootDir>/src/**/__tests__/**/*.{ts,tsx}',
'<rootDir>/src/**/*.{test,spec}.{ts,tsx}',
'<rootDir>/tests/**/*.{test,spec}.{ts,tsx}',
],
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
useESM: true,
tsconfig: './tsconfig.test.json',
},
],
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/index.ts',
'!src/**/__tests__/**',
'!src/**/*.test.{ts,tsx}',
'!src/**/*.spec.{ts,tsx}',
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
coverageThreshold: {
global: {
branches: 0,
functions: 0,
lines: 0,
statements: 0,
},
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'^(\\.{1,2}/.*)\\.js$': '$1',
},
transformIgnorePatterns: ['node_modules/(?!(@modelcontextprotocol|other-esm-packages)/)'],
extensionsToTreatAsEsm: ['.ts'],
testTimeout: 30000,
verbose: true,
};

View File

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

View File

@@ -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",
@@ -135,17 +173,27 @@
"cancel": "Cancel",
"refresh": "Refresh",
"create": "Create",
"creating": "Creating...",
"update": "Update",
"updating": "Updating...",
"submitting": "Submitting...",
"delete": "Delete",
"remove": "Remove",
"copy": "Copy",
"copyId": "Copy ID",
"copyUrl": "Copy URL",
"copyJson": "Copy JSON",
"copySuccess": "Copied to clipboard",
"copyFailed": "Copy failed",
"close": "Close"
"close": "Close",
"confirm": "Confirm",
"language": "Language"
},
"nav": {
"dashboard": "Dashboard",
"servers": "Servers",
"groups": "Groups",
"users": "Users",
"settings": "Settings",
"changePassword": "Change Password",
"market": "Market",
@@ -166,6 +214,9 @@
"groups": {
"title": "Group Management"
},
"users": {
"title": "User Management"
},
"settings": {
"title": "Settings",
"language": "Language",
@@ -220,7 +271,14 @@
"noGroups": "No groups available. Create a new group to get started.",
"noServers": "No servers in this group.",
"noServerOptions": "No servers available",
"serverCount": "{{count}} Servers"
"serverCount": "{{count}} Servers",
"toolSelection": "Tool Selection",
"toolsSelected": "Selected",
"allTools": "All",
"selectedTools": "Selected tools",
"selectAll": "Select All",
"selectNone": "Select None",
"configureTools": "Configure Tools"
},
"market": {
"title": "Server Market",
@@ -262,7 +320,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 +344,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,12 +369,17 @@
"bearerAuthKey": "Bearer Authentication Key",
"bearerAuthKeyDescription": "The authentication key that will be required in the Bearer token",
"bearerAuthKeyPlaceholder": "Enter bearer authentication key",
"skipAuth": "Skip Authentication",
"skipAuthDescription": "Bypass login requirement for frontend and API access (DEFAULT OFF for security)",
"pythonIndexUrl": "Python Package Repository URL",
"pythonIndexUrlDescription": "Set UV_DEFAULT_INDEX environment variable for Python package installation",
"pythonIndexUrlPlaceholder": "e.g. https://pypi.org/simple",
"npmRegistry": "NPM Registry URL",
"npmRegistryDescription": "Set npm_config_registry environment variable for NPM package installation",
"npmRegistryPlaceholder": "e.g. https://registry.npmjs.org/",
"baseUrl": "Base URL",
"baseUrlDescription": "Base URL for MCP requests",
"baseUrlPlaceholder": "e.g. http://localhost:3000",
"installConfig": "Installation",
"systemConfigUpdated": "System configuration updated successfully",
"enableSmartRouting": "Enable Smart Routing",
@@ -317,5 +395,124 @@
"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"
},
"users": {
"add": "Add User",
"addNew": "Add New User",
"edit": "Edit User",
"delete": "Delete User",
"create": "Create User",
"update": "Update User",
"username": "Username",
"password": "Password",
"newPassword": "New Password",
"confirmPassword": "Confirm Password",
"adminRole": "Administrator",
"admin": "Admin",
"user": "User",
"permissions": "Permissions",
"adminPermissions": "Full system access",
"userPermissions": "Limited access",
"currentUser": "You",
"noUsers": "No users found",
"adminRequired": "Administrator access required to manage users",
"usernameRequired": "Username is required",
"passwordRequired": "Password is required",
"passwordTooShort": "Password must be at least 6 characters long",
"passwordMismatch": "Passwords do not match",
"usernamePlaceholder": "Enter username",
"passwordPlaceholder": "Enter password",
"newPasswordPlaceholder": "Leave empty to keep current password",
"confirmPasswordPlaceholder": "Confirm new password",
"createError": "Failed to create user",
"updateError": "Failed to update user",
"deleteError": "Failed to delete user",
"statsError": "Failed to fetch user statistics",
"deleteConfirmation": "Are you sure you want to delete user '{{username}}'? This action cannot be undone.",
"confirmDelete": "Delete User",
"deleteWarning": "Are you sure you want to delete user '{{username}}'? This action cannot be undone."
},
"api": {
"errors": {
"readonly": "Readonly for demo environment",
"serverNameRequired": "Server name is required",
"serverConfigRequired": "Server configuration is required",
"serverConfigInvalid": "Server configuration must include either a URL, OpenAPI specification URL or schema, or command with arguments",
"serverTypeInvalid": "Server type must be one of: stdio, sse, streamable-http, openapi",
"urlRequiredForType": "URL is required for {{type}} server type",
"openapiSpecRequired": "OpenAPI specification URL or schema is required for openapi server type",
"headersInvalidFormat": "Headers must be an object",
"headersNotSupportedForStdio": "Headers are not supported for stdio server type",
"serverNotFound": "Server not found",
"failedToRemoveServer": "Server not found or failed to remove",
"internalServerError": "Internal server error",
"failedToGetServers": "Failed to get servers information",
"failedToGetServerSettings": "Failed to get server settings",
"failedToGetServerConfig": "Failed to get server configuration",
"failedToSaveSettings": "Failed to save settings",
"toolNameRequired": "Server name and tool name are required",
"descriptionMustBeString": "Description must be a string",
"groupIdRequired": "Group ID is required",
"groupNameRequired": "Group name is required",
"groupNotFound": "Group not found",
"groupIdAndServerNameRequired": "Group ID and server name are required",
"groupOrServerNotFound": "Group or server not found",
"toolsMustBeAllOrArray": "Tools must be \"all\" or an array of strings",
"serverNameAndToolNameRequired": "Server name and tool name are required",
"usernameRequired": "Username is required",
"userNotFound": "User not found",
"failedToGetUsers": "Failed to get users information",
"failedToGetUserInfo": "Failed to get user information",
"failedToGetUserStats": "Failed to get user statistics",
"marketServerNameRequired": "Server name is required",
"marketServerNotFound": "Market server not found",
"failedToGetMarketServers": "Failed to get market servers information",
"failedToGetMarketServer": "Failed to get market server information",
"failedToGetMarketCategories": "Failed to get market categories",
"failedToGetMarketTags": "Failed to get market tags",
"failedToSearchMarketServers": "Failed to search market servers",
"failedToFilterMarketServers": "Failed to filter market servers",
"failedToProcessDxtFile": "Failed to process DXT file"
},
"success": {
"serverCreated": "Server created successfully",
"serverUpdated": "Server updated successfully",
"serverRemoved": "Server removed successfully",
"serverToggled": "Server status toggled successfully",
"toolToggled": "Tool {{name}} {{action}} successfully",
"toolDescriptionUpdated": "Tool {{name}} description updated successfully",
"systemConfigUpdated": "System configuration updated successfully",
"groupCreated": "Group created successfully",
"groupUpdated": "Group updated successfully",
"groupDeleted": "Group deleted successfully",
"serverAddedToGroup": "Server added to group successfully",
"serverRemovedFromGroup": "Server removed from group successfully",
"serverToolsUpdated": "Server tools updated successfully"
}
}
}

View File

@@ -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": "在线",
@@ -136,12 +174,21 @@
"cancel": "取消",
"refresh": "刷新",
"create": "创建",
"creating": "创建中...",
"update": "更新",
"updating": "更新中...",
"submitting": "提交中...",
"delete": "删除",
"remove": "移除",
"copy": "复制",
"copyId": "复制ID",
"copyUrl": "复制URL",
"copyJson": "复制JSON",
"copySuccess": "已复制到剪贴板",
"copyFailed": "复制失败",
"close": "关闭"
"close": "关闭",
"confirm": "确认",
"language": "语言"
},
"nav": {
"dashboard": "仪表盘",
@@ -149,6 +196,7 @@
"settings": "设置",
"changePassword": "修改密码",
"groups": "分组",
"users": "用户",
"market": "市场",
"logs": "日志"
},
@@ -177,6 +225,9 @@
"groups": {
"title": "分组管理"
},
"users": {
"title": "用户管理"
},
"market": {
"title": "服务器市场 - (数据来源于 mcpm.sh"
},
@@ -221,7 +272,14 @@
"noGroups": "暂无可用分组。创建一个新分组以开始使用。",
"noServers": "此分组中没有服务器。",
"noServerOptions": "没有可用的服务器",
"serverCount": "{{count}} 台服务器"
"serverCount": "{{count}} 台服务器",
"toolSelection": "工具选择",
"toolsSelected": "选择",
"allTools": "全部",
"selectedTools": "选中的工具",
"selectAll": "全选",
"selectNone": "全不选",
"configureTools": "配置工具"
},
"market": {
"title": "服务器市场",
@@ -263,12 +321,14 @@
"tagFilterError": "按标签筛选服务器失败",
"noInstallationMethod": "该服务器没有可用的安装方法",
"showing": "显示 {{from}}-{{to}}/{{total}} 个服务器",
"perPage": "每页显示"
"perPage": "每页显示",
"confirmVariablesMessage": "请确保这些变量在运行环境中已正确定义。是否继续安装服务器?",
"confirmAndInstall": "确认并安装"
},
"tool": {
"run": "运行",
"running": "运行中...",
"runTool": "运行工具",
"runTool": "运行",
"cancel": "取消",
"noDescription": "无描述信息",
"inputSchema": "输入模式:",
@@ -285,7 +345,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,12 +370,17 @@
"bearerAuthKey": "Bearer 认证密钥",
"bearerAuthKeyDescription": "Bearer 令牌中需要携带的认证密钥",
"bearerAuthKeyPlaceholder": "请输入 Bearer 认证密钥",
"skipAuth": "免登录开关",
"skipAuthDescription": "跳过前端和 API 访问的登录要求(默认关闭确保安全性)",
"pythonIndexUrl": "Python 包仓库地址",
"pythonIndexUrlDescription": "设置 UV_DEFAULT_INDEX 环境变量,用于 Python 包安装",
"pythonIndexUrlPlaceholder": "例如: https://mirrors.aliyun.com/pypi/simple",
"npmRegistry": "NPM 仓库地址",
"npmRegistryDescription": "设置 npm_config_registry 环境变量,用于 NPM 包安装",
"npmRegistryPlaceholder": "例如: https://registry.npmmirror.com/",
"baseUrl": "基础地址",
"baseUrlDescription": "用于 MCP 请求的基础地址",
"baseUrlPlaceholder": "例如: http://localhost:3000",
"installConfig": "安装配置",
"systemConfigUpdated": "系统配置更新成功",
"enableSmartRouting": "启用智能路由",
@@ -319,5 +397,124 @@
"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": "覆盖"
},
"users": {
"add": "添加",
"addNew": "添加新用户",
"edit": "编辑用户",
"delete": "删除用户",
"create": "创建",
"update": "更新",
"username": "用户名",
"password": "密码",
"newPassword": "新密码",
"confirmPassword": "确认密码",
"adminRole": "管理员",
"admin": "管理员",
"user": "用户",
"permissions": "权限",
"adminPermissions": "完全系统访问权限",
"userPermissions": "受限访问权限",
"currentUser": "当前用户",
"noUsers": "没有找到用户",
"adminRequired": "需要管理员权限才能管理用户",
"usernameRequired": "用户名是必需的",
"passwordRequired": "密码是必需的",
"passwordTooShort": "密码至少需要6个字符",
"passwordMismatch": "密码不匹配",
"usernamePlaceholder": "输入用户名",
"passwordPlaceholder": "输入密码",
"newPasswordPlaceholder": "留空保持当前密码",
"confirmPasswordPlaceholder": "确认新密码",
"createError": "创建用户失败",
"updateError": "更新用户失败",
"deleteError": "删除用户失败",
"statsError": "获取用户统计失败",
"deleteConfirmation": "您确定要删除用户 '{{username}}' 吗?此操作无法撤消。",
"confirmDelete": "删除用户",
"deleteWarning": "您确定要删除用户 '{{username}}' 吗?此操作无法撤消。"
},
"api": {
"errors": {
"readonly": "演示环境无法修改数据",
"serverNameRequired": "服务器名称是必需的",
"serverConfigRequired": "服务器配置是必需的",
"serverConfigInvalid": "服务器配置必须包含 URL、OpenAPI 规范 URL 或模式,或者带参数的命令",
"serverTypeInvalid": "服务器类型必须是以下之一stdio、sse、streamable-http、openapi",
"urlRequiredForType": "{{type}} 服务器类型需要 URL",
"openapiSpecRequired": "openapi 服务器类型需要 OpenAPI 规范 URL 或模式",
"headersInvalidFormat": "请求头必须是对象格式",
"headersNotSupportedForStdio": "stdio 服务器类型不支持请求头",
"serverNotFound": "找不到服务器",
"failedToRemoveServer": "找不到服务器或删除失败",
"internalServerError": "服务器内部错误",
"failedToGetServers": "获取服务器信息失败",
"failedToGetServerSettings": "获取服务器设置失败",
"failedToGetServerConfig": "获取服务器配置失败",
"failedToSaveSettings": "保存设置失败",
"toolNameRequired": "服务器名称和工具名称是必需的",
"descriptionMustBeString": "描述必须是字符串",
"groupIdRequired": "分组 ID 是必需的",
"groupNameRequired": "分组名称是必需的",
"groupNotFound": "找不到分组",
"groupIdAndServerNameRequired": "分组 ID 和服务器名称是必需的",
"groupOrServerNotFound": "找不到分组或服务器",
"toolsMustBeAllOrArray": "工具必须是 \"all\" 或字符串数组",
"serverNameAndToolNameRequired": "服务器名称和工具名称是必需的",
"usernameRequired": "用户名是必需的",
"userNotFound": "找不到用户",
"failedToGetUsers": "获取用户信息失败",
"failedToGetUserInfo": "获取用户信息失败",
"failedToGetUserStats": "获取用户统计信息失败",
"marketServerNameRequired": "服务器名称是必需的",
"marketServerNotFound": "找不到市场服务器",
"failedToGetMarketServers": "获取市场服务器信息失败",
"failedToGetMarketServer": "获取市场服务器信息失败",
"failedToGetMarketCategories": "获取市场类别失败",
"failedToGetMarketTags": "获取市场标签失败",
"failedToSearchMarketServers": "搜索市场服务器失败",
"failedToFilterMarketServers": "过滤市场服务器失败",
"failedToProcessDxtFile": "处理 DXT 文件失败"
},
"success": {
"serverCreated": "服务器创建成功",
"serverUpdated": "服务器更新成功",
"serverRemoved": "服务器删除成功",
"serverToggled": "服务器状态切换成功",
"toolToggled": "工具 {{name}} {{action}} 成功",
"toolDescriptionUpdated": "工具 {{name}} 描述更新成功",
"systemConfigUpdated": "系统配置更新成功",
"groupCreated": "分组创建成功",
"groupUpdated": "分组更新成功",
"groupDeleted": "分组删除成功",
"serverAddedToGroup": "服务器添加到分组成功",
"serverRemovedFromGroup": "服务器从分组移除成功",
"serverToolsUpdated": "服务器工具更新成功"
}
}
}

View File

@@ -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,23 @@
"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",
"i18next-fs-backend": "^2.6.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.1",
"openai": "^4.103.0",
"openapi-types": "^12.1.3",
"pg": "^8.16.0",
"pgvector": "^0.2.1",
"postgres": "^3.4.7",
@@ -61,6 +73,8 @@
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2",
"@shadcn/ui": "^0.0.4",
"@swc/core": "^1.13.0",
"@swc/jest": "^0.2.39",
"@tailwindcss/postcss": "^4.1.3",
"@tailwindcss/vite": "^4.1.7",
"@types/bcryptjs": "^3.0.0",
@@ -70,6 +84,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 +92,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 +107,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",
@@ -103,5 +121,5 @@
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"packageManager": "pnpm@10.11.0+sha256.a69e9cb077da419d47d18f1dd52e207245b29cac6e076acedbeb8be3b1a67bd7"
}
"packageManager": "pnpm@10.12.4+sha256.cadfd9e6c9fcc2cb76fe7c0779a5250b632898aea5f53d833a73690c77a778d9"
}

2269
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
ignoredBuiltDependencies:
- '@swc/core'

View File

@@ -0,0 +1,200 @@
import { OpenAPIClient } from '../openapi.js';
import { ServerConfig } from '../../types/index.js';
import { OpenAPIV3 } from 'openapi-types';
describe('OpenAPIClient - Operation Name Generation', () => {
describe('generateOperationName', () => {
test('should generate operation name from method and path', async () => {
const config: ServerConfig = {
type: 'openapi',
openapi: {
schema: {
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {
'/users': {
get: {
summary: 'Get users',
responses: { '200': { description: 'Success' } },
},
post: {
summary: 'Create user',
responses: { '201': { description: 'Created' } },
},
},
'/users/{id}': {
get: {
summary: 'Get user by ID',
responses: { '200': { description: 'Success' } },
},
delete: {
summary: 'Delete user',
responses: { '204': { description: 'Deleted' } },
},
},
'/admin/settings': {
get: {
summary: 'Get admin settings',
responses: { '200': { description: 'Success' } },
},
},
'/': {
get: {
summary: 'Root endpoint',
responses: { '200': { description: 'Success' } },
},
},
},
} as OpenAPIV3.Document,
},
};
const testClient = new OpenAPIClient(config);
await testClient.initialize();
const tools = testClient.getTools();
// Verify generated operation names
expect(tools).toHaveLength(6);
const toolNames = tools.map((t) => t.name).sort();
expect(toolNames).toEqual(
[
'delete_users',
'get_admin_settings',
'get_root',
'get_users',
'post_users',
'get_users1', // Second GET /users/{id}, will add numeric suffix
].sort(),
);
});
test('should use operationId when available and generate name when missing', async () => {
const config: ServerConfig = {
type: 'openapi',
openapi: {
schema: {
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {
'/users': {
get: {
operationId: 'listUsers',
summary: 'Get users',
responses: { '200': { description: 'Success' } },
},
post: {
// No operationId, should generate post_users
summary: 'Create user',
responses: { '201': { description: 'Created' } },
},
},
'/users/{id}': {
get: {
operationId: 'getUserById',
summary: 'Get user by ID',
responses: { '200': { description: 'Success' } },
},
},
},
} as OpenAPIV3.Document,
},
};
const testClient = new OpenAPIClient(config);
await testClient.initialize();
const tools = testClient.getTools();
expect(tools).toHaveLength(3);
const toolsByName = tools.reduce(
(acc, tool) => {
acc[tool.name] = tool;
return acc;
},
{} as Record<string, any>,
);
// Those with operationId should use the original operationId
expect(toolsByName['listUsers']).toBeDefined();
expect(toolsByName['listUsers'].operationId).toBe('listUsers');
expect(toolsByName['getUserById']).toBeDefined();
expect(toolsByName['getUserById'].operationId).toBe('getUserById');
// Those without operationId should generate names
expect(toolsByName['post_users']).toBeDefined();
expect(toolsByName['post_users'].operationId).toBe('post_users');
});
test('should handle duplicate generated names with counter', async () => {
const config: ServerConfig = {
type: 'openapi',
openapi: {
schema: {
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {
'/users': {
get: {
summary: 'Get users',
responses: { '200': { description: 'Success' } },
},
},
'/users/': {
get: {
summary: 'Get users with trailing slash',
responses: { '200': { description: 'Success' } },
},
},
},
} as OpenAPIV3.Document,
},
};
const testClient = new OpenAPIClient(config);
await testClient.initialize();
const tools = testClient.getTools();
expect(tools).toHaveLength(2);
const toolNames = tools.map((t) => t.name).sort();
expect(toolNames).toEqual(['get_users', 'get_users1']);
});
test('should handle complex paths with parameters and special characters', async () => {
const config: ServerConfig = {
type: 'openapi',
openapi: {
schema: {
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {
'/api/v1/users/{user-id}/posts/{post_id}': {
get: {
summary: 'Get user post',
responses: { '200': { description: 'Success' } },
},
},
'/api-v2/user-profiles': {
post: {
summary: 'Create user profile',
responses: { '201': { description: 'Created' } },
},
},
},
} as OpenAPIV3.Document,
},
};
const testClient = new OpenAPIClient(config);
await testClient.initialize();
const tools = testClient.getTools();
expect(tools).toHaveLength(2);
const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain('get_api_v1_users_posts'); // Path parameters removed, special characters cleaned
expect(toolNames).toContain('post_apiv2_userprofiles'); // Hyphens and underscores cleaned, lowercase with underscores
});
});
});

378
src/clients/openapi.ts Normal file
View File

@@ -0,0 +1,378 @@
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');
}
// Initial baseUrl, will be updated from OpenAPI servers field in initialize()
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');
}
// Update baseUrl from OpenAPI servers field
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 generateOperationName(method: string, path: string): string {
// Clean path, remove parameter brackets and special characters
const cleanPath = path
.replace(/\{[^}]+\}/g, '') // Remove {param} format parameters
.replace(/[^\w/]/g, '') // Remove special characters, keep alphanumeric and slashes
.split('/')
.filter((segment) => segment.length > 0) // Remove empty segments
.map((segment) => segment.toLowerCase()) // Convert to lowercase
.join('_'); // Join with underscores
// Convert method to lowercase and combine with path
const methodName = method.toLowerCase();
return `${methodName}_${cleanPath || 'root'}`;
}
private updateBaseUrlFromServers(): void {
if (!this.spec?.servers || this.spec.servers.length === 0) {
return;
}
// Get the first server's URL
const serverUrl = this.spec.servers[0].url;
// If it's a relative path, combine with original spec URL
if (serverUrl.startsWith('/')) {
// Relative path, use protocol and host from original 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://')) {
// Absolute path
this.baseUrl = serverUrl;
} else {
// Relative path but doesn't start with /, might be relative to current path
if (this.config.openapi?.url) {
const originalUrl = new URL(this.config.openapi.url);
this.baseUrl = `${originalUrl.protocol}//${originalUrl.host}/${serverUrl}`;
}
}
// Update HTTP client's baseURL
this.httpClient.defaults.baseURL = this.baseUrl;
}
private extractTools(): void {
if (!this.spec?.paths) {
return;
}
this.tools = [];
const generatedNames = new Set<string>(); // Used to ensure generated names are unique
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) continue;
// Generate operation name: use operationId first, otherwise generate unique name
let operationName: string;
if (operation.operationId) {
operationName = operation.operationId;
} else {
operationName = this.generateOperationName(method, path);
// Ensure name uniqueness, add numeric suffix if duplicate
let uniqueName = operationName;
let counter = 1;
while (generatedNames.has(uniqueName) || this.tools.some((t) => t.name === uniqueName)) {
uniqueName = `${operationName}${counter}`;
counter++;
}
operationName = uniqueName;
}
generatedNames.add(operationName);
const tool: OpenAPIToolInfo = {
name: operationName,
description:
operation.summary || operation.description || `${method.toUpperCase()} ${path}`,
inputSchema: this.generateInputSchema(operation, path, method as string),
operationId: operation.operationId || operationName,
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
}
}

View File

@@ -1,8 +1,10 @@
import dotenv from 'dotenv';
import fs from 'fs';
import { McpSettings } from '../types/index.js';
import { McpSettings, IUser } from '../types/index.js';
import { getConfigFilePath } from '../utils/path.js';
import { getPackageVersion } from '../utils/version.js';
import { getDataService } from '../services/services.js';
import { DataService } from '../services/dataService.js';
dotenv.config();
@@ -11,29 +13,60 @@ const defaultConfig = {
initTimeout: process.env.INIT_TIMEOUT || 300000,
timeout: process.env.REQUEST_TIMEOUT || 60000,
basePath: process.env.BASE_PATH || '',
readonly: 'true' === process.env.READONLY || false,
mcpHubName: 'mcphub',
mcpHubVersion: getPackageVersion(),
};
const dataService: DataService = getDataService();
// Settings cache
let settingsCache: McpSettings | null = null;
export const getSettingsPath = (): string => {
return getConfigFilePath('mcp_settings.json', 'Settings');
};
export const loadSettings = (): McpSettings => {
export const loadOriginalSettings = (): 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;
}
};
export const saveSettings = (settings: McpSettings): boolean => {
export const loadSettings = (user?: IUser): McpSettings => {
return dataService.filterSettings!(loadOriginalSettings(), user);
};
export const saveSettings = (settings: McpSettings, user?: IUser): boolean => {
const settingsPath = getSettingsPath();
try {
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
const mergedSettings = dataService.mergeSettings!(loadOriginalSettings(), settings, user);
fs.writeFileSync(settingsPath, JSON.stringify(mergedSettings, null, 2), 'utf8');
// Update cache after successful save
settingsCache = mergedSettings;
return true;
} catch (error) {
console.error(`Failed to save settings to ${settingsPath}:`, error);
@@ -41,18 +74,59 @@ export const saveSettings = (settings: McpSettings): boolean => {
}
};
export const replaceEnvVars = (env: Record<string, any>): Record<string, any> => {
const res: Record<string, string> = {};
for (const [key, value] of Object.entries(env)) {
if (typeof value === 'string') {
res[key] = expandEnvVars(value);
} else {
res[key] = String(value);
}
}
return res;
/**
* 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 function replaceEnvVars(input: Record<string, any>): Record<string, any>;
export function replaceEnvVars(input: string[] | undefined): string[];
export function replaceEnvVars(input: string): string;
export function replaceEnvVars(
input: Record<string, any> | string[] | string | undefined,
): Record<string, any> | string[] | string {
// Handle object input
if (input && typeof input === 'object' && !Array.isArray(input)) {
const res: Record<string, string> = {};
for (const [key, value] of Object.entries(input)) {
if (typeof value === 'string') {
res[key] = expandEnvVars(value);
} else {
res[key] = String(value);
}
}
return res;
}
// Handle array input
if (Array.isArray(input)) {
return input.map((item) => expandEnvVars(item));
}
// Handle string input
if (typeof input === 'string') {
return expandEnvVars(input);
}
// Handle undefined/null array input
if (input === undefined || input === null) {
return [];
}
return input;
}
export const expandEnvVars = (value: string): string => {
if (typeof value !== 'string') {
return String(value);

View File

@@ -1,7 +1,16 @@
import { Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import { validationResult } from 'express-validator';
import { findUserByUsername, verifyPassword, createUser, updateUserPassword } from '../models/User.js';
import {
findUserByUsername,
verifyPassword,
createUser,
updateUserPassword,
} from '../models/User.js';
import { getDataService } from '../services/services.js';
import { DataService } from '../services/dataService.js';
const dataService: DataService = getDataService();
// Default secret key - in production, use an environment variable
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this';
@@ -9,10 +18,17 @@ const TOKEN_EXPIRY = '24h';
// Login user
export const login = async (req: Request, res: Response): Promise<void> => {
// Get translation function from request
const t = (req as any).t;
// Validate request
const errors = validationResult(req);
if (!errors.isEmpty()) {
res.status(400).json({ success: false, errors: errors.array() });
res.status(400).json({
success: false,
message: t('api.errors.validation_failed'),
errors: errors.array(),
});
return;
}
@@ -21,17 +37,23 @@ export const login = async (req: Request, res: Response): Promise<void> => {
try {
// Find user by username
const user = findUserByUsername(username);
if (!user) {
res.status(401).json({ success: false, message: 'Invalid credentials' });
res.status(401).json({
success: false,
message: t('api.errors.invalid_credentials'),
});
return;
}
// Verify password
const isPasswordValid = await verifyPassword(password, user.password);
if (!isPasswordValid) {
res.status(401).json({ success: false, message: 'Invalid credentials' });
res.status(401).json({
success: false,
message: t('api.errors.invalid_credentials'),
});
return;
}
@@ -39,38 +61,45 @@ export const login = async (req: Request, res: Response): Promise<void> => {
const payload = {
user: {
username: user.username,
isAdmin: user.isAdmin || false
}
isAdmin: user.isAdmin || false,
},
};
jwt.sign(
payload,
JWT_SECRET,
{ expiresIn: TOKEN_EXPIRY },
(err, token) => {
if (err) throw err;
res.json({
success: true,
token,
user: {
username: user.username,
isAdmin: user.isAdmin
}
});
}
);
jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY }, (err, token) => {
if (err) throw err;
res.json({
success: true,
message: t('api.success.login_successful'),
token,
user: {
username: user.username,
isAdmin: user.isAdmin,
permissions: dataService.getPermissions(user),
},
});
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ success: false, message: 'Server error' });
res.status(500).json({
success: false,
message: t('api.errors.server_error'),
});
}
};
// Register new user
export const register = async (req: Request, res: Response): Promise<void> => {
// Get translation function from request
const t = (req as any).t;
// Validate request
const errors = validationResult(req);
if (!errors.isEmpty()) {
res.status(400).json({ success: false, errors: errors.array() });
res.status(400).json({
success: false,
message: t('api.errors.validation_failed'),
errors: errors.array(),
});
return;
}
@@ -79,7 +108,7 @@ export const register = async (req: Request, res: Response): Promise<void> => {
try {
// Create new user
const newUser = await createUser({ username, password, isAdmin });
if (!newUser) {
res.status(400).json({ success: false, message: 'User already exists' });
return;
@@ -89,26 +118,22 @@ export const register = async (req: Request, res: Response): Promise<void> => {
const payload = {
user: {
username: newUser.username,
isAdmin: newUser.isAdmin || false
}
isAdmin: newUser.isAdmin || false,
},
};
jwt.sign(
payload,
JWT_SECRET,
{ expiresIn: TOKEN_EXPIRY },
(err, token) => {
if (err) throw err;
res.json({
success: true,
token,
user: {
username: newUser.username,
isAdmin: newUser.isAdmin
}
});
}
);
jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY }, (err, token) => {
if (err) throw err;
res.json({
success: true,
token,
user: {
username: newUser.username,
isAdmin: newUser.isAdmin,
permissions: dataService.getPermissions(newUser),
},
});
});
} catch (error) {
console.error('Registration error:', error);
res.status(500).json({ success: false, message: 'Server error' });
@@ -120,13 +145,14 @@ export const getCurrentUser = (req: Request, res: Response): void => {
try {
// User is already attached to request by auth middleware
const user = (req as any).user;
res.json({
success: true,
res.json({
success: true,
user: {
username: user.username,
isAdmin: user.isAdmin
}
isAdmin: user.isAdmin,
permissions: dataService.getPermissions(user),
},
});
} catch (error) {
console.error('Get current user error:', error);
@@ -149,7 +175,7 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
try {
// Find user by username
const user = findUserByUsername(username);
if (!user) {
res.status(404).json({ success: false, message: 'User not found' });
return;
@@ -157,7 +183,7 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
// Verify current password
const isPasswordValid = await verifyPassword(currentPassword, user.password);
if (!isPasswordValid) {
res.status(401).json({ success: false, message: 'Current password is incorrect' });
return;
@@ -165,7 +191,7 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
// Update the password
const updated = await updateUserPassword(username, newPassword);
if (!updated) {
res.status(500).json({ success: false, message: 'Failed to update password' });
return;
@@ -176,4 +202,4 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
console.error('Change password error:', error);
res.status(500).json({ success: false, message: 'Server error' });
}
};
};

View File

@@ -1,5 +1,11 @@
import { Request, Response } from 'express';
import config from '../config/index.js';
import { loadSettings } from '../config/index.js';
import { getDataService } from '../services/services.js';
import { DataService } from '../services/dataService.js';
import { IUser } from '../types/index.js';
const dataService: DataService = getDataService();
/**
* Get runtime configuration for frontend
@@ -28,3 +34,41 @@ 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;
let permissions = {};
if (skipAuth) {
const user: IUser = {
username: 'guest',
password: '',
isAdmin: true,
};
permissions = dataService.getPermissions(user);
}
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,
permissions,
},
});
} catch (error) {
console.error('Error getting public config:', error);
res.status(500).json({
success: false,
message: 'Failed to get public configuration',
});
}
};

View File

@@ -0,0 +1,154 @@
import { Request, Response } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import AdmZip from 'adm-zip';
import { ApiResponse } from '../types/index.js';
// Configure multer for file uploads
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const uploadDir = path.join(process.cwd(), '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: 500 * 1024 * 1024, // 500MB 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(process.cwd(), '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);
if (!fs.existsSync(finalExtractDir)) {
fs.mkdirSync(finalExtractDir, { recursive: true });
}
// 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,
});
}
};

View File

@@ -9,7 +9,9 @@ import {
deleteGroup,
addServerToGroup,
removeServerFromGroup,
getServersInGroup
getServerConfigInGroup,
getServerConfigsInGroup,
updateServerToolsInGroup,
} from '../services/groupService.js';
// Get all groups
@@ -76,7 +78,12 @@ export const createNewGroup = (req: Request, res: Response): void => {
}
const serverList = Array.isArray(servers) ? servers : [];
const newGroup = createGroup(name, description, serverList);
// Set owner property - use current user's username, default to 'admin'
const currentUser = (req as any).user;
const owner = currentUser?.username || 'admin';
const newGroup = createGroup(name, description, serverList, owner);
if (!newGroup) {
res.status(400).json({
success: false,
@@ -149,12 +156,12 @@ export const updateExistingGroup = (req: Request, res: Response): void => {
}
};
// Update servers in a group (batch update)
// Update servers in a group (batch update) - supports both string[] and server config format
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,
@@ -166,11 +173,36 @@ export const updateGroupServersBatch = (req: Request, res: Response): void => {
if (!Array.isArray(servers)) {
res.status(400).json({
success: false,
message: 'Servers must be an array of server names',
message: 'Servers must be an array of server names or server configurations',
});
return;
}
// Validate server configurations if provided in new format
for (const server of servers) {
if (typeof server === 'object' && server !== null) {
if (!server.name || typeof server.name !== 'string') {
res.status(400).json({
success: false,
message: 'Each server configuration must have a valid name',
});
return;
}
if (
server.tools &&
server.tools !== 'all' &&
(!Array.isArray(server.tools) ||
!server.tools.every((tool: any) => typeof tool === 'string'))
) {
res.status(400).json({
success: false,
message: 'Tools must be "all" or an array of strings',
});
return;
}
}
}
const updatedGroup = updateGroupServers(id, servers);
if (!updatedGroup) {
res.status(404).json({
@@ -338,4 +370,113 @@ export const getGroupServers = (req: Request, res: Response): void => {
message: 'Failed to get group servers',
});
}
};
};
// Get server configurations in a group (including tool selections)
export const getGroupServerConfigs = (req: Request, res: Response): void => {
try {
const { id } = req.params;
if (!id) {
res.status(400).json({
success: false,
message: 'Group ID is required',
});
return;
}
const serverConfigs = getServerConfigsInGroup(id);
const response: ApiResponse = {
success: true,
data: serverConfigs,
};
res.json(response);
} catch (error) {
res.status(500).json({
success: false,
message: 'Failed to get group server configurations',
});
}
};
// Get specific server configuration in a group
export const getGroupServerConfig = (req: Request, res: Response): void => {
try {
const { id, serverName } = req.params;
if (!id || !serverName) {
res.status(400).json({
success: false,
message: 'Group ID and server name are required',
});
return;
}
const serverConfig = getServerConfigInGroup(id, serverName);
if (!serverConfig) {
res.status(404).json({
success: false,
message: 'Server not found in group',
});
return;
}
const response: ApiResponse = {
success: true,
data: serverConfig,
};
res.json(response);
} catch (error) {
res.status(500).json({
success: false,
message: 'Failed to get server configuration',
});
}
};
// Update tools for a specific server in a group
export const updateGroupServerTools = (req: Request, res: Response): void => {
try {
const { id, serverName } = req.params;
const { tools } = req.body;
if (!id || !serverName) {
res.status(400).json({
success: false,
message: 'Group ID and server name are required',
});
return;
}
// Validate tools parameter
if (
tools !== 'all' &&
(!Array.isArray(tools) || !tools.every((tool) => typeof tool === 'string'))
) {
res.status(400).json({
success: false,
message: 'Tools must be "all" or an array of strings',
});
return;
}
const updatedGroup = updateServerToolsInGroup(id, serverName, tools);
if (!updatedGroup) {
res.status(404).json({
success: false,
message: 'Group or server not found',
});
return;
}
const response: ApiResponse = {
success: true,
data: updatedGroup,
message: 'Server tools updated successfully',
};
res.json(response);
} catch (error) {
res.status(500).json({
success: false,
message: 'Internal server error',
});
}
};

View File

@@ -3,23 +3,26 @@ 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';
import { syncAllServerToolsEmbeddings } from '../services/vectorSearchService.js';
import { createSafeJSON } from '../utils/serialization.js';
export const getAllServers = (_: Request, res: Response): void => {
try {
const serversInfo = getServersInfo();
const response: ApiResponse = {
success: true,
data: serversInfo,
data: createSafeJSON(serversInfo),
};
res.json(response);
} catch (error) {
console.error('Failed to get servers information:', error);
res.status(500).json({
success: false,
message: 'Failed to get servers information',
@@ -32,7 +35,7 @@ export const getAllSettings = (_: Request, res: Response): void => {
const settings = loadSettings();
const response: ApiResponse = {
success: true,
data: settings,
data: createSafeJSON(settings),
};
res.json(response);
} catch (error) {
@@ -62,19 +65,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 +97,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 +115,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 +124,17 @@ 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
}
// Set owner property - use current user's username, default to 'admin'
if (!config.owner) {
const currentUser = (req as any).user;
config.owner = currentUser?.username || 'admin';
}
const result = await addServer(name, config);
if (result.success) {
notifyToolChanged();
@@ -179,19 +208,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 +240,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 +258,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 +267,18 @@ 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
}
// Set owner property if not provided - use current user's username, default to 'admin'
if (!config.owner) {
const currentUser = (req as any).user;
config.owner = currentUser?.username || 'admin';
}
const result = await addOrUpdateServer(name, config, true); // Allow override for updates
if (result.success) {
notifyToolChanged();
res.json({
@@ -318,18 +373,152 @@ 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;
const currentUser = (req as any).user;
if (
(!routing ||
(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')) &&
(typeof install.pythonIndexUrl !== 'string' &&
typeof install.npmRegistry !== 'string' &&
typeof install.baseUrl !== 'string')) &&
(!smartRouting ||
(typeof smartRouting.enabled !== 'boolean' &&
typeof smartRouting.dbUrl !== 'string' &&
@@ -352,10 +541,12 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
enableGroupNameRoute: true,
enableBearerAuth: false,
bearerAuthKey: '',
skipAuth: false,
},
install: {
pythonIndexUrl: '',
npmRegistry: '',
baseUrl: 'http://localhost:3000',
},
smartRouting: {
enabled: false,
@@ -373,6 +564,7 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
enableGroupNameRoute: true,
enableBearerAuth: false,
bearerAuthKey: '',
skipAuth: false,
};
}
@@ -380,6 +572,7 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
settings.systemConfig.install = {
pythonIndexUrl: '',
npmRegistry: '',
baseUrl: 'http://localhost:3000',
};
}
@@ -409,6 +602,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) {
@@ -418,6 +615,9 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
if (typeof install.npmRegistry === 'string') {
settings.systemConfig.install.npmRegistry = install.npmRegistry;
}
if (typeof install.baseUrl === 'string') {
settings.systemConfig.install.baseUrl = install.baseUrl;
}
}
// Track smartRouting state and configuration changes
@@ -476,7 +676,7 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
needsSync = (!wasSmartRoutingEnabled && isNowEnabled) || (isNowEnabled && hasConfigChanged);
}
if (saveSettings(settings)) {
if (saveSettings(settings, currentUser)) {
res.json({
success: true,
data: settings.systemConfig,

View File

@@ -0,0 +1,269 @@
import { Request, Response } from 'express';
import { ApiResponse } from '../types/index.js';
import {
getAllUsers,
getUserByUsername,
createNewUser,
updateUser,
deleteUser,
getUserCount,
getAdminCount,
} from '../services/userService.js';
import { loadSettings } from '../config/index.js';
// Admin permission check middleware function
const requireAdmin = (req: Request, res: Response): boolean => {
const settings = loadSettings();
if (settings.systemConfig?.routing?.skipAuth) {
return true;
}
const user = (req as any).user;
if (!user || !user.isAdmin) {
res.status(403).json({
success: false,
message: 'Admin privileges required',
});
return false;
}
return true;
};
// Get all users (admin only)
export const getUsers = (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
try {
const users = getAllUsers().map(({ password: _, ...user }) => user); // Remove password from response
const response: ApiResponse = {
success: true,
data: users,
};
res.json(response);
} catch (error) {
res.status(500).json({
success: false,
message: 'Failed to get users information',
});
}
};
// Get a specific user by username (admin only)
export const getUser = (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
try {
const { username } = req.params;
if (!username) {
res.status(400).json({
success: false,
message: 'Username is required',
});
return;
}
const user = getUserByUsername(username);
if (!user) {
res.status(404).json({
success: false,
message: 'User not found',
});
return;
}
const { password: _, ...userData } = user; // Remove password from response
const response: ApiResponse = {
success: true,
data: userData,
};
res.json(response);
} catch (error) {
res.status(500).json({
success: false,
message: 'Failed to get user information',
});
}
};
// Create a new user (admin only)
export const createUser = async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
try {
const { username, password, isAdmin } = req.body;
if (!username || !password) {
res.status(400).json({
success: false,
message: 'Username and password are required',
});
return;
}
const newUser = await createNewUser(username, password, isAdmin || false);
if (!newUser) {
res.status(400).json({
success: false,
message: 'Failed to create user or username already exists',
});
return;
}
const { password: _, ...userData } = newUser; // Remove password from response
const response: ApiResponse = {
success: true,
data: userData,
message: 'User created successfully',
};
res.status(201).json(response);
} catch (error) {
res.status(500).json({
success: false,
message: 'Internal server error',
});
}
};
// Update an existing user (admin only)
export const updateExistingUser = async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
try {
const { username } = req.params;
const { isAdmin, newPassword } = req.body;
if (!username) {
res.status(400).json({
success: false,
message: 'Username is required',
});
return;
}
// Check if trying to change admin status
if (isAdmin !== undefined) {
const currentUser = getUserByUsername(username);
if (!currentUser) {
res.status(404).json({
success: false,
message: 'User not found',
});
return;
}
// Prevent removing admin status from the last admin
if (currentUser.isAdmin && !isAdmin && getAdminCount() === 1) {
res.status(400).json({
success: false,
message: 'Cannot remove admin status from the last admin user',
});
return;
}
}
const updateData: any = {};
if (isAdmin !== undefined) updateData.isAdmin = isAdmin;
if (newPassword) updateData.newPassword = newPassword;
if (Object.keys(updateData).length === 0) {
res.status(400).json({
success: false,
message: 'At least one field (isAdmin or newPassword) is required to update',
});
return;
}
const updatedUser = await updateUser(username, updateData);
if (!updatedUser) {
res.status(404).json({
success: false,
message: 'User not found or update failed',
});
return;
}
const { password: _, ...userData } = updatedUser; // Remove password from response
const response: ApiResponse = {
success: true,
data: userData,
message: 'User updated successfully',
};
res.json(response);
} catch (error) {
res.status(500).json({
success: false,
message: 'Internal server error',
});
}
};
// Delete a user (admin only)
export const deleteExistingUser = (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
try {
const { username } = req.params;
if (!username) {
res.status(400).json({
success: false,
message: 'Username is required',
});
return;
}
// Check if trying to delete the current admin user
const currentUser = (req as any).user;
if (currentUser.username === username) {
res.status(400).json({
success: false,
message: 'Cannot delete your own account',
});
return;
}
const success = deleteUser(username);
if (!success) {
res.status(400).json({
success: false,
message: 'User not found, failed to delete, or cannot delete the last admin',
});
return;
}
res.json({
success: true,
message: 'User deleted successfully',
});
} catch (error) {
res.status(500).json({
success: false,
message: 'Internal server error',
});
}
};
// Get user statistics (admin only)
export const getUserStats = (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
try {
const totalUsers = getUserCount();
const adminUsers = getAdminCount();
const regularUsers = totalUsers - adminUsers;
const response: ApiResponse = {
success: true,
data: {
totalUsers,
adminUsers,
regularUsers,
},
};
res.json(response);
} catch (error) {
res.status(500).json({
success: false,
message: 'Failed to get user statistics',
});
}
};

View File

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

View File

@@ -1,11 +1,68 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { loadSettings } from '../config/index.js';
import defaultConfig 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;
};
const readonlyAllowPaths = ['/tools/call/'];
const checkReadonly = (req: Request): boolean => {
if (!defaultConfig.readonly) {
return true;
}
for (const path of readonlyAllowPaths) {
if (req.path.startsWith(defaultConfig.basePath + path)) {
return true;
}
}
return req.method === 'GET';
};
// Middleware to authenticate JWT token
export const auth = (req: Request, res: Response, next: NextFunction): void => {
const t = (req as any).t;
if (!checkReadonly(req)) {
res.status(403).json({ success: false, message: t('api.errors.readonly') });
return;
}
// 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 +77,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' });
}
};
};

41
src/middlewares/i18n.ts Normal file
View File

@@ -0,0 +1,41 @@
import { Request, Response, NextFunction } from 'express';
import { getT } from '../utils/i18n.js';
/**
* i18n middleware to detect user language and attach translation function to request
*/
export const i18nMiddleware = (req: Request, res: Response, next: NextFunction) => {
// Detect language from various sources (prioritized)
const acceptLanguage = req.headers['accept-language'];
const customLanguageHeader = req.headers['x-language'] as string;
const languageFromQuery = req.query.lang as string;
// Default to English
let detectedLanguage = 'en';
// Priority order: query parameter > custom header > accept-language header
if (languageFromQuery) {
detectedLanguage = languageFromQuery;
} else if (customLanguageHeader) {
detectedLanguage = customLanguageHeader;
} else if (acceptLanguage) {
// Parse accept-language header and get primary language
const primaryLanguage = acceptLanguage.split(',')[0].split('-')[0].trim();
detectedLanguage = primaryLanguage;
}
// Normalize language code (ensure we support it)
const supportedLanguages = ['en', 'zh'];
if (!supportedLanguages.includes(detectedLanguage)) {
detectedLanguage = 'en'; // fallback to English
}
// Set language in request (using any type to avoid TypeScript issues)
(req as any).language = detectedLanguage;
// Get translation function for the detected language
const t = getT(detectedLanguage);
(req as any).t = t;
next();
};

View File

@@ -1,37 +1,10 @@
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 { userContextMiddleware } from './userContext.js';
import { i18nMiddleware } from './i18n.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,
@@ -46,16 +19,26 @@ export const errorHandler = (
};
export const initMiddlewares = (app: express.Application): void => {
// Apply i18n middleware first to detect language for all requests
app.use(i18nMiddleware);
// Serve static files from the dynamically determined frontend path
// Note: Static files will be handled by the server directly, not here
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/`) &&
req.path !== `${basePath}/messages`
req.path !== `${basePath}/messages` &&
!req.path.match(
new RegExp(`^${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/[^/]+/messages$`),
) &&
!req.path.match(
new RegExp(`^${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/[^/]+/sse(/.*)?$`),
)
) {
express.json()(req, res, next);
} else {
@@ -74,7 +57,15 @@ export const initMiddlewares = (app: express.Application): void => {
if (req.path === '/auth/login' || req.path === '/auth/register') {
next();
} else {
auth(req, res, next);
// Apply authentication middleware first
auth(req, res, (err) => {
if (err) {
next(err);
} else {
// Apply user context middleware after successful authentication
userContextMiddleware(req, res, next);
}
});
}
});

View File

@@ -0,0 +1,136 @@
import { Request, Response, NextFunction } from 'express';
import { UserContextService } from '../services/userContextService.js';
import { IUser } from '../types/index.js';
/**
* User context middleware
* Sets user context after authentication middleware, allowing service layer to access current user information
*/
export const userContextMiddleware = async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
const currentUser = (req as any).user as IUser;
if (currentUser) {
// Set user context
const userContextService = UserContextService.getInstance();
userContextService.setCurrentUser(currentUser);
// Clean up user context when response ends
res.on('finish', () => {
const userContextService = UserContextService.getInstance();
userContextService.clearCurrentUser();
});
}
next();
} catch (error) {
console.error('Error in user context middleware:', error);
next(error);
}
};
/**
* User context middleware for SSE/MCP endpoints
* Extracts user from URL path parameter and sets user context
*/
export const sseUserContextMiddleware = async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
const userContextService = UserContextService.getInstance();
const username = req.params.user;
if (username) {
// For user-scoped routes, set the user context
// Note: In a real implementation, you should validate the user exists
// and has proper permissions
const user: IUser = {
username,
password: '',
isAdmin: false, // TODO: Should be retrieved from user database
};
userContextService.setCurrentUser(user);
// Clean up user context when response ends
res.on('finish', () => {
userContextService.clearCurrentUser();
});
// Also clean up on connection close for SSE
res.on('close', () => {
userContextService.clearCurrentUser();
});
console.log(`User context set for SSE/MCP endpoint: ${username}`);
} else {
// For global routes, clear user context (admin access)
userContextService.clearCurrentUser();
console.log('Global SSE/MCP endpoint access - no user context');
}
next();
} catch (error) {
console.error('Error in SSE user context middleware:', error);
next(error);
}
};
/**
* Extended data service that can directly access current user context
*/
export interface ContextAwareDataService {
getCurrentUserFromContext(): Promise<IUser | null>;
getUserDataFromContext(dataType: string): Promise<any>;
isCurrentUserAdmin(): Promise<boolean>;
}
export class ContextAwareDataServiceImpl implements ContextAwareDataService {
private getUserContextService() {
return UserContextService.getInstance();
}
async getCurrentUserFromContext(): Promise<IUser | null> {
const userContextService = this.getUserContextService();
return userContextService.getCurrentUser();
}
async getUserDataFromContext(dataType: string): Promise<any> {
const userContextService = this.getUserContextService();
const user = userContextService.getCurrentUser();
if (!user) {
throw new Error('No user in context');
}
console.log(`Getting ${dataType} data for user: ${user.username}`);
// Return different data based on user permissions
if (user.isAdmin) {
return {
type: dataType,
data: 'Admin level data from context',
user: user.username,
access: 'full',
};
} else {
return {
type: dataType,
data: 'User level data from context',
user: user.username,
access: 'limited',
};
}
}
async isCurrentUserAdmin(): Promise<boolean> {
const userContextService = this.getUserContextService();
return userContextService.isAdmin();
}
}

View File

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

View File

@@ -8,6 +8,8 @@ import {
updateServer,
deleteServer,
toggleServer,
toggleTool,
updateToolDescription,
updateSystemConfig,
} from '../controllers/serverController.js';
import {
@@ -20,7 +22,18 @@ import {
removeServerFromExistingGroup,
getGroupServers,
updateGroupServersBatch,
getGroupServerConfigs,
getGroupServerConfig,
updateGroupServerTools,
} from '../controllers/groupController.js';
import {
getUsers,
getUser,
createUser,
updateExistingUser,
deleteExistingUser,
getUserStats,
} from '../controllers/userController.js';
import {
getAllMarketServers,
getMarketServer,
@@ -32,8 +45,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 +60,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
@@ -59,10 +75,25 @@ export const initRoutes = (app: express.Application): void => {
router.get('/groups/:id/servers', getGroupServers);
// New route for batch updating servers in a group
router.put('/groups/:id/servers/batch', updateGroupServersBatch);
// New routes for server configurations and tool management in groups
router.get('/groups/:id/server-configs', getGroupServerConfigs);
router.get('/groups/:id/server-configs/:serverName', getGroupServerConfig);
router.put('/groups/:id/server-configs/:serverName/tools', updateGroupServerTools);
// User management routes (admin only)
router.get('/users', getUsers);
router.get('/users/:username', getUser);
router.post('/users', createUser);
router.put('/users/:username', updateExistingUser);
router.delete('/users/:username', deleteExistingUser);
router.get('/users-stats', getUserStats);
// 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 +143,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);
};

View File

@@ -1,11 +1,11 @@
import express from 'express';
import config from './config/index.js';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
import { initUpstreamServers } from './services/mcpService.js';
import { initUpstreamServers, connected } from './services/mcpService.js';
import { initMiddlewares } from './middlewares/index.js';
import { initRoutes } from './routes/index.js';
import { initI18n } from './utils/i18n.js';
import {
handleSseConnection,
handleSseMessage,
@@ -13,10 +13,10 @@ import {
handleMcpOtherRequest,
} from './services/sseService.js';
import { initializeDefaultUser } from './models/User.js';
import { sseUserContextMiddleware } from './middlewares/userContext.js';
// Get the directory name in ESM
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Get the current working directory (will be project root in most cases)
const currentFileDir = process.cwd() + '/src';
export class AppServer {
private app: express.Application;
@@ -32,6 +32,10 @@ export class AppServer {
async initialize(): Promise<void> {
try {
// Initialize i18n before other components
await initI18n();
console.log('i18n initialized successfully');
// Initialize default admin user if no users exist
await initializeDefaultUser();
@@ -42,11 +46,52 @@ export class AppServer {
initUpstreamServers()
.then(() => {
console.log('MCP server initialized successfully');
this.app.get(`${this.basePath}/sse/:group?`, (req, res) => handleSseConnection(req, res));
this.app.post(`${this.basePath}/messages`, handleSseMessage);
this.app.post(`${this.basePath}/mcp/:group?`, handleMcpPostRequest);
this.app.get(`${this.basePath}/mcp/:group?`, handleMcpOtherRequest);
this.app.delete(`${this.basePath}/mcp/:group?`, handleMcpOtherRequest);
// Original routes (global and group-based)
this.app.get(`${this.basePath}/sse/:group?`, sseUserContextMiddleware, (req, res) =>
handleSseConnection(req, res),
);
this.app.post(`${this.basePath}/messages`, sseUserContextMiddleware, handleSseMessage);
this.app.post(
`${this.basePath}/mcp/:group?`,
sseUserContextMiddleware,
handleMcpPostRequest,
);
this.app.get(
`${this.basePath}/mcp/:group?`,
sseUserContextMiddleware,
handleMcpOtherRequest,
);
this.app.delete(
`${this.basePath}/mcp/:group?`,
sseUserContextMiddleware,
handleMcpOtherRequest,
);
// User-scoped routes with user context middleware
this.app.get(`${this.basePath}/:user/sse/:group?`, sseUserContextMiddleware, (req, res) =>
handleSseConnection(req, res),
);
this.app.post(
`${this.basePath}/:user/messages`,
sseUserContextMiddleware,
handleSseMessage,
);
this.app.post(
`${this.basePath}/:user/mcp/:group?`,
sseUserContextMiddleware,
handleMcpPostRequest,
);
this.app.get(
`${this.basePath}/:user/mcp/:group?`,
sseUserContextMiddleware,
handleMcpOtherRequest,
);
this.app.delete(
`${this.basePath}/:user/mcp/:group?`,
sseUserContextMiddleware,
handleMcpOtherRequest,
);
})
.catch((error) => {
console.error('Error initializing MCP server:', error);
@@ -108,6 +153,10 @@ export class AppServer {
});
}
connected(): boolean {
return connected();
}
getApp(): express.Application {
return this.app;
}
@@ -119,7 +168,7 @@ export class AppServer {
if (debug) {
console.log('DEBUG: Current directory:', process.cwd());
console.log('DEBUG: Script directory:', __dirname);
console.log('DEBUG: Script directory:', currentFileDir);
}
// First, find the package root directory
@@ -159,13 +208,13 @@ export class AppServer {
// Possible locations for package.json
const possibleRoots = [
// Standard npm package location
path.resolve(__dirname, '..', '..'),
path.resolve(currentFileDir, '..', '..'),
// Current working directory
process.cwd(),
// When running from dist directory
path.resolve(__dirname, '..'),
path.resolve(currentFileDir, '..'),
// When installed via npx
path.resolve(__dirname, '..', '..', '..'),
path.resolve(currentFileDir, '..', '..', '..'),
];
// Special handling for npx

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