Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26fa61fcfc | ||
|
|
d689541fc4 | ||
|
|
30895c4b9a | ||
|
|
37c3fd9e06 | ||
|
|
59454ca250 | ||
|
|
63efa0038c | ||
|
|
040782da8d | ||
|
|
f1a5f692cc | ||
|
|
5d798cfe6a | ||
|
|
0490d98c9e | ||
|
|
7af3c8a2ba | ||
|
|
7c43ca359e | ||
|
|
2bb6302cbc | ||
|
|
3a3f6c984c | ||
|
|
e8bc053788 | ||
|
|
bb674236c7 | ||
|
|
0f5dfbe419 | ||
|
|
74d1ca6a87 | ||
|
|
eab421c753 | ||
|
|
886ca44681 | ||
|
|
3d4baeef1a | ||
|
|
4379513a35 | ||
|
|
9a06bae225 | ||
|
|
e5aaae466f | ||
|
|
9b1338a356 | ||
|
|
10d4616601 | ||
|
|
9ca242a0e4 | ||
|
|
0a6259decf | ||
|
|
7887a3a5f9 | ||
|
|
7f33615161 | ||
|
|
c9ec3b77ce | ||
|
|
142c3f628a | ||
|
|
bbb99b6f17 | ||
|
|
c1eabb5607 | ||
|
|
afd1ee7a50 | ||
|
|
6bf22025e1 | ||
|
|
2c00f1d606 | ||
|
|
3d49c652ad | ||
|
|
c4008f617d | ||
|
|
6a5039a90e | ||
|
|
39222bdcd8 | ||
|
|
436318b24c | ||
|
|
3e5a64d533 | ||
|
|
6988618c41 | ||
|
|
964ab4a5d7 | ||
|
|
b59243e410 | ||
|
|
85c461bbfa | ||
|
|
f477e1f942 | ||
|
|
7feb5b2bcb | ||
|
|
66d592da1c | ||
|
|
adef02d3b9 | ||
|
|
6a065ca2f2 |
15
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: samanhappy
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
18
.github/workflows/build.yml
vendored
@@ -3,8 +3,6 @@ name: Build
|
||||
on:
|
||||
push:
|
||||
tags: ['v*.*.*']
|
||||
schedule:
|
||||
- cron: '0 23 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -18,6 +16,16 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Update version from tag
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
echo "Updating package.json version to $VERSION"
|
||||
jq ".version = \"$VERSION\"" package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
echo "Updated version in package.json:"
|
||||
grep -m 1 "version" package.json
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -36,7 +44,9 @@ jobs:
|
||||
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/') }}
|
||||
type=raw,value=latest${{ matrix.variant == 'full' && '-full' || '' }},enable=${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
@@ -45,7 +55,7 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.variant }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
build-args: |
|
||||
INSTALL_EXT=${{ matrix.variant == 'full' && 'true' || 'false' }}
|
||||
|
||||
58
.github/workflows/npm-publish.yml
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
name: Publish to NPM
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*.*.*']
|
||||
|
||||
jobs:
|
||||
publish-npm:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Update version from tag
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
echo "Updating package.json version to $VERSION"
|
||||
jq ".version = \"$VERSION\"" package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
echo "Updated version in package.json:"
|
||||
grep -m 1 "version" package.json
|
||||
|
||||
- name: Build package
|
||||
run: pnpm build
|
||||
|
||||
- name: Publish to NPM
|
||||
run: pnpm publish --no-git-checks --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
31
.github/workflows/release.yml
vendored
@@ -13,5 +13,36 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Update package.json version
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Extract version from tag (remove 'v' prefix)
|
||||
TAG_VERSION=${GITHUB_REF#refs/tags/v}
|
||||
echo "Updating package.json version to $TAG_VERSION"
|
||||
|
||||
# Update package.json version field
|
||||
sed -i "s/\"version\": \".*\"/\"version\": \"$TAG_VERSION\"/" package.json
|
||||
|
||||
# Commit the change
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add package.json
|
||||
git commit -m "chore: update version to $TAG_VERSION [skip ci]"
|
||||
|
||||
# Push using GITHUB_TOKEN for authentication
|
||||
git remote set-url origin https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git
|
||||
git push origin HEAD:${GITHUB_REF#refs/tags/}
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
|
||||
12
Dockerfile
@@ -2,7 +2,13 @@ FROM python:3.13-slim-bookworm AS base
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
|
||||
RUN apt-get update && apt-get install -y curl gnupg \
|
||||
# 添加 HTTP_PROXY 和 HTTPS_PROXY 环境变量
|
||||
ARG HTTP_PROXY=""
|
||||
ARG HTTPS_PROXY=""
|
||||
ENV HTTP_PROXY=$HTTP_PROXY
|
||||
ENV HTTPS_PROXY=$HTTPS_PROXY
|
||||
|
||||
RUN apt-get update && apt-get install -y curl gnupg git \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
@@ -28,7 +34,6 @@ RUN if [ "$INSTALL_EXT" = "true" ]; then \
|
||||
fi
|
||||
|
||||
RUN uv tool install mcp-server-fetch
|
||||
ENV UV_PYTHON_INSTALL_MIRROR="http://mirrors.aliyun.com/pypi/simple/"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -37,6 +42,9 @@ RUN pnpm install
|
||||
|
||||
COPY . .
|
||||
|
||||
# Download the latest servers.json from mcpm.sh and replace the existing file
|
||||
RUN curl -s -f --connect-timeout 10 https://mcpm.sh/api/servers.json -o servers.json || echo "Failed to download servers.json, using bundled version"
|
||||
|
||||
RUN pnpm frontend:build && pnpm build
|
||||
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
|
||||
180
README.md
@@ -1,56 +1,48 @@
|
||||
# MCPHub: Deploy Your Own MCP Servers in Minutes
|
||||
# MCPHub: Your Ultimate MCP Server Hub
|
||||
|
||||
English | [中文版](README.zh.md)
|
||||
|
||||
MCPHub is a unified hub server that consolidates multiple MCP (Model Context Protocol) servers into a single SSE endpoint. It streamlines service management by offering a centralized interface for all your MCP server needs.
|
||||
MCPHub is a unified management platform that aggregates multiple MCP (Model Context Protocol) servers into separate Streamable HTTP (SSE) endpoints for different scenarios by group. It streamlines your AI tool integrations through an intuitive interface and robust protocol handling.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
## 🚀 Features
|
||||
|
||||
- **Built-in featured MCP Servers**: Comes with featured MCP servers like `amap-maps`, `playwright`, `slack`, and more.
|
||||
- **Centralized Management**: Oversee multiple MCP servers from one convenient hub.
|
||||
- **Broad Protocol Support**: Works seamlessly with both stdio and SSE MCP protocols.
|
||||
- **Intuitive Dashboard UI**: Monitor server status and manage servers dynamically via a web interface.
|
||||
- **Flexible Server Management**: Add, remove, or reconfigure MCP servers without restarting the hub.
|
||||
- **Out-of-the-Box MCP Server Support**: Seamlessly integrate popular servers like `amap-maps`, `playwright`, `fetch`, `slack`, and more.
|
||||
- **Centralized Dashboard**: Monitor real-time status and performance metrics from one sleek web UI.
|
||||
- **Flexible Protocol Handling**: Full compatibility with both stdio and SSE MCP protocols.
|
||||
- **Hot-Swappable Configuration**: Add, remove, or update MCP servers on the fly — no downtime required.
|
||||
- **Group-Based Access Control**: Organize servers into customizable groups for streamlined permissions management.
|
||||
- **Secure Authentication**: Built-in user management with role-based access powered by JWT and bcrypt.
|
||||
- **Docker-Ready**: Deploy instantly with our containerized setup.
|
||||
|
||||
## Quick Start
|
||||
## 🔧 Quick Start
|
||||
|
||||
### Configuration (Optional but Recommended)
|
||||
### Optional Configuration
|
||||
|
||||
Create a `mcp_settings.json` file to customize your server settings:
|
||||
|
||||
- Customize your MCP server settings by creating the `mcp_settings.json` file. For example:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"amap-maps": {
|
||||
"amap": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@amap/amap-maps-mcp-server"
|
||||
],
|
||||
"args": ["-y", "@amap/amap-maps-mcp-server"],
|
||||
"env": {
|
||||
"AMAP_MAPS_API_KEY": "your-api-key"
|
||||
}
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest",
|
||||
"--headless"
|
||||
]
|
||||
"args": ["@playwright/mcp@latest", "--headless"]
|
||||
},
|
||||
"fetch": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"mcp-server-fetch"
|
||||
]
|
||||
"args": ["mcp-server-fetch"]
|
||||
},
|
||||
"slack": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-slack"
|
||||
],
|
||||
"args": ["-y", "@modelcontextprotocol/server-slack"],
|
||||
"env": {
|
||||
"SLACK_BOT_TOKEN": "your-bot-token",
|
||||
"SLACK_TEAM_ID": "your-team-id"
|
||||
@@ -67,72 +59,132 @@ MCPHub is a unified hub server that consolidates multiple MCP (Model Context Pro
|
||||
}
|
||||
```
|
||||
|
||||
- The above example includes the `amap-maps`, `playwright`, `fetch`, and `slack` servers. You can add or remove servers as needed.
|
||||
- The `users` section allows you to set up user authentication. The default root user is `admin` with the password `admin123`. You can change them as needed.
|
||||
- The password is hashed using bcrypt. You can generate a new password hash using the following command:
|
||||
> **Note**: Default credentials are `admin` / `admin123`. Passwords are securely hashed with bcrypt. Generate a new hash with:
|
||||
>
|
||||
> ```bash
|
||||
> npx bcryptjs your-password
|
||||
> ```
|
||||
|
||||
```bash
|
||||
npx bcryptjs your-password
|
||||
```
|
||||
### Docker Deployment
|
||||
|
||||
### Starting MCPHub with Docker
|
||||
|
||||
Run the following command to quickly launch MCPHub with default settings:
|
||||
**Recommended**: Mount your custom config:
|
||||
```bash
|
||||
docker run -p 3000:3000 -v $(pwd)/mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
|
||||
```
|
||||
|
||||
or run with default settings:
|
||||
```bash
|
||||
docker run -p 3000:3000 samanhappy/mcphub
|
||||
```
|
||||
|
||||
Run the following command to launch MCPHub with custom settings:
|
||||
### Access the Dashboard
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 -v ./mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
|
||||
Open `http://localhost:3000` and log in with your credentials.
|
||||
> **Note**: Default credentials are `admin` / `admin123`.
|
||||
|
||||
**Dashboard Overview**:
|
||||
- Live status of all MCP servers
|
||||
- Enable/disable or reconfigure servers
|
||||
- Group management for organizing servers
|
||||
- User administration for access control
|
||||
|
||||
### Streamable HTTP Endpoint
|
||||
> As of now, support for streaming HTTP endpoints varies across different AI clients. If you encounter issues, you can use the SSE endpoint or wait for future updates.
|
||||
|
||||
Connect AI clients (e.g., Claude Desktop, Cursor, DeepChat, etc.) via:
|
||||
```
|
||||
http://localhost:3000/mcp
|
||||
```
|
||||
This endpoint provides a unified streamable HTTP interface for all your MCP servers. It allows you to:
|
||||
- Send requests to any configured MCP server
|
||||
- Receive responses in real-time
|
||||
- Easily integrate with various AI clients and tools
|
||||
- Use the same endpoint for all servers, simplifying your integration process
|
||||
|
||||
**Group-Specific Endpoints (Recommended)**:
|
||||
|
||||

|
||||
|
||||
For targeted access to specific server groups, use the group-based HTTP endpoint:
|
||||
```
|
||||
http://localhost:3000/mcp/{group}
|
||||
```
|
||||
|
||||
### Dashboard Access
|
||||
Where `{group}` is the ID or name of the group you created in the dashboard. This allows you to:
|
||||
- Connect to a specific subset of MCP servers organized by use case
|
||||
- Isolate different AI tools to access only relevant servers
|
||||
- Implement more granular access control for different environments or teams
|
||||
|
||||
Open your web browser and navigate to: `http://localhost:3000`, then login using the credentials you set in the `mcp_settings.json` file.
|
||||
The default credentials are:
|
||||
- **Username**: `admin`
|
||||
- **Password**: `admin123`
|
||||
**Server-Specific Endpoints**:
|
||||
For direct access to individual servers, use the server-specific HTTP endpoint:
|
||||
```
|
||||
http://localhost:3000/mcp/{server}
|
||||
```
|
||||
Where `{server}` is the name of the server you want to connect to. This allows you to access a specific MCP server directly.
|
||||
|
||||
The dashboard provides:
|
||||
- **Real-Time Monitoring**: Keep an eye on the status of all MCP servers.
|
||||
- **Service Status Indicators**: Quickly see which services are online.
|
||||
- **Dynamic Server Management**: Add or remove MCP servers on the fly without needing to restart.
|
||||
> **Note**: If the server name and group name are the same, the group name will take precedence.
|
||||
|
||||
### SSE Endpoint
|
||||
### SSE Endpoint (Deprecated in Future)
|
||||
|
||||
Seamlessly connect your host applications (e.g., Claude Desktop, Cursor, Cherry Studio, etc.) to the MCPHub SSE endpoint at: `http://localhost:3000/sse`
|
||||
Connect AI clients (e.g., Claude Desktop, Cursor, DeepChat, etc.) via:
|
||||
```
|
||||
http://localhost:3000/sse
|
||||
```
|
||||
|
||||
## Local Development
|
||||
For targeted access to specific server groups, use the group-based SSE endpoint:
|
||||
```
|
||||
http://localhost:3000/sse/{group}
|
||||
```
|
||||
|
||||
### Clone the Repository
|
||||
For direct access to individual servers, use the server-specific SSE endpoint:
|
||||
```
|
||||
http://localhost:3000/sse/{server}
|
||||
```
|
||||
|
||||
Clone MCPHub from GitHub:
|
||||
## 🧑💻 Local Development
|
||||
|
||||
```bash
|
||||
git clone https://github.com/samanhappy/mcphub.git
|
||||
cd mcphub
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Optional Configuration
|
||||
This starts both frontend and backend in development mode with hot-reloading.
|
||||
|
||||
Customize your MCP server settings by editing the `mcp_settings.json` file.
|
||||
> For windows users, you may need to start the backend server and frontend separately: `pnpm backend:dev`, `pnpm frontend:dev`.
|
||||
|
||||
### Start the Development Server
|
||||
## 🛠️ Common Issues
|
||||
|
||||
Install dependencies and launch MCPHub:
|
||||
### Using Nginx as a Reverse Proxy
|
||||
If you are using Nginx to reverse proxy MCPHub, please make sure to add the following configuration in your Nginx setup:
|
||||
|
||||
```bash
|
||||
cd mcphub && pnpm install && pnpm dev
|
||||
```nginx
|
||||
proxy_buffering off
|
||||
```
|
||||
|
||||
## Community and Contributions
|
||||
## 🔍 Tech Stack
|
||||
|
||||
MCPHub started as a small side project that I developed on a whim, and I'm amazed at the attention it has received. Thank you all for your support!
|
||||
- **Backend**: Node.js, Express, TypeScript
|
||||
- **Frontend**: React, Vite, Tailwind CSS
|
||||
- **Auth**: JWT & bcrypt
|
||||
- **Protocol**: Model Context Protocol SDK
|
||||
|
||||
Currently, MCPHub still has many areas that need optimization and improvement. Any contributions, whether in the form of code, documentation, or suggestions, are more than welcome.
|
||||
## 👥 Contributing
|
||||
|
||||
## License
|
||||
Contributions are welcome!
|
||||
|
||||
This project is licensed under the [Apache 2.0 license](LICENSE).
|
||||
- New features & optimizations
|
||||
- Documentation improvements
|
||||
- Bug reports & fixes
|
||||
- Translations & suggestions
|
||||
|
||||
## ❤️ Sponsor
|
||||
|
||||
If you like this project, maybe you can consider:
|
||||
|
||||
[](https://ko-fi.com/samanhappy)
|
||||
|
||||
## 📄 License
|
||||
|
||||
Licensed under the [Apache 2.0 License](LICENSE).
|
||||
|
||||
252
README.zh.md
@@ -1,137 +1,211 @@
|
||||
# MCPHub:一键部署你的专属 MCP 服务
|
||||
# MCPHub:一站式 MCP 服务器聚合平台
|
||||
|
||||
[English Version](README.md) | 中文版
|
||||
|
||||
MCPHub 是一个集中管理的 MCP 服务器聚合平台,可以将多个 MCP(Model Context Protocol)服务整合为一个 SSE 端点。它通过提供一个集中的管理界面来简化服务管理,满足您对 MCP 服务的所有需求。
|
||||
MCPHub 是一个统一的 MCP(Model Context Protocol,模型上下文协议)服务器聚合平台,可以根据场景将多个服务器聚合到不同的流式 HTTP(SSE)端点。它通过直观的界面和强大的协议处理能力,简化了您的 AI 工具集成流程。
|
||||
|
||||

|
||||
|
||||
## 功能
|
||||
## 🚀 功能亮点
|
||||
|
||||
- **内置精选 MCP 服务**:默认安装 `amap-maps`、`playwright`、`slack` 等热门服务,开箱即用。
|
||||
- **集中管理**:通过单一中心轻松管理多个 MCP 服务。
|
||||
- **协议兼容**:同时支持 stdio 与 SSE MCP 协议,确保无缝对接。
|
||||
- **直观控制面板**:通过 Web 界面实时监控服务状态,并动态管理服务。
|
||||
- **灵活配置**:无需重启中心服务即可添加、移除或重新配置 MCP 服务。
|
||||
- **开箱即用的 MCP 服务器支持**:无缝集成 `amap-maps`、`playwright`、`fetch`、`slack` 等常见服务器。
|
||||
- **集中式管理控制台**:在一个简洁的 Web UI 中实时监控所有服务器的状态和性能指标。
|
||||
- **灵活的协议兼容**:完全支持 stdio 和 SSE 两种 MCP 协议。
|
||||
- **热插拔式配置**:在运行时动态添加、移除或更新服务器配置,无需停机。
|
||||
- **基于分组的访问控制**:自定义分组并管理服务器访问权限。
|
||||
- **安全认证机制**:内置用户管理,基于 JWT 和 bcrypt,实现角色权限控制。
|
||||
- **Docker 就绪**:提供容器化镜像,快速部署。
|
||||
|
||||
## 快速开始
|
||||
## 🔧 快速开始
|
||||
|
||||
### 配置(可选但推荐)
|
||||
### 可选配置
|
||||
|
||||
- 你可以通过创建 `mcp_settings.json` 文件来自定义 MCP 服务器设置,例如:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"amap-maps": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@amap/amap-maps-mcp-server"
|
||||
],
|
||||
"env": {
|
||||
"AMAP_MAPS_API_KEY": "your-api-key"
|
||||
}
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest",
|
||||
"--headless"
|
||||
]
|
||||
},
|
||||
"fetch": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"mcp-server-fetch"
|
||||
]
|
||||
},
|
||||
"slack": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-slack"
|
||||
],
|
||||
"env": {
|
||||
"SLACK_BOT_TOKEN": "your-bot-token",
|
||||
"SLACK_TEAM_ID": "your-team-id"
|
||||
}
|
||||
通过创建 `mcp_settings.json` 自定义服务器设置:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"amap": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@amap/amap-maps-mcp-server"],
|
||||
"env": {
|
||||
"AMAP_MAPS_API_KEY": "your-api-key"
|
||||
}
|
||||
},
|
||||
"users": [
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "$2b$10$Vt7krIvjNgyN67LXqly0uOcTpN0LI55cYRbcKC71pUDAP0nJ7RPa.",
|
||||
"isAdmin": true
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless"]
|
||||
},
|
||||
"fetch": {
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-fetch"]
|
||||
},
|
||||
"slack": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-slack"],
|
||||
"env": {
|
||||
"SLACK_BOT_TOKEN": "your-bot-token",
|
||||
"SLACK_TEAM_ID": "your-team-id"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
}
|
||||
},
|
||||
"users": [
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "$2b$10$Vt7krIvjNgyN67LXqly0uOcTpN0LI55cYRbcKC71pUDAP0nJ7RPa.",
|
||||
"isAdmin": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- 上述示例中包含 `amap-maps`、`playwright`、`fetch` 和 `slack` 服务器,你可以根据需要增减服务器。
|
||||
- `users` 部分允许你设置用户认证。默认的 root 用户为 `admin`,密码为 `admin123`,你可以根据需要进行更改。
|
||||
- 密码使用 bcrypt 进行哈希处理。你可以使用以下命令生成新密码的哈希值:
|
||||
> **提示**:默认用户名/密码为 `admin` / `admin123`。密码已通过 bcrypt 安全哈希。生成新密码哈希:
|
||||
>
|
||||
> ```bash
|
||||
> npx bcryptjs your-password
|
||||
> ```
|
||||
|
||||
```bash
|
||||
npx bcryptjs your-password
|
||||
```
|
||||
### Docker 部署
|
||||
|
||||
### 启动
|
||||
**推荐**:挂载自定义配置:
|
||||
|
||||
运行以下命令即可使用默认配置快速启动 MCPHub:
|
||||
```bash
|
||||
docker run -p 3000:3000 -v $(pwd)/mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
|
||||
```
|
||||
|
||||
或使用默认配置运行:
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 samanhappy/mcphub
|
||||
```
|
||||
|
||||
运行以下命令可使用自定义配置启动 MCPHub:
|
||||
### 访问控制台
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 -v ./mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
|
||||
打开 `http://localhost:3000`,使用您的账号登录。
|
||||
|
||||
> **提示**:默认用户名/密码为 `admin` / `admin123`。
|
||||
|
||||
**控制台功能**:
|
||||
|
||||
- 实时监控所有 MCP 服务器状态
|
||||
- 启用/禁用或重新配置服务器
|
||||
- 分组管理,组织服务器访问
|
||||
- 用户管理,设定权限
|
||||
|
||||
### 支持流式的 HTTP 端点
|
||||
|
||||
> 截至目前,各家 AI 客户端对流式的 HTTP 端点支持不一,如果遇到问题,可以使用 SSE 端点或者等待更新。
|
||||
|
||||
通过以下地址连接 AI 客户端(如 Claude Desktop、Cursor、DeepChat 等):
|
||||
|
||||
```
|
||||
http://localhost:3000/mcp
|
||||
```
|
||||
|
||||
### 控制面板访问
|
||||
这个端点为所有 MCP 服务器提供统一的流式 HTTP 接口。它允许您:
|
||||
|
||||
在浏览器中打开 `http://localhost:3000` 并使用你在 `mcp_settings.json` 文件中设置的凭据登录。默认凭据为:
|
||||
- **用户名**:`admin`
|
||||
- **密码**:`admin123`
|
||||
- 向任何配置的 MCP 服务器发送请求
|
||||
- 实时接收响应
|
||||
- 轻松与各种 AI 客户端和工具集成
|
||||
- 对所有服务器使用相同的端点,简化集成过程
|
||||
|
||||
控制面板提供以下功能:
|
||||
- **实时监控**:随时查看所有 MCP 服务的运行状态。
|
||||
- **服务状态指示**:快速识别各服务是否在线。
|
||||
- **动态管理**:无需重启即可动态添加或移除 MCP 服务。
|
||||
**基于分组的 HTTP 端点(推荐)**:
|
||||

|
||||
要针对特定服务器分组进行访问,请使用基于分组的 HTTP 端点:
|
||||
|
||||
### SSE 端点
|
||||
```
|
||||
http://localhost:3000/mcp/{group}
|
||||
```
|
||||
|
||||
您可以将主机应用(如 Claude Desktop、Cursor、Cherry Studio 等)无缝连接至 MCPHub 的 SSE 端点: `http://localhost:3000/sse`
|
||||
其中 `{group}` 是您在控制面板中创建的分组 ID 或名称。这样做可以:
|
||||
|
||||
## 本地开发
|
||||
- 连接到按用例组织的特定 MCP 服务器子集
|
||||
- 隔离不同的 AI 工具,使其只能访问相关服务器
|
||||
- 为不同环境或团队实现更精细的访问控制
|
||||
- 通过分组名称轻松识别和管理服务器
|
||||
- 允许不同的 AI 客户端使用相同的端点,简化集成过程
|
||||
|
||||
### 克隆仓库
|
||||
**针对特定服务器的 HTTP 端点**:
|
||||
要针对特定服务器进行访问,请使用以下格式:
|
||||
|
||||
从 GitHub 克隆 MCPHub:
|
||||
```
|
||||
http://localhost:3000/mcp/{server}
|
||||
```
|
||||
|
||||
其中 `{server}` 是您要连接的服务器名称。这样做可以直接访问特定的 MCP 服务器。
|
||||
|
||||
> **提示**:如果服务器名称和分组名称相同,则分组名称优先。
|
||||
|
||||
### SSE 端点集成 (未来可能废弃)
|
||||
|
||||
通过以下地址连接 AI 客户端(如 Claude Desktop、Cursor、DeepChat 等):
|
||||
|
||||
```
|
||||
http://localhost:3000/sse
|
||||
```
|
||||
|
||||
要针对特定服务器分组进行访问,请使用基于分组的 SSE 端点:
|
||||
|
||||
```
|
||||
http://localhost:3000/sse/{group}
|
||||
```
|
||||
|
||||
要针对特定服务器进行访问,请使用以下格式:
|
||||
|
||||
```
|
||||
http://localhost:3000/sse/{server}
|
||||
```
|
||||
|
||||
## 🧑💻 本地开发
|
||||
|
||||
```bash
|
||||
git clone https://github.com/samanhappy/mcphub.git
|
||||
cd mcphub
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### 可选配置
|
||||
此命令将在开发模式下启动前后端,并启用热重载。
|
||||
|
||||
通过编辑 `mcp_settings.json` 文件来自定义 MCP 服务器设置。
|
||||
> 针对 Windows 用户,可能需要分别启动后端服务器和前端:`pnpm backend:dev`,`pnpm frontend:dev`。
|
||||
|
||||
### 启动开发服务器
|
||||
## 🛠️ 常见问题
|
||||
|
||||
进入项目目录,安装依赖并启动 MCPHub:
|
||||
### 使用 nginx 反向代理
|
||||
|
||||
```bash
|
||||
cd mcphub && pnpm install && pnpm dev
|
||||
如果您在使用 nginx 反向代理 MCPHub,请确保在 nginx 配置中添加以下内容:
|
||||
|
||||
```nginx
|
||||
proxy_buffering off
|
||||
```
|
||||
|
||||
## 社区与贡献
|
||||
## 🔍 技术栈
|
||||
|
||||
MCPHub 只是我一时兴起开发的小项目,没想到竟收获了这么多关注,非常感谢大家的支持!目前 MCPHub 还有不少地方需要优化和完善,我也专门建了个交流群,方便大家交流反馈。如果你也对这个项目感兴趣,欢迎一起参与建设!
|
||||
- **后端**:Node.js、Express、TypeScript
|
||||
- **前端**:React、Vite、Tailwind CSS
|
||||
- **认证**:JWT & bcrypt
|
||||
- **协议**:Model Context Protocol SDK
|
||||
|
||||

|
||||
## 👥 贡献指南
|
||||
|
||||
## 许可证
|
||||
期待您的贡献!
|
||||
|
||||
- 新功能与优化
|
||||
- 文档完善
|
||||
- Bug 报告与修复
|
||||
- 翻译与建议
|
||||
|
||||
欢迎加入企微交流共建群,由于群人数限制,有兴趣的同学可以扫码添加管理员为好友后拉入群聊。
|
||||
|
||||
<img src="assets/wexin.png" width="350">
|
||||
|
||||
如果觉得项目有帮助,不妨请我喝杯咖啡 ☕️
|
||||
|
||||
<img src="assets/reward.png" width="350">
|
||||
|
||||
## 致谢
|
||||
感谢以下人员的赞赏:小白、琛。你们的支持是我继续前进的动力!
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 [Apache 2.0 许可证](LICENSE)。
|
||||
|
||||
BIN
assets/cursor-mcp.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
assets/cursor-query.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
assets/cursor-tools.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
BIN
assets/group.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
assets/group.zh.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
assets/market.zh.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
assets/reward.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 40 KiB |
BIN
assets/wegroup.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
assets/wexin.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
96
bin/cli.js
Executable file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
|
||||
// Enable debug logging if needed
|
||||
// process.env.DEBUG = 'true';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Start with more debug information
|
||||
console.log('📋 MCPHub CLI');
|
||||
console.log(`📁 CLI script location: ${__dirname}`);
|
||||
|
||||
// The npm package directory structure when installed is:
|
||||
// node_modules/@samanhappy/mcphub/
|
||||
// - dist/
|
||||
// - bin/
|
||||
// - frontend/dist/
|
||||
|
||||
// Get the package root - this is where package.json is located
|
||||
function findPackageRoot() {
|
||||
const isDebug = process.env.DEBUG === 'true';
|
||||
|
||||
// Possible locations for package.json
|
||||
const possibleRoots = [
|
||||
// Standard npm package location
|
||||
path.resolve(__dirname, '..'),
|
||||
// When installed via npx
|
||||
path.resolve(__dirname, '..', '..', '..')
|
||||
];
|
||||
|
||||
// Special handling for npx
|
||||
if (process.argv[1] && process.argv[1].includes('_npx')) {
|
||||
const npxDir = path.dirname(process.argv[1]);
|
||||
possibleRoots.unshift(path.resolve(npxDir, '..'));
|
||||
}
|
||||
|
||||
if (isDebug) {
|
||||
console.log('DEBUG: Checking for package.json in:', possibleRoots);
|
||||
}
|
||||
|
||||
for (const root of possibleRoots) {
|
||||
const packageJsonPath = path.join(root, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
if (pkg.name === 'mcphub' || pkg.name === '@samanhappy/mcphub') {
|
||||
if (isDebug) {
|
||||
console.log(`DEBUG: Found package.json at ${packageJsonPath}`);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue to the next potential root
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('⚠️ Could not find package.json, using default path');
|
||||
return path.resolve(__dirname, '..');
|
||||
}
|
||||
|
||||
// Locate and check the frontend distribution
|
||||
function checkFrontend(packageRoot) {
|
||||
const isDebug = process.env.DEBUG === 'true';
|
||||
const frontendDistPath = path.join(packageRoot, 'frontend', 'dist');
|
||||
|
||||
if (isDebug) {
|
||||
console.log(`DEBUG: Checking frontend at: ${frontendDistPath}`);
|
||||
}
|
||||
|
||||
if (fs.existsSync(frontendDistPath) && fs.existsSync(path.join(frontendDistPath, 'index.html'))) {
|
||||
console.log('✅ Frontend distribution found');
|
||||
return true;
|
||||
} else {
|
||||
console.log('⚠️ Frontend distribution not found at', frontendDistPath);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const projectRoot = findPackageRoot();
|
||||
console.log(`📦 Using package root: ${projectRoot}`);
|
||||
|
||||
// Check if frontend exists
|
||||
checkFrontend(projectRoot);
|
||||
|
||||
// Start the server
|
||||
console.log('🚀 Starting MCPHub server...');
|
||||
import(path.join(projectRoot, 'dist', 'index.js')).catch(err => {
|
||||
console.error('Failed to start MCPHub:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
232
doc/intro2.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# 本地部署、一键安装、分组路由:MCPHub 重塑 MCP 服务器体验
|
||||
|
||||
## 概述
|
||||
|
||||
现代 AI 应用场景中,将大模型(LLM)与各种数据源和工具无缝对接,往往需要手动编写大量胶水代码,并且无法快速复用。MCP(Model Context Protocol)协议由 Anthropic 在 2024 年开源,旨在提供类似“USB‑C”接口般的标准化通信方式,简化 AI 助手与内容仓库、业务系统等的集成流程。然而,MCP 服务器部署常常需要大量环境依赖、手动配置及持续运行,开发者常因安装和配置耗费大量时间和精力。MCPHub 作为一款开源的一站式聚合平台,通过直观的 Web UI、Docker 镜像和热插拔配置,实现本地或容器里的“一键安装”与“分组路由”,大幅降低 MCP 服务器的使用门槛和运维成本。
|
||||
|
||||
## MCPHub 是什么
|
||||
|
||||
### MCP 协议简介
|
||||
|
||||
Model Context Protocol(MCP)是一种开放标准,类似“USB‑C”接口,为 AI 助手与内容仓库、业务系统和第三方服务之间提供统一通信协议。它支持 stdio 与 SSE(最新协议中被 Streamable HTTP 取代)两种通信方式,既能满足实时流式数据交换,也可用于批量任务。2024 年由 Anthropic 团队开源发布后,MCP 已在各类 AI 客户端(如 Claude Desktop)中得到应用,成功实现与 GitHub、Slack、网页自动化工具等的无缝对接。
|
||||
|
||||
### MCPHub 项目概览
|
||||
|
||||
MCPHub 是一个统一的 MCP 服务器聚合平台,内置 MCP 服务器市场实现一键安装。前端基于 React、Vite 和 Tailwind CSS 构建,后端兼容任意使用 npx 或 uvx 命令启动的 MCP 服务器。它通过一个集中式 Dashboard 实时展示各服务器的运行状态,并支持在运行时热插拔增删改服务器配置,无需停机维护。支持分组式访问控制,可以通过独立的 SSE 端点访问不同的 MCP 服务器组合,管理员可灵活定义不同团队或环境的权限策略。官方提供 Docker 镜像,仅需一条命令即可快速启动本地或云端服务。
|
||||
|
||||

|
||||
|
||||
## 为什么要使用 MCPHub
|
||||
|
||||
### 1. 复杂的环境依赖与配置
|
||||
|
||||
- MCP 服务器常依赖 Node.js、Python 等多种运行时,需手动维护大量命令、参数和环境变量。
|
||||
- MCPHub 内置 MCP 服务器市场,包含众多常用 MCP 服务器,支持一键安装和自动配置,简化了环境搭建过程。
|
||||
- 通过 Docker 部署,MCPHub 可在任何支持 Docker 的平台上运行,避免了环境不一致的问题。
|
||||
|
||||

|
||||
|
||||
### 2. 持续运行的服务压力
|
||||
|
||||
- MCP 要求长连接服务常驻内存,重启或升级时需要人工干预,缺乏弹性。
|
||||
- 借助 Docker 容器化部署,MCPHub 可快速重建环境,享受容器带来的弹性与隔离优势。
|
||||
|
||||
### 3. 路由与分组管理缺乏统一视图
|
||||
|
||||
- 传统方式下,很难可视化地将不同 MCP 服务按场景分类,容易造成 token 浪费和工具选择精度下降。
|
||||
- MCPHub 支持动态创建分组(如“地图检索”、“网页自动化”、“聊天”等),为每个分组生成独立的 SSE 端点,实现各类用例的隔离与优化。
|
||||
|
||||

|
||||
|
||||
## 如何使用 MCPHub
|
||||
|
||||
### 快速部署
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 samanhappy/mcphub
|
||||
```
|
||||
|
||||
一条命令就可以在本地快速启动 MCPHub,默认监听 3000 端口。
|
||||
|
||||
MCPHub 使用`mcp_settings.json`保存所有服务器、分组和用户的配置。你可以创建一个 `mcp_settings.json` 文件,并将其挂载到 Docker 容器中,以便在重启时保留配置。
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"amap": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@amap/amap-maps-mcp-server"],
|
||||
"env": {
|
||||
"AMAP_MAPS_API_KEY": "your-api-key"
|
||||
}
|
||||
},
|
||||
"time-mcp": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"time-mcp"
|
||||
]
|
||||
},
|
||||
"sequential-thinking": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-sequential-thinking"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
然后挂载配置文件启动:
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 -v $(pwd)/mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
|
||||
```
|
||||
|
||||
> 注意:首次运行时,MCPHub 会自动下载并安装所需的依赖包,可能需要一些时间。
|
||||
|
||||
### 访问控制台
|
||||
|
||||
启动后访问 `http://localhost:3000` 即可进入控制台。
|
||||
|
||||
> 默认登录用户名和密码为 `admin`/`admin123`,登录后可以修改密码以确保安全。
|
||||
|
||||
控制台提供了服务器管理、分组管理和市场管理等功能,你可以在这里查看所有已安装的 MCP 服务器、创建新的分组、添加或删除服务器等。
|
||||
|
||||
### 分组路由 & SSE 端点
|
||||
|
||||
#### 全局 SSE 端点
|
||||
|
||||
```
|
||||
http://localhost:3000/sse
|
||||
```
|
||||
|
||||
通过全局 SSE 端点可以访问所有已启用的 MCP 服务器。
|
||||
|
||||
#### 基于分组的 SSE 端点
|
||||
|
||||
除了全局 SSE 端点,MCPHub 还支持基于分组的 SSE 端点。你可以为每个分组创建独立的 SSE 端点,以便更好地管理和路由请求。
|
||||
分组的 SSE 端点格式如下:
|
||||
|
||||
```
|
||||
http://localhost:3000/sse/{groupId}
|
||||
```
|
||||
|
||||
其中 `{groupId}` 是分组的唯一标识符,可以从控制台获取。比如我在上面的截图中创建了一个名为 `map` 的分组,选择了 `amap` 和 `sequential-thinking` 两个服务器,那么可以通过以下 URL 访问这个分组的 SSE 端点:
|
||||
|
||||
```
|
||||
http://localhost:3000/sse/a800bef7-c4c1-4460-9557-5f4404cdd0bd
|
||||
```
|
||||
|
||||
#### 使用分组路由
|
||||
|
||||
创建了路由分组后,我们就可以在任意支持 MCP 的客户端中使用这个分组的 SSE 端点了。比如在 `Cursor` 中,我们可以在配置文件中指定分组的 SSE 端点:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mcphub": {
|
||||
"url": "http://localhost:3000/sse/a800bef7-c4c1-4460-9557-5f4404cdd0bd"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
配置完成后,可以从 `Cursor` 中看到所有可用的 MCP 服务器工具列表。
|
||||
|
||||

|
||||
|
||||
然后,我们可以测试一下,比如输入:深度思考一下,帮我制定一个五一假期从南京出发的自驾行出游计划,要求避开拥堵路线,结合天气情况,并且可以体验到不同的自然风光。
|
||||
|
||||

|
||||
|
||||
接着可以看到,`Cursor` 在运行过程中调用了多个工具。
|
||||
|
||||

|
||||
|
||||
最终生成结果如下:
|
||||
|
||||
```markdown
|
||||
基于对路线和天气的全面分析,我为你制定了一个四天的五一自驾游行程方案:
|
||||
|
||||
### 第一天(4月29日):南京→黄山
|
||||
- 出发时间:建议早上8点出发
|
||||
- 行驶路线:G42沪蓉高速 → S11巢黄高速
|
||||
- 行驶时间:约4.5小时(282公里)
|
||||
- 住宿地点:黄山风景区附近
|
||||
- 特别提醒:
|
||||
* 路线多隧道,注意安全
|
||||
* 天气较好,适合出行
|
||||
* 建议提前订好黄山景区附近酒店
|
||||
|
||||
### 第二天(4月30日):黄山→婺源
|
||||
- 游览时间:上午游览黄山景区
|
||||
- 下午出发前往婺源
|
||||
- 行驶路线:G3京台高速 → G56杭瑞高速
|
||||
- 行驶时间:约2.5小时(139公里)
|
||||
- 住宿地点:婺源县城或特色古村落
|
||||
- 特别提醒:
|
||||
* 注意天气可能有阵雨
|
||||
* 建议游览篁岭、李坑等著名古村落
|
||||
|
||||
### 第三天(5月1日):婺源→景德镇
|
||||
- 上午游览婺源景点
|
||||
- 中午后出发前往景德镇
|
||||
- 行驶路线:G56杭瑞高速
|
||||
- 行驶时间:约1.5小时(77公里)
|
||||
- 住宿地点:景德镇市区
|
||||
- 特别提醒:
|
||||
* 可能遇到阵雨天气
|
||||
* 建议参观陶瓷博物馆和古窑址
|
||||
|
||||
### 第四天(5月2日):景德镇→南京
|
||||
- 建议上午游览景德镇景点
|
||||
- 中午后出发返回南京
|
||||
- 行驶路线:G35济广高速 → G4211宁芜高速
|
||||
- 行驶时间:约5小时(429公里)
|
||||
- 特别提醒:
|
||||
* 路程较长,建议做好休息安排
|
||||
* 注意途中加油站分布
|
||||
* 建议错峰返程,避免拥堵
|
||||
|
||||
### 整体建议:
|
||||
1. 避堵建议:
|
||||
- 选择错峰出行时间
|
||||
- 提前查看实时路况
|
||||
- 准备备选绕行路线
|
||||
|
||||
2. 安全建议:
|
||||
- 全程多雨,注意雨天行车安全
|
||||
- 保持车距,谨慎驾驶
|
||||
- 途经隧道较多,注意灯光使用
|
||||
|
||||
3. 行程准备:
|
||||
- 提前预订住宿
|
||||
- 准备雨具和应急用品
|
||||
- 建议下载离线地图
|
||||
- 检查车况,特别是雨刮和轮胎
|
||||
|
||||
4. 景点安排:
|
||||
- 黄山:建议游览迎客松、光明顶等
|
||||
- 婺源:篁岭、李坑、江岭等
|
||||
- 景德镇:陶瓷博物馆、古窑民俗博览区
|
||||
|
||||
这条路线可以让你体验到:
|
||||
- 黄山的壮丽山景
|
||||
- 婺源的田园风光
|
||||
- 景德镇的人文历史
|
||||
既避开了主要拥堵路段,又能欣赏到不同的自然风光。
|
||||
```
|
||||
|
||||
可以看到,`Cursor` 通过调用 `amap` 和 `sequential-thinking` 两个服务器,成功生成了一个五一假期的自驾游行程方案,并且避开了拥堵路线,结合了天气情况。但是细心的同学可能发现,计划中的开始时间是 4 月 29 日,而今年的五一假期是 5 月 1 日开始的,产生偏差的原因是 `sequential-thinking` 使用了错误的假期时间。如何解决这个问题呢?我们可以尝试在分组中添加支持搜索的 MCP 服务器,这样就可以在查询时自动纠正错误的假期时间了,具体就不在这里展开了。
|
||||
|
||||
## 结语
|
||||
|
||||
MCPHub 将本地部署、一键安装、分组路由和可视化管理融为一体,以简洁而强大的设计,彻底解决了 MCP 服务器的部署、配置与运维难题。无论是追求快速验证的开发者,还是需要稳定可靠 AI 工具链的企业用户,都能通过 MCPHub 专注于核心业务与创新,而无需被底层细节所困扰。
|
||||
|
||||
尽管目前各家平台都在陆续推出各类 MCP 云服务,但在数据隐私、合规性和定制化需求日益增长的背景下,MCPHub 仍然是一个值得关注的本地部署解决方案。
|
||||
|
||||
MCPHub 只是我一时兴起开发的小项目,没想到竟收获了这么多关注,非常感谢大家的支持!目前 MCPHub 还有不少地方需要优化和完善,我也专门建了个交流群,方便大家交流反馈。如果你也对这个项目感兴趣,欢迎一起参与建设!项目地址为:https://github.com/samanhappy/mcphub。
|
||||
|
||||

|
||||
@@ -4,7 +4,19 @@ NPM_REGISTRY=${NPM_REGISTRY:-https://registry.npmjs.org/}
|
||||
echo "Setting npm registry to ${NPM_REGISTRY}"
|
||||
npm config set registry "$NPM_REGISTRY"
|
||||
|
||||
# 处理 HTTP_PROXY 和 HTTPS_PROXY 环境变量
|
||||
if [ -n "$HTTP_PROXY" ]; then
|
||||
echo "Setting HTTP proxy to ${HTTP_PROXY}"
|
||||
npm config set proxy "$HTTP_PROXY"
|
||||
export HTTP_PROXY="$HTTP_PROXY"
|
||||
fi
|
||||
|
||||
if [ -n "$HTTPS_PROXY" ]; then
|
||||
echo "Setting HTTPS proxy to ${HTTPS_PROXY}"
|
||||
npm config set https-proxy "$HTTPS_PROXY"
|
||||
export HTTPS_PROXY="$HTTPS_PROXY"
|
||||
fi
|
||||
|
||||
echo "Using REQUEST_TIMEOUT: $REQUEST_TIMEOUT"
|
||||
echo "Using UV_PYTHON_INSTALL_MIRROR: $UV_PYTHON_INSTALL_MIRROR"
|
||||
|
||||
exec "$@"
|
||||
|
||||
@@ -1,35 +1,48 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { ToastProvider } from './contexts/ToastContext';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import MainLayout from './layouts/MainLayout';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import DashboardPage from './pages/Dashboard';
|
||||
import ServersPage from './pages/ServersPage';
|
||||
import GroupsPage from './pages/GroupsPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import MarketPage from './pages/MarketPage';
|
||||
import LogsPage from './pages/LogsPage';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
{/* 公共路由 */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* 受保护的路由,使用 MainLayout 作为布局容器 */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/servers" element={<ServersPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* 未匹配的路由重定向到首页 */}
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
{/* 公共路由 */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* 受保护的路由,使用 MainLayout 作为布局容器 */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/servers" element={<ServersPage />} />
|
||||
<Route path="/groups" element={<GroupsPage />} />
|
||||
<Route path="/market" element={<MarketPage />} />
|
||||
<Route path="/market/:serverName" element={<MarketPage />} />
|
||||
<Route path="/logs" element={<LogsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* 未匹配的路由重定向到首页 */}
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
132
frontend/src/components/AddGroupForm.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
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'
|
||||
|
||||
interface AddGroupFormProps {
|
||||
onAdd: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { createGroup } = useGroupData()
|
||||
const { servers } = useServerData()
|
||||
const [availableServers, setAvailableServers] = useState<Server[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const [formData, setFormData] = useState<GroupFormData>({
|
||||
name: '',
|
||||
description: '',
|
||||
servers: []
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// Filter available servers (enabled only)
|
||||
setAvailableServers(servers.filter(server => server.enabled !== false))
|
||||
}, [servers])
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
if (!formData.name.trim()) {
|
||||
setError(t('groups.nameRequired'))
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
const result = await createGroup(formData.name, formData.description, formData.servers)
|
||||
|
||||
if (!result) {
|
||||
setError(t('groups.createError'))
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
onAdd()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('groups.addNew')}</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
|
||||
{error}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddGroupForm
|
||||
@@ -74,7 +74,7 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
|
||||
</button>
|
||||
|
||||
{modalVisible && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<ServerForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={toggleModal}
|
||||
|
||||
149
frontend/src/components/EditGroupForm.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Group, GroupFormData, Server } from '@/types'
|
||||
import { useGroupData } from '@/hooks/useGroupData'
|
||||
import { useServerData } from '@/hooks/useServerData'
|
||||
import { ToggleGroup } from './ui/ToggleGroup'
|
||||
|
||||
interface EditGroupFormProps {
|
||||
group: Group
|
||||
onEdit: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { updateGroup } = useGroupData()
|
||||
const { servers } = useServerData()
|
||||
const [availableServers, setAvailableServers] = useState<Server[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const [formData, setFormData] = useState<GroupFormData>({
|
||||
name: group.name,
|
||||
description: group.description || '',
|
||||
servers: group.servers || []
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// Filter available servers (enabled only)
|
||||
setAvailableServers(servers.filter(server => server.enabled !== false))
|
||||
}, [servers])
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}))
|
||||
}
|
||||
|
||||
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)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
if (!formData.name.trim()) {
|
||||
setError(t('groups.nameRequired'))
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
const result = await updateGroup(group.id, {
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
servers: formData.servers
|
||||
})
|
||||
|
||||
if (!result) {
|
||||
setError(t('groups.updateError'))
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
onEdit()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('groups.edit')}</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
|
||||
{error}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditGroupForm
|
||||
@@ -61,7 +61,7 @@ const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<ServerForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={onCancel}
|
||||
|
||||
143
frontend/src/components/GroupCard.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Group, Server } from '@/types'
|
||||
import { Edit, Trash, Copy, Check } from '@/components/icons/LucideIcons'
|
||||
import DeleteDialog from '@/components/ui/DeleteDialog'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
|
||||
interface GroupCardProps {
|
||||
group: Group
|
||||
servers: Server[]
|
||||
onEdit: (group: Group) => void
|
||||
onDelete: (groupId: string) => void
|
||||
}
|
||||
|
||||
const GroupCard = ({
|
||||
group,
|
||||
servers,
|
||||
onEdit,
|
||||
onDelete
|
||||
}: GroupCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { showToast } = useToast()
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleEdit = () => {
|
||||
onEdit(group)
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
setShowDeleteDialog(true)
|
||||
}
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
onDelete(group.id)
|
||||
setShowDeleteDialog(false)
|
||||
}
|
||||
|
||||
const copyToClipboard = () => {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(group.id).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
} else {
|
||||
// Fallback for HTTP or unsupported clipboard API
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = group.id
|
||||
// Avoid scrolling to bottom
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-9999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (err) {
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error')
|
||||
console.error('Copy to clipboard failed:', err)
|
||||
}
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
}
|
||||
|
||||
// Get servers that belong to this group
|
||||
const groupServers = servers.filter(server => group.servers.includes(server.name))
|
||||
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
{group.description && (
|
||||
<p className="text-gray-600 text-sm mt-1">{group.description}</p>
|
||||
)}
|
||||
</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">
|
||||
{t('groups.serverCount', { count: group.servers.length })}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
title={t('groups.edit')}
|
||||
>
|
||||
<Edit size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="text-gray-500 hover:text-red-600"
|
||||
title={t('groups.delete')}
|
||||
>
|
||||
<Trash size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DeleteDialog
|
||||
isOpen={showDeleteDialog}
|
||||
onClose={() => setShowDeleteDialog(false)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
serverName={group.name}
|
||||
isGroup={true}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GroupCard
|
||||
179
frontend/src/components/LogViewer.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { LogEntry } from '../services/logService';
|
||||
import { Button } from './ui/Button';
|
||||
import { Badge } from './ui/Badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface LogViewerProps {
|
||||
logs: LogEntry[];
|
||||
isLoading?: boolean;
|
||||
error?: Error | null;
|
||||
onClear?: () => void;
|
||||
}
|
||||
|
||||
const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error = null, onClear }) => {
|
||||
const { t } = useTranslation();
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [filter, setFilter] = useState<string>('');
|
||||
const [typeFilter, setTypeFilter] = useState<Array<'info' | 'error' | 'warn' | 'debug'>>(['info', 'error', 'warn', 'debug']);
|
||||
const [sourceFilter, setSourceFilter] = useState<Array<'main' | 'child-process'>>(['main', 'child-process']);
|
||||
|
||||
// Auto scroll to bottom when new logs come in if autoScroll is enabled
|
||||
useEffect(() => {
|
||||
if (autoScroll && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs, autoScroll]);
|
||||
|
||||
// Filter logs based on current filter settings
|
||||
const filteredLogs = logs.filter(log => {
|
||||
const matchesText = filter ? log.message.toLowerCase().includes(filter.toLowerCase()) : true;
|
||||
const matchesType = typeFilter.includes(log.type);
|
||||
const matchesSource = sourceFilter.includes(log.source as 'main' | 'child-process');
|
||||
return matchesText && matchesType && matchesSource;
|
||||
});
|
||||
|
||||
// Format timestamp to readable format
|
||||
const formatTimestamp = (timestamp: number) => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
};
|
||||
|
||||
// Get badge color based on log type
|
||||
const getLogTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'error': return 'bg-red-500';
|
||||
case 'warn': return 'bg-yellow-500';
|
||||
case 'debug': return 'bg-purple-500';
|
||||
default: return 'bg-blue-500';
|
||||
}
|
||||
};
|
||||
|
||||
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="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-sm">{t('logs.filters')}:</span>
|
||||
|
||||
{/* Text search filter */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('logs.search')}
|
||||
className="px-2 py-1 text-sm border rounded"
|
||||
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 => (
|
||||
<Badge
|
||||
key={type}
|
||||
variant={typeFilter.includes(type) ? 'default' : 'outline'}
|
||||
className={`cursor-pointer ${typeFilter.includes(type) ? getLogTypeColor(type) : ''}`}
|
||||
onClick={() => {
|
||||
if (typeFilter.includes(type)) {
|
||||
setTypeFilter(prev => prev.filter(t => t !== type));
|
||||
} else {
|
||||
setTypeFilter(prev => [...prev, type]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{type}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Log source filters */}
|
||||
<div className="flex gap-1 items-center ml-2">
|
||||
{(['main', 'child-process'] as const).map(source => (
|
||||
<Badge
|
||||
key={source}
|
||||
variant={sourceFilter.includes(source) ? 'default' : 'outline'}
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
if (sourceFilter.includes(source)) {
|
||||
setSourceFilter(prev => prev.filter(s => s !== source));
|
||||
} else {
|
||||
setSourceFilter(prev => [...prev, source]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{source === 'main' ? t('logs.mainProcess') : t('logs.childProcess')}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-1 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoScroll}
|
||||
onChange={() => setAutoScroll(!autoScroll)}
|
||||
className="form-checkbox h-4 w-4"
|
||||
/>
|
||||
{t('logs.autoScroll')}
|
||||
</label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClear}
|
||||
disabled={isLoading || logs.length === 0}
|
||||
>
|
||||
{t('logs.clearLogs')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
className="flex-grow p-2 overflow-auto bg-card rounded-b-md font-mono text-sm"
|
||||
style={{ maxHeight: 'calc(100vh - 300px)' }}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center h-full">
|
||||
<span>{t('logs.loading')}</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-red-500 p-2">
|
||||
{error.message}
|
||||
</div>
|
||||
) : filteredLogs.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground p-8">
|
||||
{filter || typeFilter.length < 4 || sourceFilter.length < 2
|
||||
? t('logs.noMatch')
|
||||
: t('logs.noLogs')}
|
||||
</div>
|
||||
) : (
|
||||
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' :
|
||||
log.type === 'warn' ? 'text-yellow-500' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="text-gray-400">[{formatTimestamp(log.timestamp)}]</span>
|
||||
<Badge className={`ml-2 mr-1 ${getLogTypeColor(log.type)}`}>
|
||||
{log.type}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="mr-2">
|
||||
{log.source === 'main' ? t('logs.main') : t('logs.child')}
|
||||
{log.processId ? ` (${log.processId})` : ''}
|
||||
</Badge>
|
||||
<span className="whitespace-pre-wrap">{log.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogViewer;
|
||||
153
frontend/src/components/MarketServerCard.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MarketServer } from '@/types';
|
||||
|
||||
interface MarketServerCardProps {
|
||||
server: MarketServer;
|
||||
onClick: (server: MarketServer) => void;
|
||||
}
|
||||
|
||||
const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Intelligently calculate how many tags to display to ensure they fit in a single line
|
||||
const getTagsToDisplay = () => {
|
||||
if (!server.tags || server.tags.length === 0) {
|
||||
return { tagsToShow: [], hasMore: false, moreCount: 0 };
|
||||
}
|
||||
|
||||
// Estimate available width in the card (in characters)
|
||||
const estimatedAvailableWidth = 28; // Estimated number of characters that can fit in one line
|
||||
|
||||
// Calculate the character space needed for tags and plus sign (including # and spacing)
|
||||
const calculateTagWidth = (tag: string) => tag.length + 3; // +3 for # and spacing
|
||||
|
||||
// Loop to determine the maximum number of tags that can be displayed
|
||||
let totalWidth = 0;
|
||||
let i = 0;
|
||||
|
||||
// First, sort tags by length to prioritize displaying shorter tags
|
||||
const sortedTags = [...server.tags].sort((a, b) => a.length - b.length);
|
||||
|
||||
// Calculate how many tags can fit
|
||||
for (i = 0; i < sortedTags.length; i++) {
|
||||
const tagWidth = calculateTagWidth(sortedTags[i]);
|
||||
|
||||
// If this tag would make the total width exceed available width, stop adding
|
||||
if (totalWidth + tagWidth > estimatedAvailableWidth) {
|
||||
break;
|
||||
}
|
||||
|
||||
totalWidth += tagWidth;
|
||||
|
||||
// If this is the last tag but there's still space, no need to show "more"
|
||||
if (i === sortedTags.length - 1) {
|
||||
return {
|
||||
tagsToShow: sortedTags,
|
||||
hasMore: false,
|
||||
moreCount: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
tagsToShow: sortedTags.slice(0, i),
|
||||
hasMore: true,
|
||||
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)),
|
||||
hasMore: true,
|
||||
moreCount: moreCount + 1
|
||||
};
|
||||
};
|
||||
|
||||
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"
|
||||
onClick={() => onClick(server)}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900 line-clamp-1 mr-2">{server.display_name}</h3>
|
||||
{server.is_official && (
|
||||
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded flex-shrink-0">
|
||||
{t('market.official')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm mb-4 line-clamp-2 min-h-[40px]">{server.description}</p>
|
||||
|
||||
{/* Categories */}
|
||||
<div className="flex flex-wrap gap-1 mb-2 min-h-[28px]">
|
||||
{server.categories?.length > 0 ? (
|
||||
server.categories.map((category, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="bg-gray-100 text-gray-800 text-xs px-2 py-1 rounded whitespace-nowrap"
|
||||
>
|
||||
{category}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 py-1">-</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="relative mb-3 min-h-[28px] overflow-x-auto">
|
||||
{server.tags?.length > 0 ? (
|
||||
<div className="flex gap-1 items-center whitespace-nowrap">
|
||||
{tagsToShow.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="bg-green-50 text-green-700 text-xs px-2 py-1 rounded flex-shrink-0"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
{hasMore && (
|
||||
<span className="bg-gray-100 text-gray-600 text-xs px-1.5 py-1 rounded flex-shrink-0">
|
||||
+{moreCount} {t('market.moreTags')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 py-1">-</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mt-auto pt-2 text-xs text-gray-500 border-t border-gray-100">
|
||||
<div className="overflow-hidden">
|
||||
<span className="whitespace-nowrap">{t('market.by')} </span>
|
||||
<span className="font-medium whitespace-nowrap overflow-hidden text-ellipsis max-w-[120px] inline-block align-bottom">
|
||||
{server.author?.name || t('market.unknown')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center flex-shrink-0">
|
||||
<svg className="h-4 w-4 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>{server.tools?.length || 0} {t('market.tools')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketServerCard;
|
||||
297
frontend/src/components/MarketServerDetail.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MarketServer, MarketServerInstallation } from '@/types';
|
||||
import ServerForm from './ServerForm';
|
||||
|
||||
interface MarketServerDetailProps {
|
||||
server: MarketServer;
|
||||
onBack: () => void;
|
||||
onInstall: (server: MarketServer) => void;
|
||||
installing?: boolean;
|
||||
isInstalled?: boolean;
|
||||
}
|
||||
|
||||
const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
server,
|
||||
onBack,
|
||||
onInstall,
|
||||
installing = false,
|
||||
isInstalled = false
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Helper function to determine button state
|
||||
const getButtonProps = () => {
|
||||
if (isInstalled) {
|
||||
return {
|
||||
className: "bg-green-600 cursor-default px-4 py-2 rounded text-sm font-medium text-white",
|
||||
disabled: true,
|
||||
text: t('market.installed')
|
||||
};
|
||||
} else if (installing) {
|
||||
return {
|
||||
className: "bg-gray-400 cursor-not-allowed px-4 py-2 rounded text-sm font-medium text-white",
|
||||
disabled: true,
|
||||
text: t('market.installing')
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
className: "bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white",
|
||||
disabled: false,
|
||||
text: t('market.install')
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const toggleModal = () => {
|
||||
setModalVisible(!modalVisible);
|
||||
setError(null); // Clear any previous errors when toggling modal
|
||||
};
|
||||
|
||||
const handleInstall = () => {
|
||||
if (!isInstalled) {
|
||||
toggleModal();
|
||||
}
|
||||
};
|
||||
|
||||
// Get the preferred installation configuration based on priority:
|
||||
// npm > uvx > default
|
||||
const getPreferredInstallation = (): MarketServerInstallation | undefined => {
|
||||
if (!server.installations) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (server.installations.npm) {
|
||||
return server.installations.npm;
|
||||
} else if (server.installations.uvx) {
|
||||
return server.installations.uvx;
|
||||
} 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);
|
||||
} catch (err) {
|
||||
console.error('Error installing server:', err);
|
||||
setError(t('errors.serverInstall'));
|
||||
}
|
||||
};
|
||||
|
||||
const buttonProps = getButtonProps();
|
||||
const preferredInstallation = getPreferredInstallation();
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-gray-600 hover:text-gray-900 flex items-center"
|
||||
>
|
||||
<svg className="h-5 w-5 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{t('market.backToList')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
<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} •
|
||||
<a
|
||||
href={server.repository.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline ml-1"
|
||||
>
|
||||
{t('market.repository')}
|
||||
</a>
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{t('market.official')}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
disabled={buttonProps.disabled}
|
||||
className={buttonProps.className}
|
||||
>
|
||||
{buttonProps.text}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-700 mb-6">{server.description}</p>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold mb-3">{t('market.categories')} & {t('market.tags')}</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{server.categories?.map((category, index) => (
|
||||
<span key={`cat-${index}`} className="bg-gray-100 text-gray-800 px-3 py-1 rounded">
|
||||
{category}
|
||||
</span>
|
||||
))}
|
||||
{server.tags && server.tags.map((tag, index) => (
|
||||
<span key={`tag-${index}`} className="bg-gray-100 text-green-700 px-2 py-1 rounded text-sm">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{server.arguments && Object.keys(server.arguments).length > 0 && (
|
||||
<div className="mb-6">
|
||||
<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">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
|
||||
{t('market.argumentName')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
|
||||
{t('market.description')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
|
||||
{t('market.required')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
|
||||
{t('market.example')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{Object.entries(server.arguments).map(([name, arg], index) => (
|
||||
<tr key={index}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{name}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
{arg.description}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{arg.required ? (
|
||||
<span className="text-green-600">✓</span>
|
||||
) : (
|
||||
<span className="text-red-600">✗</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<code className="bg-gray-100 px-2 py-1 rounded">{arg.example}</code>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold mb-3">{t('market.tools')}</h3>
|
||||
<div className="space-y-4">
|
||||
{server.tools?.map((tool, index) => (
|
||||
<div key={index} className="border border-gray-200 rounded p-4">
|
||||
<h4 className="font-medium mb-2">
|
||||
{tool.name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Toggle visibility of schema (simplified for this implementation)
|
||||
const element = document.getElementById(`schema-${index}`);
|
||||
if (element) {
|
||||
element.classList.toggle('hidden');
|
||||
}
|
||||
}}
|
||||
className="text-sm text-blue-600 hover:underline focus:outline-none ml-2"
|
||||
>
|
||||
{t('market.viewSchema')}
|
||||
</button>
|
||||
</h4>
|
||||
<p className="text-gray-600 mb-2">{tool.description}</p>
|
||||
<div className="mt-2">
|
||||
<pre id={`schema-${index}`} className="hidden bg-gray-50 p-3 rounded text-sm overflow-auto mt-2">
|
||||
{JSON.stringify(tool.inputSchema, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{server.examples && server.examples.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold mb-3">{t('market.examples')}</h3>
|
||||
<div className="space-y-4">
|
||||
{server.examples.map((example, index) => (
|
||||
<div key={index} className="border border-gray-200 rounded p-4">
|
||||
<h4 className="font-medium mb-2">{example.title}</h4>
|
||||
<p className="text-gray-600 mb-2">{example.description}</p>
|
||||
<pre className="bg-gray-50 p-3 rounded text-sm overflow-auto">
|
||||
{example.prompt}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
disabled={buttonProps.disabled}
|
||||
className={buttonProps.className}
|
||||
>
|
||||
{buttonProps.text}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{modalVisible && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<ServerForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={toggleModal}
|
||||
modalTitle={t('market.installServer', { name: server.display_name })}
|
||||
formError={error}
|
||||
initialData={{
|
||||
name: server.name,
|
||||
status: 'disconnected',
|
||||
config: preferredInstallation
|
||||
? {
|
||||
command: preferredInstallation.command || '',
|
||||
args: preferredInstallation.args || [],
|
||||
env: preferredInstallation.env || {}
|
||||
}
|
||||
: undefined
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketServerDetail;
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Server } from '@/types'
|
||||
import { ChevronDown, ChevronRight } from '@/components/icons/LucideIcons'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import { ChevronDown, ChevronRight, AlertCircle, Copy, Check } from 'lucide-react'
|
||||
import { StatusBadge } from '@/components/ui/Badge'
|
||||
import ToolCard from '@/components/ui/ToolCard'
|
||||
import DeleteDialog from '@/components/ui/DeleteDialog'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
|
||||
interface ServerCardProps {
|
||||
server: Server
|
||||
@@ -15,9 +16,26 @@ interface ServerCardProps {
|
||||
|
||||
const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { showToast } = useToast()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isToggling, setIsToggling] = useState(false)
|
||||
const [showErrorPopover, setShowErrorPopover] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const errorPopoverRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (errorPopoverRef.current && !errorPopoverRef.current.contains(event.target as Node)) {
|
||||
setShowErrorPopover(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleRemove = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
@@ -41,58 +59,162 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
|
||||
}
|
||||
}
|
||||
|
||||
const handleErrorIconClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setShowErrorPopover(!showErrorPopover)
|
||||
}
|
||||
|
||||
const copyToClipboard = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!server.error) return
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(server.error).then(() => {
|
||||
setCopied(true)
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
} else {
|
||||
// Fallback for HTTP or unsupported clipboard API
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = server.error
|
||||
// Avoid scrolling to bottom
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-9999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
setCopied(true)
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (err) {
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error')
|
||||
console.error('Copy to clipboard failed:', err)
|
||||
}
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
onRemove(server.name)
|
||||
setShowDeleteDialog(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white shadow rounded-lg p-6 mb-6 ${server.enabled === false ? 'opacity-60' : ''}`}>
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<h2 className={`text-xl font-semibold ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'}`}>{server.name}</h2>
|
||||
<Badge status={server.status} />
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
{t('server.edit')}
|
||||
</button>
|
||||
<div className="flex items-center">
|
||||
<>
|
||||
<div className={`bg-white shadow rounded-lg p-6 mb-6 ${server.enabled === false ? 'opacity-60' : ''}`}>
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<h2 className={`text-xl font-semibold ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'}`}>{server.name}</h2>
|
||||
<StatusBadge status={server.status} />
|
||||
|
||||
{server.error && (
|
||||
<div className="relative">
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={handleErrorIconClick}
|
||||
aria-label={t('server.viewErrorDetails')}
|
||||
>
|
||||
<AlertCircle className="text-red-500 hover:text-red-600" size={18} />
|
||||
</div>
|
||||
|
||||
{showErrorPopover && (
|
||||
<div
|
||||
ref={errorPopoverRef}
|
||||
className="absolute z-10 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-0 w-120"
|
||||
style={{
|
||||
left: '-231px',
|
||||
top: '24px',
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
width: '480px',
|
||||
transform: 'translateX(50%)'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex justify-between items-center sticky top-0 bg-white py-2 px-4 border-b border-gray-200 z-20 shadow-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<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"
|
||||
title={t('common.copy')}
|
||||
>
|
||||
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowErrorPopover(false)
|
||||
}}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 pt-2">
|
||||
<pre className="text-sm text-gray-700 break-words whitespace-pre-wrap">{server.error}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
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'
|
||||
}`}
|
||||
disabled={isToggling}
|
||||
onClick={handleEdit}
|
||||
className="px-3 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm"
|
||||
>
|
||||
{isToggling
|
||||
? t('common.processing')
|
||||
: server.enabled !== false
|
||||
? t('server.disable')
|
||||
: t('server.enable')
|
||||
}
|
||||
{t('server.edit')}
|
||||
</button>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
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'
|
||||
}`}
|
||||
disabled={isToggling}
|
||||
>
|
||||
{isToggling
|
||||
? t('common.processing')
|
||||
: server.enabled !== false
|
||||
? t('server.disable')
|
||||
: t('server.enable')
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
className="px-3 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm"
|
||||
>
|
||||
{t('server.delete')}
|
||||
</button>
|
||||
<button className="text-gray-400 hover:text-gray-600">
|
||||
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
className="px-3 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm"
|
||||
>
|
||||
{t('server.delete')}
|
||||
</button>
|
||||
<button className="text-gray-400 hover:text-gray-600">
|
||||
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isExpanded && server.tools && (
|
||||
<div className="mt-6">
|
||||
<h3 className={`text-lg font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}>{t('server.tools')}</h3>
|
||||
<div className="space-y-4">
|
||||
{server.tools.map((tool, index) => (
|
||||
<ToolCard key={index} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DeleteDialog
|
||||
@@ -101,18 +223,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
|
||||
onConfirm={handleConfirmDelete}
|
||||
serverName={server.name}
|
||||
/>
|
||||
|
||||
{isExpanded && server.tools && (
|
||||
<div className="mt-6">
|
||||
<h3 className={`text-lg font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}>{t('server.tools')}</h3>
|
||||
<div className="space-y-4">
|
||||
{server.tools.map((tool, index) => (
|
||||
<ToolCard key={index} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,21 @@ interface ServerFormProps {
|
||||
|
||||
const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formError = null }: ServerFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [serverType, setServerType] = useState<'sse' | 'stdio'>(
|
||||
initialData && initialData.config && initialData.config.url ? 'sse' : 'stdio',
|
||||
)
|
||||
|
||||
// Determine the initial server type from the initialData
|
||||
const getInitialServerType = () => {
|
||||
if (!initialData || !initialData.config) return 'stdio';
|
||||
|
||||
if (initialData.config.type) {
|
||||
return initialData.config.type; // Use explicit type if available
|
||||
} else if (initialData.config.url) {
|
||||
return 'sse'; // Fallback to SSE if URL exists
|
||||
} else {
|
||||
return 'stdio'; // Default to stdio
|
||||
}
|
||||
};
|
||||
|
||||
const [serverType, setServerType] = useState<'stdio' | 'sse' | 'streamable-http'>(getInitialServerType());
|
||||
|
||||
const [formData, setFormData] = useState<ServerFormData>({
|
||||
name: (initialData && initialData.name) || '',
|
||||
@@ -27,6 +39,8 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
: String(initialData.config.args)
|
||||
: '',
|
||||
args: (initialData && initialData.config && initialData.config.args) || [],
|
||||
type: getInitialServerType(), // Initialize the type field
|
||||
env: []
|
||||
})
|
||||
|
||||
const [envVars, setEnvVars] = useState<EnvVar[]>(
|
||||
@@ -49,6 +63,11 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
setFormData({ ...formData, arguments: value, args })
|
||||
}
|
||||
|
||||
const updateServerType = (type: 'stdio' | 'sse' | 'streamable-http') => {
|
||||
setServerType(type);
|
||||
setFormData(prev => ({ ...prev, type }));
|
||||
}
|
||||
|
||||
const handleEnvVarChange = (index: number, field: 'key' | 'value', value: string) => {
|
||||
const newEnvVars = [...envVars]
|
||||
newEnvVars[index][field] = value
|
||||
@@ -80,14 +99,17 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
|
||||
const payload = {
|
||||
name: formData.name,
|
||||
config:
|
||||
serverType === 'sse'
|
||||
config: {
|
||||
type: serverType, // Always include the type
|
||||
...(serverType === 'sse' || serverType === 'streamable-http'
|
||||
? { url: formData.url }
|
||||
: {
|
||||
command: formData.command,
|
||||
args: formData.args,
|
||||
env: Object.keys(env).length > 0 ? env : undefined,
|
||||
},
|
||||
command: formData.command,
|
||||
args: formData.args,
|
||||
env: Object.keys(env).length > 0 ? env : undefined,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit(payload)
|
||||
@@ -139,10 +161,10 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
name="serverType"
|
||||
value="command"
|
||||
checked={serverType === 'stdio'}
|
||||
onChange={() => setServerType('stdio')}
|
||||
onChange={() => updateServerType('stdio')}
|
||||
className="mr-1"
|
||||
/>
|
||||
<label htmlFor="command">stdio</label>
|
||||
<label htmlFor="command">STDIO</label>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
@@ -151,15 +173,27 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
name="serverType"
|
||||
value="url"
|
||||
checked={serverType === 'sse'}
|
||||
onChange={() => setServerType('sse')}
|
||||
onChange={() => updateServerType('sse')}
|
||||
className="mr-1"
|
||||
/>
|
||||
<label htmlFor="url">sse</label>
|
||||
<label htmlFor="url">SSE</label>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="radio"
|
||||
id="streamable-http"
|
||||
name="serverType"
|
||||
value="streamable-http"
|
||||
checked={serverType === 'streamable-http'}
|
||||
onChange={() => updateServerType('streamable-http')}
|
||||
className="mr-1"
|
||||
/>
|
||||
<label htmlFor="streamable-http">Streamable HTTP</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{serverType === 'sse' ? (
|
||||
{serverType === 'sse' || serverType === 'streamable-http' ? (
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="url">
|
||||
{t('server.url')}
|
||||
@@ -171,8 +205,8 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
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"
|
||||
placeholder="e.g.: http://localhost:3000/sse"
|
||||
required={serverType === 'sse'}
|
||||
placeholder={serverType === 'streamable-http' ? "e.g.: http://localhost:3000/mcp" : "e.g.: http://localhost:3000/sse"}
|
||||
required={serverType === 'sse' || serverType === 'streamable-http'}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { ChevronDown, ChevronRight, Edit, Trash, Copy, Check, User, Settings, LogOut, Info } from 'lucide-react'
|
||||
|
||||
export { ChevronDown, ChevronRight }
|
||||
export { ChevronDown, ChevronRight, Edit, Trash, Copy, Check, User, Settings, LogOut, Info }
|
||||
|
||||
const LucideIcons = {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Edit,
|
||||
Trash,
|
||||
Copy,
|
||||
Check,
|
||||
User,
|
||||
Settings,
|
||||
LogOut,
|
||||
Info
|
||||
}
|
||||
|
||||
export default LucideIcons
|
||||
@@ -6,8 +6,8 @@ interface ContentProps {
|
||||
|
||||
const Content: React.FC<ContentProps> = ({ children }) => {
|
||||
return (
|
||||
<main className="flex-1 p-6 overflow-auto">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<main className="flex-1 overflow-auto p-6 bg-gray-100 dark:bg-gray-900">
|
||||
<div className="container mx-auto">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import ThemeSwitch from '@/components/ui/ThemeSwitch';
|
||||
|
||||
interface HeaderProps {
|
||||
onToggleSidebar: () => void;
|
||||
@@ -9,49 +9,31 @@ interface HeaderProps {
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { auth, logout } = useAuth();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
const { auth } = useAuth();
|
||||
|
||||
return (
|
||||
<header className="bg-white shadow-sm z-10">
|
||||
<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 items-center">
|
||||
{/* 侧边栏切换按钮 */}
|
||||
<button
|
||||
<button
|
||||
onClick={onToggleSidebar}
|
||||
className="p-2 rounded-md text-gray-500 hover:text-gray-900 hover:bg-gray-100 focus:outline-none"
|
||||
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={t('app.toggleSidebar')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
|
||||
{/* 应用标题 */}
|
||||
<h1 className="ml-4 text-xl font-bold text-gray-900">{t('app.title')}</h1>
|
||||
<h1 className="ml-4 text-xl font-bold text-gray-900 dark:text-white">{t('app.title')}</h1>
|
||||
</div>
|
||||
|
||||
{/* 用户信息和操作 */}
|
||||
|
||||
{/* Theme Switch */}
|
||||
<div className="flex items-center space-x-4">
|
||||
{auth.user && (
|
||||
<span className="text-sm text-gray-700">
|
||||
{t('app.welcomeUser', { username: auth.user.username })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-3 py-1.5 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm"
|
||||
>
|
||||
{t('app.logout')}
|
||||
</button>
|
||||
</div>
|
||||
<ThemeSwitch />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import UserProfileMenu from '@/components/ui/UserProfileMenu';
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed: boolean;
|
||||
@@ -16,7 +17,10 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
// 菜单项配置
|
||||
// Application version from package.json (accessed via Vite environment variables)
|
||||
const appVersion = import.meta.env.PACKAGE_VERSION as string;
|
||||
|
||||
// Menu item configuration
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
path: '/',
|
||||
@@ -38,11 +42,29 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
label: t('nav.settings'),
|
||||
path: '/groups',
|
||||
label: t('nav.groups'),
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
|
||||
<path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/market',
|
||||
label: t('nav.market'),
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" 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 1H3zM16 16.5a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0zM6.5 18a1.5 1.5 0 100-3 1.5 1.5 0 000 3z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/logs',
|
||||
label: t('nav.logs'),
|
||||
icon: (
|
||||
<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 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
@@ -50,29 +72,37 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`bg-white shadow-sm transition-all duration-300 ease-in-out ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<nav className="p-3 space-y-1">
|
||||
{menuItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center px-3 py-2 rounded-md transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`
|
||||
}
|
||||
end={item.path === '/'}
|
||||
>
|
||||
<span className="flex-shrink-0">{item.icon}</span>
|
||||
{!collapsed && <span className="ml-3">{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
{/* Scrollable navigation area */}
|
||||
<div className="overflow-y-auto flex-grow">
|
||||
<nav className="p-3 space-y-1">
|
||||
{menuItems.map((item) => (
|
||||
<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'
|
||||
}`
|
||||
}
|
||||
end={item.path === '/'}
|
||||
>
|
||||
<span className="flex-shrink-0">{item.icon}</span>
|
||||
{!collapsed && <span className="ml-3">{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* User profile menu fixed at the bottom */}
|
||||
<div className="p-3 bg-white dark:bg-gray-800">
|
||||
<UserProfileMenu collapsed={collapsed} version={appVersion} />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
112
frontend/src/components/ui/AboutDialog.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, RefreshCw } from 'lucide-react';
|
||||
import { checkLatestVersion, compareVersions } from '@/utils/version';
|
||||
|
||||
interface AboutDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
version: string;
|
||||
}
|
||||
|
||||
const AboutDialog: React.FC<AboutDialogProps> = ({ isOpen, onClose, version }) => {
|
||||
const { t } = useTranslation();
|
||||
const [hasNewVersion, setHasNewVersion] = useState(false);
|
||||
const [latestVersion, setLatestVersion] = useState("");
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
setIsChecking(true);
|
||||
try {
|
||||
const latest = await checkLatestVersion();
|
||||
if (latest) {
|
||||
setLatestVersion(latest);
|
||||
setHasNewVersion(compareVersions(version, latest) > 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check for updates:', error);
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
checkForUpdates();
|
||||
}
|
||||
}, [isOpen, version]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 bg-opacity-30 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg max-w-md w-full">
|
||||
<div className="p-6 relative">
|
||||
{/* Close button (X) in the top-right corner */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
|
||||
{t('about.title')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{t('about.currentVersion')}:
|
||||
</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{version}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hasNewVersion && latestVersion && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900 p-3 rounded">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-blue-600 dark:text-blue-300" 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>
|
||||
</div>
|
||||
<div className="ml-3 flex-1 text-sm text-blue-700 dark:text-blue-300">
|
||||
<p>{t('about.newVersionAvailable', { version: latestVersion })}</p>
|
||||
<p className="mt-1">
|
||||
<a
|
||||
href="https://github.com/samanhappy/mcphub"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{t('about.viewOnGitHub')}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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
|
||||
${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'
|
||||
} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500`}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isChecking ? 'animate-spin' : ''}`} />
|
||||
{isChecking ? t('about.checking') : t('about.checkForUpdates')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutDialog;
|
||||
@@ -1,25 +1,61 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ServerStatus } from '@/types'
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ServerStatus } from '@/types';
|
||||
import { cn } from '../../utils/cn';
|
||||
|
||||
interface BadgeProps {
|
||||
status: ServerStatus
|
||||
type BadgeVariant = 'default' | 'secondary' | 'outline' | 'destructive';
|
||||
|
||||
type BadgeProps = {
|
||||
children: React.ReactNode;
|
||||
variant?: BadgeVariant;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const badgeVariants = {
|
||||
default: 'bg-blue-500 text-white hover:bg-blue-600',
|
||||
secondary: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600',
|
||||
outline: 'bg-transparent border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800',
|
||||
destructive: 'bg-red-500 text-white hover:bg-red-600',
|
||||
};
|
||||
|
||||
export function Badge({
|
||||
children,
|
||||
variant = 'default',
|
||||
className,
|
||||
onClick
|
||||
}: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors',
|
||||
badgeVariants[variant],
|
||||
onClick ? 'cursor-pointer' : '',
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const Badge = ({ status }: BadgeProps) => {
|
||||
const { t } = useTranslation()
|
||||
// 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',
|
||||
}
|
||||
};
|
||||
|
||||
// Map status to translation keys
|
||||
const statusTranslations = {
|
||||
connected: 'status.online',
|
||||
disconnected: 'status.offline',
|
||||
connecting: 'status.connecting'
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
@@ -27,7 +63,5 @@ const Badge = ({ status }: BadgeProps) => {
|
||||
>
|
||||
{t(statusTranslations[status] || status)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default Badge
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../../utils/cn';
|
||||
|
||||
type ButtonVariant = 'default' | 'outline' | 'ghost' | 'link' | 'destructive';
|
||||
type ButtonSize = 'default' | 'sm' | 'lg' | 'icon';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
asChild?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const variantStyles: Record<ButtonVariant, string> = {
|
||||
default: 'bg-blue-500 text-white hover:bg-blue-600 focus:ring-blue-500',
|
||||
outline: 'border border-gray-300 dark:border-gray-700 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300',
|
||||
ghost: 'bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300',
|
||||
link: 'bg-transparent underline-offset-4 hover:underline text-blue-500 hover:text-blue-600',
|
||||
destructive: 'bg-red-500 text-white hover:bg-red-600 focus:ring-red-500',
|
||||
};
|
||||
|
||||
const sizeStyles: Record<ButtonSize, string> = {
|
||||
default: 'h-10 py-2 px-4',
|
||||
sm: 'h-8 px-3 text-sm',
|
||||
lg: 'h-12 px-6',
|
||||
icon: 'h-10 w-10 p-0',
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
className,
|
||||
disabled,
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-md inline-flex items-center justify-center font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none',
|
||||
variantStyles[variant],
|
||||
sizeStyles[size],
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -5,33 +5,40 @@ interface DeleteDialogProps {
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
serverName: string
|
||||
isGroup?: boolean
|
||||
}
|
||||
|
||||
const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName }: DeleteDialogProps) => {
|
||||
const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName, isGroup = false }: DeleteDialogProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white shadow rounded-lg p-6 w-full max-w-md">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">{t('server.delete')}</h3>
|
||||
<p className="text-gray-700">
|
||||
{t('server.confirmDelete')} <strong>{serverName}</strong>
|
||||
</p>
|
||||
<div className="flex justify-end mt-6">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="bg-gray-300 hover:bg-gray-400 text-gray-800 font-medium py-2 px-4 rounded mr-2"
|
||||
>
|
||||
{t('server.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded"
|
||||
>
|
||||
{t('server.delete')}
|
||||
</button>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-30 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">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-3">
|
||||
{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 })}
|
||||
</p>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
||||
>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
128
frontend/src/components/ui/Pagination.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React from 'react';
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
1
|
||||
</button>
|
||||
);
|
||||
|
||||
// Start range
|
||||
let startPage = Math.max(2, currentPage - Math.floor(maxDisplayedPages / 2));
|
||||
|
||||
// If we're showing ellipsis after first page
|
||||
if (startPage > 2) {
|
||||
buttons.push(
|
||||
<span key="ellipsis1" className="px-3 py-1">
|
||||
...
|
||||
</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'
|
||||
}`}
|
||||
>
|
||||
{i}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// If we're showing ellipsis before last page
|
||||
if (startPage + maxDisplayedPages - 3 < totalPages - 1) {
|
||||
buttons.push(
|
||||
<span key="ellipsis2" className="px-3 py-1">
|
||||
...
|
||||
</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'
|
||||
}`}
|
||||
>
|
||||
{totalPages}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return buttons;
|
||||
};
|
||||
|
||||
// If there's only one page, don't render pagination
|
||||
if (totalPages <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center my-6">
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
« 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'
|
||||
}`}
|
||||
>
|
||||
Next »
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pagination;
|
||||
51
frontend/src/components/ui/ThemeSwitch.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { Sun, Moon, Monitor } from 'lucide-react';
|
||||
|
||||
const ThemeSwitch: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeSwitch;
|
||||
96
frontend/src/components/ui/Toast.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info' | 'warning';
|
||||
|
||||
export interface ToastProps {
|
||||
message: string;
|
||||
type?: ToastType;
|
||||
duration?: number;
|
||||
onClose: () => void;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
const Toast: React.FC<ToastProps> = ({
|
||||
message,
|
||||
type = 'info',
|
||||
duration = 3000,
|
||||
onClose,
|
||||
visible
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
const timer = setTimeout(() => {
|
||||
onClose();
|
||||
}, duration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [visible, duration, onClose]);
|
||||
|
||||
const icons = {
|
||||
success: <Check className="w-5 h-5 text-green-500" />,
|
||||
error: <X className="w-5 h-5 text-red-500" />,
|
||||
info: (
|
||||
<svg className="w-5 h-5 text-blue-500" 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>
|
||||
),
|
||||
warning: (
|
||||
<svg className="w-5 h-5 text-yellow-500" 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-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
)
|
||||
};
|
||||
|
||||
const bgColors = {
|
||||
success: 'bg-green-50 border-green-200',
|
||||
error: 'bg-red-50 border-red-200',
|
||||
info: 'bg-blue-50 border-blue-200',
|
||||
warning: 'bg-yellow-50 border-yellow-200'
|
||||
};
|
||||
|
||||
const textColors = {
|
||||
success: 'text-green-800',
|
||||
error: 'text-red-800',
|
||||
info: 'text-blue-800',
|
||||
warning: 'text-yellow-800'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"fixed top-4 right-4 z-50 max-w-sm p-4 rounded-md shadow-lg border",
|
||||
bgColors[type],
|
||||
"transform transition-all duration-300 ease-in-out",
|
||||
visible ? "translate-x-0 opacity-100" : "translate-x-full opacity-0"
|
||||
)}>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
{icons[type]}
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className={cn("text-sm font-medium", textColors[type])}>
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto pl-3">
|
||||
<div className="-mx-1.5 -my-1.5">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"inline-flex rounded-md p-1.5",
|
||||
`hover:bg-${type}-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-${type}-500`
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">Dismiss</span>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toast;
|
||||
134
frontend/src/components/ui/ToggleGroup.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
interface ToggleGroupItemProps {
|
||||
value: string;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ToggleGroupItem: React.FC<ToggleGroupItemProps> = ({
|
||||
value,
|
||||
isSelected,
|
||||
onClick,
|
||||
children
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
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"
|
||||
: "hover:bg-gray-50 text-gray-700"
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
{children}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-5 h-5 text-blue-500">
|
||||
<path fillRule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
interface ToggleGroupProps {
|
||||
label: string;
|
||||
helpText?: string;
|
||||
noOptionsText?: string;
|
||||
values: string[];
|
||||
options: { value: string; label: string }[];
|
||||
onChange: (values: string[]) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ToggleGroup: React.FC<ToggleGroupProps> = ({
|
||||
label,
|
||||
helpText,
|
||||
noOptionsText = "No options available",
|
||||
values,
|
||||
options,
|
||||
onChange,
|
||||
className
|
||||
}) => {
|
||||
const handleToggle = (value: string) => {
|
||||
const isSelected = values.includes(value);
|
||||
if (isSelected) {
|
||||
onChange(values.filter(v => v !== value));
|
||||
} else {
|
||||
onChange([...values, value]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<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">
|
||||
{options.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm p-3">{noOptionsText}</p>
|
||||
) : (
|
||||
<div className="space-y-1 p-1">
|
||||
{options.map(option => (
|
||||
<ToggleGroupItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
isSelected={values.includes(option.value)}
|
||||
onClick={() => handleToggle(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{helpText && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{helpText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SwitchProps {
|
||||
checked: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const Switch: React.FC<SwitchProps> = ({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
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",
|
||||
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
|
||||
)}
|
||||
onClick={() => !disabled && onCheckedChange(!checked)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform",
|
||||
checked ? "translate-x-6" : "translate-x-1"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
132
frontend/src/components/ui/UserProfileMenu.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { User, Settings, LogOut, Info } from 'lucide-react';
|
||||
import AboutDialog from './AboutDialog';
|
||||
import { checkLatestVersion, compareVersions } from '@/utils/version';
|
||||
|
||||
interface UserProfileMenuProps {
|
||||
collapsed: boolean;
|
||||
version: string;
|
||||
}
|
||||
|
||||
const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version }) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { auth, logout } = useAuth();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showNewVersionInfo, setShowNewVersionInfo] = useState(false);
|
||||
const [showAboutDialog, setShowAboutDialog] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Check for new version on login and component mount
|
||||
useEffect(() => {
|
||||
const checkForNewVersion = async () => {
|
||||
try {
|
||||
const latestVersion = await checkLatestVersion();
|
||||
if (latestVersion) {
|
||||
setShowNewVersionInfo(compareVersions(version, latestVersion) > 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking for new version:', error);
|
||||
}
|
||||
};
|
||||
|
||||
checkForNewVersion();
|
||||
}, [version]);
|
||||
|
||||
// Close the menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSettingsClick = () => {
|
||||
navigate('/settings');
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleLogoutClick = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const handleAboutClick = () => {
|
||||
setShowAboutDialog(true);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="relative">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`flex ${collapsed ? 'justify-center' : 'items-center'} w-full p-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors rounded-md ${isOpen ? 'bg-gray-100 dark:bg-gray-700' : ''
|
||||
}`}
|
||||
>
|
||||
<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">
|
||||
<User className="h-4 w-4 text-gray-700 dark:text-gray-300" />
|
||||
</div>
|
||||
{showNewVersionInfo && (
|
||||
<span className="absolute -top-1 -right-1 block w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="ml-3 flex flex-col items-start">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{auth.user?.username || t('auth.user')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</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">
|
||||
<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"
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
{t('nav.settings')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAboutClick}
|
||||
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 relative"
|
||||
>
|
||||
<Info className="h-4 w-4 mr-2" />
|
||||
{t('about.title')}
|
||||
({version})
|
||||
{showNewVersionInfo && (
|
||||
<span className="absolute top-2 right-4 block w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
)}
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
{t('app.logout')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* About dialog */}
|
||||
<AboutDialog
|
||||
isOpen={showAboutDialog}
|
||||
onClose={() => setShowAboutDialog(false)}
|
||||
version={version}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserProfileMenu;
|
||||
76
frontend/src/contexts/ThemeContext.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
resolvedTheme: 'light' | 'dark'; // The actual theme used after resolving system preference
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const ThemeProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
// Get theme from localStorage or default to 'system'
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const savedTheme = localStorage.getItem('theme') as Theme;
|
||||
return savedTheme || 'system';
|
||||
});
|
||||
|
||||
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
|
||||
|
||||
// Function to set theme and save to localStorage
|
||||
const handleSetTheme = (newTheme: Theme) => {
|
||||
setTheme(newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
};
|
||||
|
||||
// Effect to handle system theme changes and apply theme to document
|
||||
useEffect(() => {
|
||||
const updateTheme = () => {
|
||||
const root = window.document.documentElement;
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
|
||||
// Determine which theme to use
|
||||
const themeToApply = theme === 'system' ? systemTheme : theme;
|
||||
setResolvedTheme(themeToApply as 'light' | 'dark');
|
||||
|
||||
// Apply or remove dark class based on theme
|
||||
if (themeToApply === 'dark') {
|
||||
console.log('Applying dark mode to HTML root element'); // 添加日志
|
||||
root.classList.add('dark');
|
||||
document.body.style.backgroundColor = '#111827'; // Force a dark background to ensure visible effect
|
||||
} else {
|
||||
console.log('Removing dark mode from HTML root element'); // 添加日志
|
||||
root.classList.remove('dark');
|
||||
document.body.style.backgroundColor = ''; // Reset background color
|
||||
}
|
||||
};
|
||||
|
||||
// Set up listeners for system theme changes
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
mediaQuery.addEventListener('change', updateTheme);
|
||||
|
||||
// Initial theme setup
|
||||
updateTheme();
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', updateTheme);
|
||||
};
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme: handleSetTheme, resolvedTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
60
frontend/src/contexts/ToastContext.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
import Toast, { ToastType } from '@/components/ui/Toast';
|
||||
|
||||
interface ToastContextProps {
|
||||
showToast: (message: string, type?: ToastType, duration?: number) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextProps | undefined>(undefined);
|
||||
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface ToastProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
|
||||
const [toast, setToast] = useState<{
|
||||
message: string;
|
||||
type: ToastType;
|
||||
visible: boolean;
|
||||
duration: number;
|
||||
}>({
|
||||
message: '',
|
||||
type: 'info',
|
||||
visible: false,
|
||||
duration: 3000,
|
||||
});
|
||||
|
||||
const showToast = (message: string, type: ToastType = 'info', duration: number = 3000) => {
|
||||
setToast({
|
||||
message,
|
||||
type,
|
||||
visible: true,
|
||||
duration,
|
||||
});
|
||||
};
|
||||
|
||||
const hideToast = () => {
|
||||
setToast((prev) => ({ ...prev, visible: false }));
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
<Toast
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
duration={toast.duration}
|
||||
onClose={hideToast}
|
||||
visible={toast.visible}
|
||||
/>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
232
frontend/src/hooks/useGroupData.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Group, ApiResponse } from '@/types';
|
||||
|
||||
export const useGroupData = () => {
|
||||
const { t } = useTranslation();
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const fetchGroups = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/groups', {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<Group[]> = await response.json();
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setGroups(data.data);
|
||||
} else {
|
||||
console.error('Invalid group data format:', data);
|
||||
setGroups([]);
|
||||
}
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching groups:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch groups');
|
||||
setGroups([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Trigger a refresh of the groups data
|
||||
const triggerRefresh = useCallback(() => {
|
||||
setRefreshKey(prev => prev + 1);
|
||||
}, []);
|
||||
|
||||
// Create a new group with server associations
|
||||
const createGroup = async (name: string, description?: string, servers: string[] = []) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/groups', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || ''
|
||||
},
|
||||
body: JSON.stringify({ name, description, servers }),
|
||||
});
|
||||
|
||||
const result: ApiResponse<Group> = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(result.message || t('groups.createError'));
|
||||
return null;
|
||||
}
|
||||
|
||||
triggerRefresh();
|
||||
return result.data || null;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create group');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Update an existing group with server associations
|
||||
const updateGroup = async (id: string, data: { name?: string; description?: string; servers?: string[] }) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(`/api/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;
|
||||
}
|
||||
|
||||
triggerRefresh();
|
||||
return result.data || null;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update group');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Update servers in a group (for batch updates)
|
||||
const updateGroupServers = async (groupId: string, servers: string[]) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(`/api/groups/${groupId}/servers/batch`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || ''
|
||||
},
|
||||
body: JSON.stringify({ servers }),
|
||||
});
|
||||
|
||||
const result: ApiResponse<Group> = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(result.message || t('groups.updateError'));
|
||||
return null;
|
||||
}
|
||||
|
||||
triggerRefresh();
|
||||
return result.data || null;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update group servers');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Delete a group
|
||||
const deleteGroup = async (id: string) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(`/api/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;
|
||||
}
|
||||
|
||||
triggerRefresh();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete group');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Add server to a group
|
||||
const addServerToGroup = async (groupId: string, serverName: string) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(`/api/groups/${groupId}/servers`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || ''
|
||||
},
|
||||
body: JSON.stringify({ serverName }),
|
||||
});
|
||||
|
||||
const result: ApiResponse<Group> = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(result.message || t('groups.serverAddError'));
|
||||
return null;
|
||||
}
|
||||
|
||||
triggerRefresh();
|
||||
return result.data || null;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to add server to group');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Remove server from group
|
||||
const removeServerFromGroup = async (groupId: string, serverName: string) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(`/api/groups/${groupId}/servers/${serverName}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
});
|
||||
|
||||
const result: ApiResponse<Group> = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(result.message || t('groups.serverRemoveError'));
|
||||
return null;
|
||||
}
|
||||
|
||||
triggerRefresh();
|
||||
return result.data || null;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to remove server from group');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch groups when the component mounts or refreshKey changes
|
||||
useEffect(() => {
|
||||
fetchGroups();
|
||||
}, [fetchGroups, refreshKey]);
|
||||
|
||||
return {
|
||||
groups,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
triggerRefresh,
|
||||
createGroup,
|
||||
updateGroup,
|
||||
updateGroupServers,
|
||||
deleteGroup,
|
||||
addServerToGroup,
|
||||
removeServerFromGroup
|
||||
};
|
||||
};
|
||||
410
frontend/src/hooks/useMarketData.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MarketServer, ApiResponse } from '@/types';
|
||||
|
||||
export const useMarketData = () => {
|
||||
const { t } = useTranslation();
|
||||
const [servers, setServers] = useState<MarketServer[]>([]);
|
||||
const [allServers, setAllServers] = useState<MarketServer[]>([]);
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('');
|
||||
const [selectedTag, setSelectedTag] = useState<string>('');
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentServer, setCurrentServer] = useState<MarketServer | null>(null);
|
||||
const [installedServers, setInstalledServers] = useState<string[]>([]);
|
||||
|
||||
// Pagination states
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [serversPerPage, setServersPerPage] = useState(9);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
// Fetch all market servers
|
||||
const fetchMarketServers = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/market/servers', {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
// Apply pagination to the fetched data
|
||||
applyPagination(data.data, currentPage);
|
||||
} else {
|
||||
console.error('Invalid market servers data format:', data);
|
||||
setError(t('market.fetchError'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching market servers:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
// Apply pagination to data
|
||||
const applyPagination = useCallback((data: MarketServer[], page: number, itemsPerPage = serversPerPage) => {
|
||||
const totalItems = data.length;
|
||||
const calculatedTotalPages = Math.ceil(totalItems / itemsPerPage);
|
||||
setTotalPages(calculatedTotalPages);
|
||||
|
||||
// Ensure current page is valid
|
||||
const validPage = Math.max(1, Math.min(page, calculatedTotalPages));
|
||||
if (validPage !== page) {
|
||||
setCurrentPage(validPage);
|
||||
}
|
||||
|
||||
const startIndex = (validPage - 1) * itemsPerPage;
|
||||
const paginatedServers = data.slice(startIndex, startIndex + itemsPerPage);
|
||||
setServers(paginatedServers);
|
||||
}, [serversPerPage]);
|
||||
|
||||
// Change page
|
||||
const changePage = useCallback((page: number) => {
|
||||
setCurrentPage(page);
|
||||
applyPagination(allServers, page, serversPerPage);
|
||||
}, [allServers, applyPagination, serversPerPage]);
|
||||
|
||||
// Fetch all categories
|
||||
const fetchCategories = useCallback(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/market/categories', {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<string[]> = await response.json();
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setCategories(data.data);
|
||||
} else {
|
||||
console.error('Invalid categories data format:', data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching categories:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch all tags
|
||||
const fetchTags = useCallback(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/market/tags', {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<string[]> = await response.json();
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setTags(data.data);
|
||||
} else {
|
||||
console.error('Invalid tags data format:', data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching tags:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch server by name
|
||||
const fetchServerByName = useCallback(async (name: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(`/api/market/servers/${name}`, {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<MarketServer> = await response.json();
|
||||
|
||||
if (data && data.success && data.data) {
|
||||
setCurrentServer(data.data);
|
||||
return data.data;
|
||||
} else {
|
||||
console.error('Invalid server data format:', data);
|
||||
setError(t('market.serverNotFound'));
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error fetching server ${name}:`, err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
// Search servers by query
|
||||
const searchServers = useCallback(async (query: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setSearchQuery(query);
|
||||
|
||||
if (!query.trim()) {
|
||||
// Fetch fresh data from server instead of just applying pagination
|
||||
fetchMarketServers();
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(`/api/market/servers/search?query=${encodeURIComponent(query)}`, {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
applyPagination(data.data, 1);
|
||||
} else {
|
||||
console.error('Invalid search results format:', data);
|
||||
setError(t('market.searchError'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error searching servers:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t, allServers, applyPagination, fetchMarketServers]);
|
||||
|
||||
// Filter servers by category
|
||||
const filterByCategory = useCallback(async (category: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setSelectedCategory(category);
|
||||
setSelectedTag(''); // Reset tag filter when filtering by category
|
||||
|
||||
if (!category) {
|
||||
fetchMarketServers();
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(`/api/market/categories/${encodeURIComponent(category)}`, {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
applyPagination(data.data, 1);
|
||||
} else {
|
||||
console.error('Invalid category filter results format:', data);
|
||||
setError(t('market.filterError'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error filtering servers by category:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t, fetchMarketServers, applyPagination]);
|
||||
|
||||
// Filter servers by tag
|
||||
const filterByTag = useCallback(async (tag: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setSelectedTag(tag);
|
||||
setSelectedCategory(''); // Reset category filter when filtering by tag
|
||||
|
||||
if (!tag) {
|
||||
fetchMarketServers();
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(`/api/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();
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setAllServers(data.data);
|
||||
setCurrentPage(1);
|
||||
applyPagination(data.data, 1);
|
||||
} else {
|
||||
console.error('Invalid tag filter results format:', data);
|
||||
setError(t('market.tagFilterError'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error filtering servers by tag:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t, fetchMarketServers, applyPagination]);
|
||||
|
||||
// Fetch installed servers
|
||||
const fetchInstalledServers = useCallback(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/servers', {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
// Extract server names
|
||||
const installedServerNames = data.data.map((server: any) => server.name);
|
||||
setInstalledServers(installedServerNames);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching installed servers:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Check if a server is already installed
|
||||
const isServerInstalled = useCallback((serverName: string) => {
|
||||
return installedServers.includes(serverName);
|
||||
}, [installedServers]);
|
||||
|
||||
// Install server to the local environment
|
||||
const installServer = useCallback(async (server: MarketServer) => {
|
||||
try {
|
||||
const installType = server.installations?.npm ? 'npm' : Object.keys(server.installations || {}).length > 0 ? Object.keys(server.installations)[0] : null;
|
||||
|
||||
if (!installType || !server.installations?.[installType]) {
|
||||
setError(t('market.noInstallationMethod'));
|
||||
return false;
|
||||
}
|
||||
|
||||
const installation = server.installations[installType];
|
||||
|
||||
// Prepare server configuration
|
||||
const serverConfig = {
|
||||
name: server.name,
|
||||
config: {
|
||||
command: installation.command,
|
||||
args: installation.args,
|
||||
env: installation.env || {}
|
||||
}
|
||||
};
|
||||
|
||||
// Call the createServer API
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/servers', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || ''
|
||||
},
|
||||
body: JSON.stringify(serverConfig),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || `Status: ${response.status}`);
|
||||
}
|
||||
|
||||
// Update installed servers list after successful installation
|
||||
await fetchInstalledServers();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error installing server:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
}
|
||||
}, [t, fetchInstalledServers]);
|
||||
|
||||
// Change servers per page
|
||||
const changeServersPerPage = useCallback((perPage: number) => {
|
||||
setServersPerPage(perPage);
|
||||
setCurrentPage(1);
|
||||
applyPagination(allServers, 1, perPage);
|
||||
}, [allServers, applyPagination]);
|
||||
|
||||
// Load initial data
|
||||
useEffect(() => {
|
||||
fetchMarketServers();
|
||||
fetchCategories();
|
||||
fetchTags();
|
||||
fetchInstalledServers();
|
||||
}, [fetchMarketServers, fetchCategories, fetchTags, fetchInstalledServers]);
|
||||
|
||||
return {
|
||||
servers,
|
||||
allServers,
|
||||
categories,
|
||||
tags,
|
||||
selectedCategory,
|
||||
selectedTag,
|
||||
searchQuery,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
currentServer,
|
||||
fetchMarketServers,
|
||||
fetchServerByName,
|
||||
searchServers,
|
||||
filterByCategory,
|
||||
filterByTag,
|
||||
installServer,
|
||||
// Pagination properties and methods
|
||||
currentPage,
|
||||
totalPages,
|
||||
serversPerPage,
|
||||
changePage,
|
||||
changeServersPerPage,
|
||||
// Installed servers methods
|
||||
isServerInstalled
|
||||
};
|
||||
};
|
||||
@@ -2,16 +2,16 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server, ApiResponse } from '@/types';
|
||||
|
||||
// 配置选项
|
||||
// Configuration options
|
||||
const CONFIG = {
|
||||
// 初始化启动阶段的配置
|
||||
// Initialization phase configuration
|
||||
startup: {
|
||||
maxAttempts: 60, // 初始化阶段最大尝试次数
|
||||
pollingInterval: 3000 // 初始阶段轮询间隔 (3秒)
|
||||
maxAttempts: 60, // Maximum number of attempts during initialization
|
||||
pollingInterval: 3000 // Polling interval during initialization (3 seconds)
|
||||
},
|
||||
// 正常运行阶段的配置
|
||||
// Normal operation phase configuration
|
||||
normal: {
|
||||
pollingInterval: 10000 // 正常运行时的轮询间隔 (10秒)
|
||||
pollingInterval: 10000 // Polling interval during normal operation (10 seconds)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -23,12 +23,12 @@ export const useServerData = () => {
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const [fetchAttempts, setFetchAttempts] = useState(0);
|
||||
|
||||
// 轮询定时器引用
|
||||
// Timer reference for polling
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
// 保存当前尝试次数,避免依赖循环
|
||||
// Track current attempt count to avoid dependency cycles
|
||||
const attemptsRef = useRef<number>(0);
|
||||
|
||||
// 清理定时器
|
||||
// Clear the timer
|
||||
const clearTimer = () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
@@ -36,9 +36,9 @@ export const useServerData = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 开始正常轮询
|
||||
// Start normal polling
|
||||
const startNormalPolling = useCallback(() => {
|
||||
// 确保没有其他定时器在运行
|
||||
// Ensure no other timers are running
|
||||
clearTimer();
|
||||
|
||||
const fetchServers = async () => {
|
||||
@@ -60,12 +60,12 @@ export const useServerData = () => {
|
||||
setServers([]);
|
||||
}
|
||||
|
||||
// 重置错误状态
|
||||
// Reset error state
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching servers during normal polling:', err);
|
||||
|
||||
// 使用友好的错误消息
|
||||
// Use friendly error message
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else if (err instanceof TypeError && (
|
||||
@@ -79,21 +79,21 @@ export const useServerData = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 立即执行一次
|
||||
// Execute immediately
|
||||
fetchServers();
|
||||
|
||||
// 设置定期轮询
|
||||
// Set up regular polling
|
||||
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
// 重置尝试计数
|
||||
// Reset attempt count
|
||||
if (refreshKey > 0) {
|
||||
attemptsRef.current = 0;
|
||||
setFetchAttempts(0);
|
||||
}
|
||||
|
||||
// 初始化加载阶段的请求函数
|
||||
// Initialization phase request function
|
||||
const fetchInitialData = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
@@ -104,51 +104,51 @@ export const useServerData = () => {
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
// 处理API响应中的包装对象,提取data字段
|
||||
// Handle API response wrapper object, extract data field
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
setIsInitialLoading(false);
|
||||
// 初始化成功,开始正常轮询
|
||||
// Initialization successful, start normal polling
|
||||
startNormalPolling();
|
||||
return true;
|
||||
} else if (data && Array.isArray(data)) {
|
||||
// 兼容性处理,如果API直接返回数组
|
||||
// Compatibility handling, if API directly returns array
|
||||
setServers(data);
|
||||
setIsInitialLoading(false);
|
||||
// 初始化成功,开始正常轮询
|
||||
// Initialization successful, start normal polling
|
||||
startNormalPolling();
|
||||
return true;
|
||||
} else {
|
||||
// 如果数据格式不符合预期,设置为空数组
|
||||
// If data format is not as expected, set to empty array
|
||||
console.error('Invalid server data format:', data);
|
||||
setServers([]);
|
||||
setIsInitialLoading(false);
|
||||
// 初始化成功但数据为空,开始正常轮询
|
||||
// Initialization successful but data is empty, start normal polling
|
||||
startNormalPolling();
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
// 增加尝试次数计数,使用 ref 避免触发 effect 重新运行
|
||||
// Increment attempt count, use ref to avoid triggering effect rerun
|
||||
attemptsRef.current += 1;
|
||||
console.error(`Initial loading attempt ${attemptsRef.current} failed:`, err);
|
||||
|
||||
// 更新状态用于显示
|
||||
// Update state for display
|
||||
setFetchAttempts(attemptsRef.current);
|
||||
|
||||
// 设置适当的错误消息
|
||||
// Set appropriate error message
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else {
|
||||
setError(t('errors.initialStartup'));
|
||||
}
|
||||
|
||||
// 如果已超过最大尝试次数,放弃初始化并切换到正常轮询
|
||||
// If maximum attempt count is exceeded, give up initialization and switch to normal polling
|
||||
if (attemptsRef.current >= CONFIG.startup.maxAttempts) {
|
||||
console.log('Maximum startup attempts reached, switching to normal polling');
|
||||
setIsInitialLoading(false);
|
||||
// 清除初始化的轮询
|
||||
// Clear initialization polling
|
||||
clearTimer();
|
||||
// 切换到正常轮询模式
|
||||
// Switch to normal polling mode
|
||||
startNormalPolling();
|
||||
}
|
||||
|
||||
@@ -156,45 +156,45 @@ export const useServerData = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 组件挂载时,根据当前状态设置适当的轮询
|
||||
// On component mount, set appropriate polling based on current state
|
||||
if (isInitialLoading) {
|
||||
// 确保没有其他定时器在运行
|
||||
// Ensure no other timers are running
|
||||
clearTimer();
|
||||
|
||||
// 立即执行一次初始请求
|
||||
// Execute initial request immediately
|
||||
fetchInitialData();
|
||||
|
||||
// 设置初始阶段的轮询间隔
|
||||
// Set polling interval for initialization phase
|
||||
intervalRef.current = setInterval(fetchInitialData, CONFIG.startup.pollingInterval);
|
||||
console.log(`Started initial polling with interval: ${CONFIG.startup.pollingInterval}ms`);
|
||||
} else {
|
||||
// 已经初始化完成,开始正常轮询
|
||||
// Initialization completed, start normal polling
|
||||
startNormalPolling();
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
// Cleanup function
|
||||
return () => {
|
||||
clearTimer();
|
||||
};
|
||||
}, [refreshKey, t, isInitialLoading, startNormalPolling]);
|
||||
|
||||
// 手动触发刷新
|
||||
// Manually trigger refresh
|
||||
const triggerRefresh = () => {
|
||||
// 清除当前的定时器
|
||||
// Clear current timer
|
||||
clearTimer();
|
||||
|
||||
// 如果在初始化阶段,重置初始化状态
|
||||
// If in initialization phase, reset initialization state
|
||||
if (isInitialLoading) {
|
||||
setIsInitialLoading(true);
|
||||
attemptsRef.current = 0;
|
||||
setFetchAttempts(0);
|
||||
}
|
||||
|
||||
// refreshKey 的改变会触发 useEffect 再次运行
|
||||
// Change in refreshKey will trigger useEffect to run again
|
||||
setRefreshKey(prevKey => prevKey + 1);
|
||||
};
|
||||
|
||||
// 服务器相关操作
|
||||
// Server related operations
|
||||
const handleServerAdd = () => {
|
||||
setRefreshKey(prevKey => prevKey + 1);
|
||||
};
|
||||
|
||||
224
frontend/src/hooks/useSettingsData.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ApiResponse } from '@/types';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
|
||||
// Define types for the settings data
|
||||
interface RoutingConfig {
|
||||
enableGlobalRoute: boolean;
|
||||
enableGroupNameRoute: boolean;
|
||||
enableBearerAuth: boolean;
|
||||
bearerAuthKey: string;
|
||||
}
|
||||
|
||||
interface InstallConfig {
|
||||
pythonIndexUrl: string;
|
||||
npmRegistry: string;
|
||||
}
|
||||
|
||||
interface SystemSettings {
|
||||
systemConfig?: {
|
||||
routing?: RoutingConfig;
|
||||
install?: InstallConfig;
|
||||
};
|
||||
}
|
||||
|
||||
interface TempRoutingConfig {
|
||||
bearerAuthKey: string;
|
||||
}
|
||||
|
||||
export const useSettingsData = () => {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [routingConfig, setRoutingConfig] = useState<RoutingConfig>({
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
});
|
||||
|
||||
const [tempRoutingConfig, setTempRoutingConfig] = useState<TempRoutingConfig>({
|
||||
bearerAuthKey: '',
|
||||
});
|
||||
|
||||
const [installConfig, setInstallConfig] = useState<InstallConfig>({
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
// Trigger a refresh of the settings data
|
||||
const triggerRefresh = useCallback(() => {
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
// Fetch current settings
|
||||
const fetchSettings = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/settings', {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<SystemSettings> = await response.json();
|
||||
|
||||
if (data.success && data.data?.systemConfig?.routing) {
|
||||
setRoutingConfig({
|
||||
enableGlobalRoute: data.data.systemConfig.routing.enableGlobalRoute ?? true,
|
||||
enableGroupNameRoute: data.data.systemConfig.routing.enableGroupNameRoute ?? true,
|
||||
enableBearerAuth: data.data.systemConfig.routing.enableBearerAuth ?? false,
|
||||
bearerAuthKey: data.data.systemConfig.routing.bearerAuthKey || '',
|
||||
});
|
||||
}
|
||||
if (data.success && data.data?.systemConfig?.install) {
|
||||
setInstallConfig({
|
||||
pythonIndexUrl: data.data.systemConfig.install.pythonIndexUrl || '',
|
||||
npmRegistry: data.data.systemConfig.install.npmRegistry || '',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to fetch settings');
|
||||
showToast(t('errors.failedToFetchSettings'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t, showToast]);
|
||||
|
||||
// Update routing configuration
|
||||
const updateRoutingConfig = async <T extends keyof RoutingConfig>(
|
||||
key: T,
|
||||
value: RoutingConfig[T],
|
||||
) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/system-config', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
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,
|
||||
[key]: value,
|
||||
});
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
showToast(t('errors.failedToUpdateRouteConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update routing config:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to update routing config');
|
||||
showToast(t('errors.failedToUpdateRouteConfig'));
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update install configuration
|
||||
const updateInstallConfig = async (key: keyof InstallConfig, value: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/system-config', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
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,
|
||||
[key]: value,
|
||||
});
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
showToast(t('errors.failedToUpdateSystemConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update system config:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to update system config');
|
||||
showToast(t('errors.failedToUpdateSystemConfig'));
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch settings when the component mounts or refreshKey changes
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, [fetchSettings, refreshKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (routingConfig) {
|
||||
setTempRoutingConfig({
|
||||
bearerAuthKey: routingConfig.bearerAuthKey,
|
||||
});
|
||||
}
|
||||
}, [routingConfig]);
|
||||
|
||||
return {
|
||||
routingConfig,
|
||||
tempRoutingConfig,
|
||||
setTempRoutingConfig,
|
||||
installConfig,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
triggerRefresh,
|
||||
fetchSettings,
|
||||
updateRoutingConfig,
|
||||
updateInstallConfig,
|
||||
};
|
||||
};
|
||||
@@ -32,8 +32,8 @@ i18n
|
||||
},
|
||||
|
||||
detection: {
|
||||
// Order of detection; we put 'navigator' first to use browser language
|
||||
order: ['navigator', 'localStorage', 'cookie', 'htmlTag'],
|
||||
// Order of detection; prioritize localStorage to respect user language choice
|
||||
order: ['localStorage', 'cookie', 'htmlTag', 'navigator'],
|
||||
// Cache the language in localStorage
|
||||
caches: ['localStorage', 'cookie'],
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Use standard Tailwind directives */
|
||||
/* Use project's custom Tailwind import */
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Add some custom styles to verify CSS is working correctly */
|
||||
@@ -11,6 +11,52 @@ body {
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Dark mode override styles - these will apply when dark class is on html element */
|
||||
.dark body {
|
||||
background-color: #111827;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.dark .bg-white {
|
||||
background-color: #1f2937 !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-900 {
|
||||
color: #f9fafb !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-800 {
|
||||
color: #f3f4f6 !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-700 {
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-600 {
|
||||
color: #d1d5db !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-500 {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
.dark .border-gray-300 {
|
||||
border-color: #4b5563 !important;
|
||||
}
|
||||
|
||||
.dark .bg-gray-100 {
|
||||
background-color: #374151 !important;
|
||||
}
|
||||
|
||||
.dark .bg-gray-50 {
|
||||
background-color: #1f2937 !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;
|
||||
}
|
||||
|
||||
.bg-custom-blue {
|
||||
background-color: #4a90e2;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ const MainLayout: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-gray-100">
|
||||
<div className="flex flex-col h-screen bg-gray-100 dark:bg-gray-900">
|
||||
{/* 顶部导航 */}
|
||||
<Header onToggleSidebar={toggleSidebar} />
|
||||
|
||||
|
||||
@@ -9,7 +9,28 @@
|
||||
"profile": "Profile",
|
||||
"changePassword": "Change Password",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"welcomeUser": "Welcome, {{username}}"
|
||||
"welcomeUser": "Welcome, {{username}}",
|
||||
"name": "MCP Hub"
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"versionInfo": "MCP Hub Version: {{version}}",
|
||||
"newVersion": "New version available!",
|
||||
"currentVersion": "Current version",
|
||||
"newVersionAvailable": "New version {{version}} is available",
|
||||
"viewOnGitHub": "View on GitHub",
|
||||
"checkForUpdates": "Check for Updates",
|
||||
"checking": "Checking for updates..."
|
||||
},
|
||||
"profile": {
|
||||
"viewProfile": "View profile",
|
||||
"userCenter": "User Center"
|
||||
},
|
||||
"theme": {
|
||||
"title": "Theme",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
@@ -26,7 +47,9 @@
|
||||
"passwordsNotMatch": "New password and confirmation do not match",
|
||||
"changePasswordSuccess": "Password changed successfully",
|
||||
"changePasswordError": "Failed to change password",
|
||||
"changePassword": "Change Password"
|
||||
"changePassword": "Change Password",
|
||||
"passwordChanged": "Password changed successfully",
|
||||
"passwordChangeError": "Failed to change password"
|
||||
},
|
||||
"server": {
|
||||
"addServer": "Add Server",
|
||||
@@ -34,12 +57,13 @@
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"confirmDelete": "Are you sure you want to delete this server?",
|
||||
"deleteWarning": "Deleting server '{{name}}' will remove it and all its data. This action cannot be undone.",
|
||||
"status": "Status",
|
||||
"tools": "Tools",
|
||||
"name": "Server Name",
|
||||
"url": "Server URL",
|
||||
"apiKey": "API Key",
|
||||
"save": "Save Changes",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"invalidConfig": "Could not find configuration data for {{serverName}}",
|
||||
"addError": "Failed to add server",
|
||||
@@ -60,7 +84,13 @@
|
||||
"toggleError": "Failed to toggle server {{serverName}}",
|
||||
"alreadyExists": "Server {{serverName}} already exists",
|
||||
"invalidData": "Invalid server data provided",
|
||||
"notFound": "Server {{serverName}} not found"
|
||||
"notFound": "Server {{serverName}} not found",
|
||||
"namePlaceholder": "Enter server name",
|
||||
"urlPlaceholder": "Enter server URL",
|
||||
"commandPlaceholder": "Enter command",
|
||||
"argumentsPlaceholder": "Enter arguments",
|
||||
"errorDetails": "Error Details",
|
||||
"viewErrorDetails": "View error details"
|
||||
},
|
||||
"status": {
|
||||
"online": "Online",
|
||||
@@ -74,19 +104,32 @@
|
||||
"serverAdd": "Failed to add server. Please check the server status",
|
||||
"serverUpdate": "Failed to edit server {{serverName}}. Please check the server status",
|
||||
"serverFetch": "Failed to retrieve server data. Please try again later",
|
||||
"initialStartup": "The server might be starting up. Please wait a moment as this process can take some time on first launch..."
|
||||
"initialStartup": "The server might be starting up. Please wait a moment as this process can take some time on first launch...",
|
||||
"serverInstall": "Failed to install server",
|
||||
"failedToFetchSettings": "Failed to fetch settings",
|
||||
"failedToUpdateRouteConfig": "Failed to update route configuration"
|
||||
},
|
||||
"common": {
|
||||
"processing": "Processing...",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"refresh": "Refresh"
|
||||
"refresh": "Refresh",
|
||||
"create": "Create",
|
||||
"submitting": "Submitting...",
|
||||
"delete": "Delete",
|
||||
"copy": "Copy",
|
||||
"copySuccess": "Copied to clipboard",
|
||||
"copyFailed": "Copy failed",
|
||||
"close": "Close"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"servers": "Servers",
|
||||
"groups": "Groups",
|
||||
"settings": "Settings",
|
||||
"changePassword": "Change Password"
|
||||
"changePassword": "Change Password",
|
||||
"market": "Market",
|
||||
"logs": "Logs"
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
@@ -100,9 +143,123 @@
|
||||
"servers": {
|
||||
"title": "Servers Management"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Group Management"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"language": "Language"
|
||||
"language": "Language",
|
||||
"account": "Account Settings",
|
||||
"password": "Change Password",
|
||||
"appearance": "Appearance",
|
||||
"routeConfig": "Security Configuration",
|
||||
"installConfig": "Installation Configuration"
|
||||
},
|
||||
"market": {
|
||||
"title": "Server Market - (Data from mcpm.sh)"
|
||||
},
|
||||
"logs": {
|
||||
"title": "System Logs"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"filters": "Filters",
|
||||
"search": "Search logs...",
|
||||
"autoScroll": "Auto-scroll",
|
||||
"clearLogs": "Clear logs",
|
||||
"loading": "Loading logs...",
|
||||
"noLogs": "No logs available.",
|
||||
"noMatch": "No logs match the current filters.",
|
||||
"mainProcess": "Main Process",
|
||||
"childProcess": "Child Process",
|
||||
"main": "Main",
|
||||
"child": "Child"
|
||||
},
|
||||
"groups": {
|
||||
"add": "Add",
|
||||
"addNew": "Add New Group",
|
||||
"edit": "Edit Group",
|
||||
"delete": "Delete",
|
||||
"confirmDelete": "Are you sure you want to delete this group?",
|
||||
"deleteWarning": "Deleting group '{{name}}' will remove it and all its server associations. This action cannot be undone.",
|
||||
"name": "Group Name",
|
||||
"namePlaceholder": "Enter group name",
|
||||
"nameRequired": "Group name is required",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Enter group description (optional)",
|
||||
"createError": "Failed to create group",
|
||||
"updateError": "Failed to update group",
|
||||
"deleteError": "Failed to delete group",
|
||||
"serverAddError": "Failed to add server to group",
|
||||
"serverRemoveError": "Failed to remove server from group",
|
||||
"addServer": "Add Server to Group",
|
||||
"selectServer": "Select a server to add",
|
||||
"servers": "Servers in Group",
|
||||
"remove": "Remove",
|
||||
"noGroups": "No groups available. Create a new group to get started.",
|
||||
"noServers": "No servers in this group.",
|
||||
"noServerOptions": "No servers available",
|
||||
"serverCount": "{{count}} Servers"
|
||||
},
|
||||
"market": {
|
||||
"title": "Server Market",
|
||||
"official": "Official",
|
||||
"by": "By",
|
||||
"unknown": "Unknown",
|
||||
"tools": "tools",
|
||||
"search": "Search",
|
||||
"searchPlaceholder": "Search for servers by name, category, or tags",
|
||||
"clearFilters": "Clear",
|
||||
"clearCategoryFilter": "",
|
||||
"clearTagFilter": "",
|
||||
"categories": "Categories",
|
||||
"tags": "Tags",
|
||||
"showTags": "Show tags",
|
||||
"hideTags": "Hide tags",
|
||||
"moreTags": "",
|
||||
"noServers": "No servers found matching your search",
|
||||
"backToList": "Back to list",
|
||||
"install": "Install",
|
||||
"installing": "Installing...",
|
||||
"installed": "Installed",
|
||||
"installServer": "Install Server: {{name}}",
|
||||
"installSuccess": "Server {{serverName}} installed successfully",
|
||||
"author": "Author",
|
||||
"license": "License",
|
||||
"repository": "Repository",
|
||||
"examples": "Examples",
|
||||
"arguments": "Arguments",
|
||||
"argumentName": "Name",
|
||||
"description": "Description",
|
||||
"required": "Required",
|
||||
"example": "Example",
|
||||
"viewSchema": "View schema",
|
||||
"fetchError": "Error fetching market servers",
|
||||
"serverNotFound": "Server not found",
|
||||
"searchError": "Error searching servers",
|
||||
"filterError": "Error filtering servers by category",
|
||||
"tagFilterError": "Error filtering servers by tag",
|
||||
"noInstallationMethod": "No installation method available for this server",
|
||||
"showing": "Showing {{from}}-{{to}} of {{total}} servers",
|
||||
"perPage": "Per page"
|
||||
},
|
||||
"settings": {
|
||||
"enableGlobalRoute": "Enable Global Route",
|
||||
"enableGlobalRouteDescription": "Allow connections to /sse endpoint without specifying a group ID",
|
||||
"enableGroupNameRoute": "Enable Group Name Route",
|
||||
"enableGroupNameRouteDescription": "Allow connections to /sse endpoint using group names instead of just group IDs",
|
||||
"enableBearerAuth": "Enable Bearer Authentication",
|
||||
"enableBearerAuthDescription": "Require bearer token authentication for MCP requests",
|
||||
"bearerAuthKey": "Bearer Authentication Key",
|
||||
"bearerAuthKeyDescription": "The authentication key that will be required in the Bearer token",
|
||||
"bearerAuthKeyPlaceholder": "Enter bearer authentication key",
|
||||
"pythonIndexUrl": "Python Package Repository URL",
|
||||
"pythonIndexUrlDescription": "Set UV_DEFAULT_INDEX environment variable for Python package installation",
|
||||
"pythonIndexUrlPlaceholder": "e.g. https://pypi.org/simple",
|
||||
"npmRegistry": "NPM Registry URL",
|
||||
"npmRegistryDescription": "Set npm_config_registry environment variable for NPM package installation",
|
||||
"npmRegistryPlaceholder": "e.g. https://registry.npmjs.org/",
|
||||
"installConfig": "Installation Configuration",
|
||||
"systemConfigUpdated": "System configuration updated successfully"
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,28 @@
|
||||
"profile": "个人资料",
|
||||
"changePassword": "修改密码",
|
||||
"toggleSidebar": "切换侧边栏",
|
||||
"welcomeUser": "欢迎, {{username}}"
|
||||
"welcomeUser": "欢迎, {{username}}",
|
||||
"name": "MCP Hub"
|
||||
},
|
||||
"about": {
|
||||
"title": "关于",
|
||||
"versionInfo": "MCP Hub 版本: {{version}}",
|
||||
"newVersion": "有新版本可用!",
|
||||
"currentVersion": "当前版本",
|
||||
"newVersionAvailable": "新版本 {{version}} 已发布",
|
||||
"viewOnGitHub": "在 GitHub 上查看",
|
||||
"checkForUpdates": "检查更新",
|
||||
"checking": "检查更新中..."
|
||||
},
|
||||
"profile": {
|
||||
"viewProfile": "查看个人中心",
|
||||
"userCenter": "个人中心"
|
||||
},
|
||||
"theme": {
|
||||
"title": "主题",
|
||||
"light": "浅色",
|
||||
"dark": "深色",
|
||||
"system": "系统"
|
||||
},
|
||||
"auth": {
|
||||
"login": "登录",
|
||||
@@ -26,7 +47,9 @@
|
||||
"passwordsNotMatch": "新密码与确认密码不一致",
|
||||
"changePasswordSuccess": "密码修改成功",
|
||||
"changePasswordError": "修改密码失败",
|
||||
"changePassword": "修改密码"
|
||||
"changePassword": "修改密码",
|
||||
"passwordChanged": "密码修改成功",
|
||||
"passwordChangeError": "修改密码失败"
|
||||
},
|
||||
"server": {
|
||||
"addServer": "添加服务器",
|
||||
@@ -34,12 +57,13 @@
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"confirmDelete": "您确定要删除此服务器吗?",
|
||||
"deleteWarning": "删除服务器 '{{name}}' 将会移除该服务器及其所有数据。此操作无法撤销。",
|
||||
"status": "状态",
|
||||
"tools": "工具",
|
||||
"name": "服务器名称",
|
||||
"url": "服务器 URL",
|
||||
"apiKey": "API 密钥",
|
||||
"save": "保存更改",
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"addError": "添加服务器失败",
|
||||
"editError": "编辑服务器 {{serverName}} 失败",
|
||||
@@ -60,7 +84,13 @@
|
||||
"toggleError": "切换服务器 {{serverName}} 状态失败",
|
||||
"alreadyExists": "服务器 {{serverName}} 已经存在",
|
||||
"invalidData": "提供的服务器数据无效",
|
||||
"notFound": "找不到服务器 {{serverName}}"
|
||||
"notFound": "找不到服务器 {{serverName}}",
|
||||
"namePlaceholder": "请输入服务器名称",
|
||||
"urlPlaceholder": "请输入服务器URL",
|
||||
"commandPlaceholder": "请输入命令",
|
||||
"argumentsPlaceholder": "请输入参数",
|
||||
"errorDetails": "错误详情",
|
||||
"viewErrorDetails": "查看错误详情"
|
||||
},
|
||||
"status": {
|
||||
"online": "在线",
|
||||
@@ -74,19 +104,33 @@
|
||||
"serverAdd": "添加服务器失败,请检查服务器状态",
|
||||
"serverUpdate": "编辑服务器 {{serverName}} 失败,请检查服务器状态",
|
||||
"serverFetch": "获取服务器数据失败,请稍后重试",
|
||||
"initialStartup": "服务器可能正在启动中。首次启动可能需要一些时间,请耐心等候..."
|
||||
"initialStartup": "服务器可能正在启动中。首次启动可能需要一些时间,请耐心等候...",
|
||||
"serverInstall": "安装服务器失败",
|
||||
"failedToFetchSettings": "获取设置失败",
|
||||
"failedToUpdateSystemConfig": "更新系统配置失败",
|
||||
"failedToUpdateRouteConfig": "更新路由配置失败"
|
||||
},
|
||||
"common": {
|
||||
"processing": "处理中...",
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"refresh": "刷新"
|
||||
"refresh": "刷新",
|
||||
"create": "创建",
|
||||
"submitting": "提交中...",
|
||||
"delete": "删除",
|
||||
"copy": "复制",
|
||||
"copySuccess": "已复制到剪贴板",
|
||||
"copyFailed": "复制失败",
|
||||
"close": "关闭"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "仪表盘",
|
||||
"servers": "服务器",
|
||||
"settings": "设置",
|
||||
"changePassword": "修改密码"
|
||||
"changePassword": "修改密码",
|
||||
"groups": "分组",
|
||||
"market": "市场",
|
||||
"logs": "日志"
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
@@ -102,7 +146,121 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
"language": "语言"
|
||||
"language": "语言",
|
||||
"account": "账户设置",
|
||||
"password": "修改密码",
|
||||
"appearance": "外观",
|
||||
"routeConfig": "安全配置",
|
||||
"installConfig": "安装配置"
|
||||
},
|
||||
"groups": {
|
||||
"title": "分组管理"
|
||||
},
|
||||
"market": {
|
||||
"title": "服务器市场 - (数据来源于 mcpm.sh)"
|
||||
},
|
||||
"logs": {
|
||||
"title": "系统日志"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"filters": "筛选",
|
||||
"search": "搜索日志...",
|
||||
"autoScroll": "自动滚动",
|
||||
"clearLogs": "清除日志",
|
||||
"loading": "加载日志中...",
|
||||
"noLogs": "暂无日志。",
|
||||
"noMatch": "没有匹配当前筛选条件的日志。",
|
||||
"mainProcess": "主进程",
|
||||
"childProcess": "子进程",
|
||||
"main": "主",
|
||||
"child": "子"
|
||||
},
|
||||
"groups": {
|
||||
"add": "添加",
|
||||
"addNew": "添加新分组",
|
||||
"edit": "编辑分组",
|
||||
"delete": "删除",
|
||||
"confirmDelete": "您确定要删除此分组吗?",
|
||||
"deleteWarning": "删除分组 '{{name}}' 将会移除该分组及其所有服务器关联。此操作无法撤销。",
|
||||
"name": "分组名称",
|
||||
"namePlaceholder": "请输入分组名称",
|
||||
"nameRequired": "分组名称不能为空",
|
||||
"description": "描述",
|
||||
"descriptionPlaceholder": "请输入分组描述(可选)",
|
||||
"createError": "创建分组失败",
|
||||
"updateError": "更新分组失败",
|
||||
"deleteError": "删除分组失败",
|
||||
"serverAddError": "向分组添加服务器失败",
|
||||
"serverRemoveError": "从分组移除服务器失败",
|
||||
"addServer": "添加服务器到分组",
|
||||
"selectServer": "选择要添加的服务器",
|
||||
"servers": "分组中的服务器",
|
||||
"remove": "移除",
|
||||
"noGroups": "暂无可用分组。创建一个新分组以开始使用。",
|
||||
"noServers": "此分组中没有服务器。",
|
||||
"noServerOptions": "没有可用的服务器",
|
||||
"serverCount": "{{count}} 台服务器"
|
||||
},
|
||||
"market": {
|
||||
"title": "服务器市场",
|
||||
"official": "官方",
|
||||
"by": "作者",
|
||||
"unknown": "未知",
|
||||
"tools": "工具",
|
||||
"search": "搜索",
|
||||
"searchPlaceholder": "搜索服务器名称、分类或标签",
|
||||
"clearFilters": "清除",
|
||||
"clearCategoryFilter": "",
|
||||
"clearTagFilter": "",
|
||||
"categories": "分类",
|
||||
"tags": "标签",
|
||||
"showTags": "显示标签",
|
||||
"hideTags": "隐藏标签",
|
||||
"moreTags": "",
|
||||
"noServers": "未找到匹配的服务器",
|
||||
"backToList": "返回列表",
|
||||
"install": "安装",
|
||||
"installing": "安装中...",
|
||||
"installed": "已安装",
|
||||
"installServer": "安装服务器: {{name}}",
|
||||
"installSuccess": "服务器 {{serverName}} 安装成功",
|
||||
"author": "作者",
|
||||
"license": "许可证",
|
||||
"repository": "代码仓库",
|
||||
"examples": "示例",
|
||||
"arguments": "参数",
|
||||
"argumentName": "名称",
|
||||
"description": "描述",
|
||||
"required": "必填",
|
||||
"example": "示例",
|
||||
"viewSchema": "查看结构",
|
||||
"fetchError": "获取服务器市场数据失败",
|
||||
"serverNotFound": "未找到服务器",
|
||||
"searchError": "搜索服务器失败",
|
||||
"filterError": "按分类筛选服务器失败",
|
||||
"tagFilterError": "按标签筛选服务器失败",
|
||||
"noInstallationMethod": "该服务器没有可用的安装方法",
|
||||
"showing": "显示 {{from}}-{{to}}/{{total}} 个服务器",
|
||||
"perPage": "每页显示"
|
||||
},
|
||||
"settings": {
|
||||
"enableGlobalRoute": "启用全局路由",
|
||||
"enableGlobalRouteDescription": "允许不指定组 ID 就连接到 /sse 端点",
|
||||
"enableGroupNameRoute": "启用组名路由",
|
||||
"enableGroupNameRouteDescription": "允许使用组名而不仅仅是组 ID 连接到 /sse 端点",
|
||||
"enableBearerAuth": "启用 Bearer 认证",
|
||||
"enableBearerAuthDescription": "对 MCP 请求启用 Bearer 令牌认证",
|
||||
"bearerAuthKey": "Bearer 认证密钥",
|
||||
"bearerAuthKeyDescription": "Bearer 令牌中需要携带的认证密钥",
|
||||
"bearerAuthKeyPlaceholder": "请输入 Bearer 认证密钥",
|
||||
"pythonIndexUrl": "Python 包仓库地址",
|
||||
"pythonIndexUrlDescription": "设置 UV_DEFAULT_INDEX 环境变量,用于 Python 包安装",
|
||||
"pythonIndexUrlPlaceholder": "例如: https://mirrors.aliyun.com/pypi/simple",
|
||||
"npmRegistry": "NPM 仓库地址",
|
||||
"npmRegistryDescription": "设置 npm_config_registry 环境变量,用于 NPM 包安装",
|
||||
"npmRegistryPlaceholder": "例如: https://registry.npmmirror.com/",
|
||||
"installConfig": "安装配置",
|
||||
"systemConfigUpdated": "系统配置更新成功"
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ const DashboardPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { servers, error, setError, isLoading } = useServerData();
|
||||
|
||||
// 计算服务器统计信息
|
||||
// Calculate server statistics
|
||||
const serverStats = {
|
||||
total: servers.length,
|
||||
online: servers.filter(server => server.status === 'connected').length,
|
||||
@@ -22,7 +22,7 @@ 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);
|
||||
@@ -64,7 +64,7 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<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="flex items-center">
|
||||
<div className="p-3 rounded-full bg-blue-100 text-blue-800">
|
||||
@@ -79,7 +79,7 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 在线服务器 */}
|
||||
{/* Online servers */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-green-100 text-green-800">
|
||||
@@ -100,7 +100,7 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 离线服务器 */}
|
||||
{/* Offline servers */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-red-100 text-red-800">
|
||||
@@ -121,7 +121,7 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 连接中服务器 */}
|
||||
{/* Connecting servers */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-yellow-100 text-yellow-800">
|
||||
@@ -144,7 +144,7 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 最近活动列表 */}
|
||||
{/* Recent activity list */}
|
||||
{servers.length > 0 && !isLoading && (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">{t('pages.dashboard.recentServers')}</h2>
|
||||
|
||||
116
frontend/src/pages/GroupsPage.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Group } from '@/types';
|
||||
import { useGroupData } from '@/hooks/useGroupData';
|
||||
import { useServerData } from '@/hooks/useServerData';
|
||||
import AddGroupForm from '@/components/AddGroupForm';
|
||||
import EditGroupForm from '@/components/EditGroupForm';
|
||||
import GroupCard from '@/components/GroupCard';
|
||||
|
||||
const GroupsPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
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);
|
||||
|
||||
const handleEditClick = (group: Group) => {
|
||||
setEditingGroup(group);
|
||||
};
|
||||
|
||||
const handleEditComplete = () => {
|
||||
setEditingGroup(null);
|
||||
triggerRefresh(); // Refresh the groups list after editing
|
||||
};
|
||||
|
||||
const handleDeleteGroup = async (groupId: string) => {
|
||||
const success = await deleteGroup(groupId);
|
||||
if (!success) {
|
||||
setGroupError(t('groups.deleteError'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddGroup = () => {
|
||||
setShowAddForm(true);
|
||||
};
|
||||
|
||||
const handleAddComplete = () => {
|
||||
setShowAddForm(false);
|
||||
triggerRefresh(); // Refresh the groups list after adding
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">{t('pages.groups.title')}</h1>
|
||||
<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"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
{t('groups.add')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{groupError && (
|
||||
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6">
|
||||
<p>{groupError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupsLoading ? (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<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>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p className="text-gray-600">{t('app.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<p className="text-gray-600">{t('groups.noGroups')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{groups.map((group) => (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
servers={servers}
|
||||
onEdit={handleEditClick}
|
||||
onDelete={handleDeleteGroup}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddForm && (
|
||||
<AddGroupForm onAdd={handleAddComplete} onCancel={handleAddComplete} />
|
||||
)}
|
||||
|
||||
{editingGroup && (
|
||||
<EditGroupForm
|
||||
group={editingGroup}
|
||||
onEdit={handleEditComplete}
|
||||
onCancel={() => setEditingGroup(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupsPage;
|
||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ThemeSwitch from '@/components/ui/ThemeSwitch';
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -39,10 +40,13 @@ const LoginPage: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="absolute top-4 right-4">
|
||||
<ThemeSwitch />
|
||||
</div>
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
{t('auth.loginTitle')}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -58,7 +62,7 @@ const LoginPage: React.FC = () => {
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-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"
|
||||
placeholder={t('auth.username')}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
@@ -74,7 +78,7 @@ const LoginPage: React.FC = () => {
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
placeholder={t('auth.password')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
@@ -83,7 +87,7 @@ const LoginPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-500 text-sm text-center">{error}</div>
|
||||
<div className="text-red-500 dark:text-red-400 text-sm text-center">{error}</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
|
||||
28
frontend/src/pages/LogsPage.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
// filepath: /Users/sunmeng/code/github/mcphub/frontend/src/pages/LogsPage.tsx
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LogViewer from '../components/LogViewer';
|
||||
import { useLogs } from '../services/logService';
|
||||
|
||||
const LogsPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { logs, loading, error, clearLogs } = useLogs();
|
||||
|
||||
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>
|
||||
</div>
|
||||
<div className="bg-card rounded-md shadow-sm">
|
||||
<LogViewer
|
||||
logs={logs}
|
||||
isLoading={loading}
|
||||
error={error}
|
||||
onClear={clearLogs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogsPage;
|
||||
356
frontend/src/pages/MarketPage.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
||||
import { MarketServer } from '@/types';
|
||||
import { useMarketData } from '@/hooks/useMarketData';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import MarketServerCard from '@/components/MarketServerCard';
|
||||
import MarketServerDetail from '@/components/MarketServerDetail';
|
||||
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,
|
||||
searchServers,
|
||||
filterByCategory,
|
||||
filterByTag,
|
||||
selectedCategory,
|
||||
selectedTag,
|
||||
installServer,
|
||||
fetchServerByName,
|
||||
isServerInstalled,
|
||||
// Pagination
|
||||
currentPage,
|
||||
totalPages,
|
||||
changePage,
|
||||
serversPerPage,
|
||||
changeServersPerPage
|
||||
} = useMarketData();
|
||||
|
||||
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(() => {
|
||||
const loadServerDetails = async () => {
|
||||
if (serverName) {
|
||||
const server = await fetchServerByName(serverName);
|
||||
if (server) {
|
||||
setSelectedServer(server);
|
||||
} else {
|
||||
// If server not found, navigate back to market page
|
||||
navigate('/market');
|
||||
}
|
||||
} else {
|
||||
setSelectedServer(null);
|
||||
}
|
||||
};
|
||||
|
||||
loadServerDetails();
|
||||
}, [serverName, fetchServerByName, navigate]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
searchServers(searchQuery);
|
||||
};
|
||||
|
||||
const handleCategoryClick = (category: string) => {
|
||||
filterByCategory(category);
|
||||
};
|
||||
|
||||
const handleTagClick = (tag: string) => {
|
||||
filterByTag(tag);
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSearchQuery('');
|
||||
filterByCategory('');
|
||||
filterByTag('');
|
||||
};
|
||||
|
||||
const handleServerClick = (server: MarketServer) => {
|
||||
navigate(`/market/${server.name}`);
|
||||
};
|
||||
|
||||
const handleBackToList = () => {
|
||||
navigate('/market');
|
||||
};
|
||||
|
||||
const handleInstall = async (server: MarketServer) => {
|
||||
try {
|
||||
setInstalling(true);
|
||||
const success = await installServer(server);
|
||||
if (success) {
|
||||
// Show success message using toast instead of alert
|
||||
showToast(t('market.installSuccess', { serverName: server.display_name }), 'success');
|
||||
}
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
changePage(page);
|
||||
// Scroll to top of page when changing pages
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleChangeItemsPerPage = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newValue = parseInt(e.target.value, 10);
|
||||
changeServersPerPage(newValue);
|
||||
};
|
||||
|
||||
const toggleTagsVisibility = () => {
|
||||
setShowTags(!showTags);
|
||||
};
|
||||
|
||||
// Render detailed view if a server is selected
|
||||
if (selectedServer) {
|
||||
return (
|
||||
<MarketServerDetail
|
||||
server={selectedServer}
|
||||
onBack={handleBackToList}
|
||||
onInstall={handleInstall}
|
||||
installing={installing}
|
||||
isInstalled={isServerInstalled(selectedServer.name)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center">
|
||||
{t('market.title')}
|
||||
<span className="text-sm text-gray-500 font-normal ml-2">{t('pages.market.title').split(' - ')[1]}</span>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p>{error}</p>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="text-red-700 hover:text-red-900"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 01.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search bar at the top */}
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<form onSubmit={handleSearch} className="flex space-x-4 mb-0">
|
||||
<div className="flex-grow">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('market.searchPlaceholder')}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded"
|
||||
>
|
||||
{t('market.search')}
|
||||
</button>
|
||||
{(searchQuery || selectedCategory || selectedTag) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearFilters}
|
||||
className="border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded hover:bg-gray-50"
|
||||
>
|
||||
{t('market.clearFilters')}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
{/* Left sidebar for filters (without search) */}
|
||||
<div className="md:w-48 flex-shrink-0">
|
||||
<div className="bg-white shadow rounded-lg p-4 mb-6 sticky top-4">
|
||||
{/* Categories */}
|
||||
{categories.length > 0 ? (
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
|
||||
{selectedCategory && (
|
||||
<span className="text-xs text-blue-600 cursor-pointer hover:underline" onClick={() => filterByCategory('')}>
|
||||
{t('market.clearCategoryFilter')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => handleCategoryClick(category)}
|
||||
className={`px-3 py-2 rounded text-sm text-left ${selectedCategory === category
|
||||
? 'bg-blue-100 text-blue-800 font-medium'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="mb-6">
|
||||
<div className="mb-3">
|
||||
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 items-center py-4">
|
||||
<svg className="animate-spin h-6 w-6 text-blue-500 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p className="text-sm text-gray-600">{t('app.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-6">
|
||||
<div className="mb-3">
|
||||
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 py-2">{t('market.noCategories')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{/* {tags.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<div className="flex items-center">
|
||||
<h3 className="font-medium text-gray-900">{t('market.tags')}</h3>
|
||||
<button
|
||||
onClick={toggleTagsVisibility}
|
||||
className="ml-2 p-1 text-gray-600 hover:text-blue-600 hover:bg-gray-100 rounded-full"
|
||||
aria-label={showTags ? t('market.hideTags') : t('market.showTags')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className={`h-5 w-5 transition-transform ${showTags ? 'rotate-180' : ''}`} viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M5.293 7.293a1 1 011.414 0L10 10.586l3.293-3.293a1 1 011.414 1.414l-4 4a1 1 01-1.414 0l-4-4a1 1 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{selectedTag && (
|
||||
<span className="text-xs text-blue-600 cursor-pointer hover:underline" onClick={() => filterByTag('')}>
|
||||
{t('market.clearTagFilter')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showTags && (
|
||||
<div className="flex flex-wrap gap-2 max-h-48 overflow-y-auto pr-2">
|
||||
{tags.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => handleTagClick(tag)}
|
||||
className={`px-2 py-1 rounded text-xs ${selectedTag === tag
|
||||
? 'bg-green-100 text-green-800 font-medium'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
#{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex-grow">
|
||||
{loading ? (
|
||||
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center">
|
||||
<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>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p className="text-gray-600">{t('app.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<p className="text-gray-600">{t('market.noServers')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{servers.map((server, index) => (
|
||||
<MarketServerCard
|
||||
key={index}
|
||||
server={server}
|
||||
onClick={handleServerClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="text-sm text-gray-500">
|
||||
{t('market.showing', {
|
||||
from: (currentPage - 1) * serversPerPage + 1,
|
||||
to: Math.min(currentPage * serversPerPage, allServers.length),
|
||||
total: allServers.length
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<label htmlFor="perPage" className="text-sm text-gray-600">
|
||||
{t('market.perPage')}:
|
||||
</label>
|
||||
<select
|
||||
id="perPage"
|
||||
value={serversPerPage}
|
||||
onChange={handleChangeItemsPerPage}
|
||||
className="border rounded p-1 text-sm"
|
||||
>
|
||||
<option value="6">6</option>
|
||||
<option value="9">9</option>
|
||||
<option value="12">12</option>
|
||||
<option value="24">24</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketPage;
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Server } from '@/types';
|
||||
import ServerCard from '@/components/ServerCard';
|
||||
import AddServerForm from '@/components/AddServerForm';
|
||||
@@ -8,6 +9,7 @@ import { useServerData } from '@/hooks/useServerData';
|
||||
|
||||
const ServersPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
servers,
|
||||
error,
|
||||
@@ -16,9 +18,11 @@ const ServersPage: React.FC = () => {
|
||||
handleServerAdd,
|
||||
handleServerEdit,
|
||||
handleServerRemove,
|
||||
handleServerToggle
|
||||
handleServerToggle,
|
||||
triggerRefresh
|
||||
} = useServerData();
|
||||
const [editingServer, setEditingServer] = useState<Server | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const handleEditClick = async (server: Server) => {
|
||||
const fullServerData = await handleServerEdit(server);
|
||||
@@ -29,6 +33,18 @@ const ServersPage: React.FC = () => {
|
||||
|
||||
const handleEditComplete = () => {
|
||||
setEditingServer(null);
|
||||
triggerRefresh();
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
triggerRefresh();
|
||||
// Add a slight delay to make the spinner visible
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -36,14 +52,31 @@ const ServersPage: React.FC = () => {
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">{t('pages.servers.title')}</h1>
|
||||
<div className="flex space-x-4">
|
||||
<AddServerForm onAdd={handleServerAdd} />
|
||||
<button
|
||||
onClick={() => handleServerAdd()}
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center"
|
||||
onClick={() => navigate('/market')}
|
||||
className="px-4 py-2 bg-emerald-100 text-emerald-800 rounded hover:bg-emerald-200 flex items-center"
|
||||
>
|
||||
<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="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clipRule="evenodd" />
|
||||
<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" />
|
||||
</svg>
|
||||
{t('nav.market')}
|
||||
</button>
|
||||
<AddServerForm onAdd={handleServerAdd} />
|
||||
<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' : ''}`}
|
||||
>
|
||||
{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">
|
||||
<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>
|
||||
) : (
|
||||
<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="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
{t('common.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,52 +1,300 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ChangePasswordForm from '@/components/ChangePasswordForm';
|
||||
import { Switch } from '@/components/ui/ToggleGroup';
|
||||
import { useSettingsData } from '@/hooks/useSettingsData';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import { generateRandomKey } from '@/utils/key';
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = 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;
|
||||
}>({
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
});
|
||||
|
||||
const {
|
||||
routingConfig,
|
||||
tempRoutingConfig,
|
||||
setTempRoutingConfig,
|
||||
installConfig: savedInstallConfig,
|
||||
loading,
|
||||
updateRoutingConfig,
|
||||
updateInstallConfig
|
||||
} = useSettingsData();
|
||||
|
||||
// Update local installConfig when savedInstallConfig changes
|
||||
useEffect(() => {
|
||||
if (savedInstallConfig) {
|
||||
setInstallConfig(savedInstallConfig);
|
||||
}
|
||||
}, [savedInstallConfig]);
|
||||
|
||||
const [sectionsVisible, setSectionsVisible] = useState({
|
||||
routingConfig: false,
|
||||
installConfig: false,
|
||||
password: false
|
||||
});
|
||||
|
||||
const toggleSection = (section: 'routingConfig' | 'installConfig' | 'password') => {
|
||||
setSectionsVisible(prev => ({
|
||||
...prev,
|
||||
[section]: !prev[section]
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRoutingConfigChange = async (key: 'enableGlobalRoute' | 'enableGroupNameRoute' | 'enableBearerAuth' | 'bearerAuthKey', value: boolean | string) => {
|
||||
await updateRoutingConfig(key, value);
|
||||
|
||||
// If enableBearerAuth is turned on and there's no key, generate one
|
||||
if (key === 'enableBearerAuth' && value === true) {
|
||||
if (!tempRoutingConfig.bearerAuthKey) {
|
||||
const newKey = generateRandomKey();
|
||||
handleBearerAuthKeyChange(newKey);
|
||||
await updateRoutingConfig('bearerAuthKey', newKey);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleBearerAuthKeyChange = (value: string) => {
|
||||
setTempRoutingConfig(prev => ({
|
||||
...prev,
|
||||
bearerAuthKey: value
|
||||
}));
|
||||
};
|
||||
|
||||
const saveBearerAuthKey = async () => {
|
||||
await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey);
|
||||
};
|
||||
|
||||
const handleInstallConfigChange = (key: 'pythonIndexUrl' | 'npmRegistry', value: string) => {
|
||||
setInstallConfig({
|
||||
...installConfig,
|
||||
[key]: value
|
||||
});
|
||||
};
|
||||
|
||||
const saveInstallConfig = async (key: 'pythonIndexUrl' | 'npmRegistry') => {
|
||||
await updateInstallConfig(key, installConfig[key]);
|
||||
};
|
||||
|
||||
const handlePasswordChangeSuccess = () => {
|
||||
setTimeout(() => {
|
||||
navigate('/');
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleLanguageChange = (lang: string) => {
|
||||
localStorage.setItem('i18nextLng', lang);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="container mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-8">{t('pages.settings.title')}</h1>
|
||||
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('auth.changePassword')}</h2>
|
||||
<div className="max-w-lg">
|
||||
<ChangePasswordForm onSuccess={handlePasswordChangeSuccess} />
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 其他设置可以在这里添加 */}
|
||||
<div className="bg-white shadow rounded-lg p-6 mt-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('pages.settings.language')}</h2>
|
||||
<div className="flex space-x-4">
|
||||
<button
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200"
|
||||
onClick={() => {
|
||||
localStorage.setItem('i18nextLng', 'en');
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
English
|
||||
</button>
|
||||
<button
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200"
|
||||
onClick={() => {
|
||||
localStorage.setItem('i18nextLng', 'zh');
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
中文
|
||||
</button>
|
||||
|
||||
{/* Route 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('routingConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('pages.settings.routeConfig')}</h2>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.routingConfig ? '▼' : '►'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.routingConfig && (
|
||||
<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.enableBearerAuth')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.enableBearerAuthDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableBearerAuth}
|
||||
onCheckedChange={(checked) => handleRoutingConfigChange('enableBearerAuth', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{routingConfig.enableBearerAuth && (
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.bearerAuthKey')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.bearerAuthKeyDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
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"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableGlobalRoute')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.enableGlobalRouteDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableGlobalRoute}
|
||||
onCheckedChange={(checked) => handleRoutingConfigChange('enableGlobalRoute', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableGroupNameRoute')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.enableGroupNameRouteDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableGroupNameRoute}
|
||||
onCheckedChange={(checked) => handleRoutingConfigChange('enableGroupNameRoute', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Change Password */}
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('password')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('auth.changePassword')}</h2>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.password ? '▼' : '►'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.password && (
|
||||
<div className="max-w-lg mt-4">
|
||||
<ChangePasswordForm onSuccess={handlePasswordChangeSuccess} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
152
frontend/src/services/logService.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getToken } from './authService'; // Import getToken function
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: number;
|
||||
type: 'info' | 'error' | 'warn' | 'debug';
|
||||
source: string;
|
||||
message: string;
|
||||
processId?: string;
|
||||
}
|
||||
|
||||
// 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 fetch('/api/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;
|
||||
} catch (error) {
|
||||
console.error('Error fetching logs:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 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 fetch('/api/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');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error clearing logs:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Hook to use logs with SSE streaming
|
||||
export const useLogs = () => {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let eventSource: EventSource | null = null;
|
||||
let isMounted = true;
|
||||
|
||||
const connectToLogStream = () => {
|
||||
try {
|
||||
// Close existing connection if any
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
|
||||
// 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(`/api/logs/stream?token=${token}`);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
if (!isMounted) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'initial') {
|
||||
setLogs(data.logs);
|
||||
setLoading(false);
|
||||
} else if (data.type === 'log') {
|
||||
setLogs(prevLogs => [...prevLogs, data.log]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error parsing SSE message:', err);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
if (!isMounted) return;
|
||||
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
// Attempt to reconnect after a delay
|
||||
setTimeout(connectToLogStream, 5000);
|
||||
}
|
||||
|
||||
setError(new Error('Connection to log stream lost, attempting to reconnect...'));
|
||||
};
|
||||
} catch (err) {
|
||||
if (!isMounted) return;
|
||||
setError(err instanceof Error ? err : new Error('Failed to connect to log stream'));
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial connection
|
||||
connectToLogStream();
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const clearAllLogs = async () => {
|
||||
try {
|
||||
await clearLogs();
|
||||
setLogs([]);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to clear logs'));
|
||||
}
|
||||
};
|
||||
|
||||
return { logs, loading, error, clearLogs: clearAllLogs };
|
||||
};
|
||||
@@ -1,58 +1,131 @@
|
||||
// 服务器状态类型
|
||||
// Server status types
|
||||
export type ServerStatus = 'connecting' | 'connected' | 'disconnected';
|
||||
|
||||
// 工具输入模式类型
|
||||
export interface ToolInputSchema {
|
||||
// Market server types
|
||||
export interface MarketServerRepository {
|
||||
type: string;
|
||||
properties?: Record<string, any>;
|
||||
required?: string[];
|
||||
[key: string]: any;
|
||||
url: string;
|
||||
}
|
||||
|
||||
// 工具类型
|
||||
export interface Tool {
|
||||
export interface MarketServerAuthor {
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema: ToolInputSchema;
|
||||
}
|
||||
|
||||
// 服务器配置类型
|
||||
export interface ServerConfig {
|
||||
url?: string;
|
||||
command?: string;
|
||||
args?: string[] | string;
|
||||
export interface MarketServerInstallation {
|
||||
type: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
// 服务器类型
|
||||
export interface MarketServerArgument {
|
||||
description: string;
|
||||
required: boolean;
|
||||
example: string;
|
||||
}
|
||||
|
||||
export interface MarketServerExample {
|
||||
title: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export interface MarketServerTool {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface MarketServer {
|
||||
name: string;
|
||||
display_name: string;
|
||||
description: string;
|
||||
repository: MarketServerRepository;
|
||||
homepage: string;
|
||||
author: MarketServerAuthor;
|
||||
license: string;
|
||||
categories: string[];
|
||||
tags: string[];
|
||||
examples: MarketServerExample[];
|
||||
installations: {
|
||||
[key: string]: MarketServerInstallation;
|
||||
};
|
||||
arguments: Record<string, MarketServerArgument>;
|
||||
tools: MarketServerTool[];
|
||||
is_official?: boolean;
|
||||
}
|
||||
|
||||
// Tool input schema types
|
||||
export interface ToolInputSchema {
|
||||
type: string;
|
||||
properties?: Record<string, unknown>;
|
||||
required?: string[];
|
||||
}
|
||||
|
||||
// Tool types
|
||||
export interface Tool {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: ToolInputSchema;
|
||||
}
|
||||
|
||||
// Server config types
|
||||
export interface ServerConfig {
|
||||
type?: 'stdio' | 'sse' | 'streamable-http';
|
||||
url?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
// Server types
|
||||
export interface Server {
|
||||
name: string;
|
||||
status: ServerStatus;
|
||||
error?: string;
|
||||
tools?: Tool[];
|
||||
config?: ServerConfig;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
// 环境变量类型
|
||||
// Group types
|
||||
export interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
servers: string[];
|
||||
}
|
||||
|
||||
// Environment variable types
|
||||
export interface EnvVar {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
// 表单数据类型
|
||||
// Form data types
|
||||
export interface ServerFormData {
|
||||
name: string;
|
||||
url: string;
|
||||
command: string;
|
||||
arguments: string;
|
||||
args: string[];
|
||||
args?: string[]; // Added explicit args field
|
||||
type?: 'stdio' | 'sse' | 'streamable-http'; // Added type field
|
||||
env: EnvVar[];
|
||||
}
|
||||
|
||||
// API响应类型
|
||||
export interface ApiResponse<T> {
|
||||
// Group form data types
|
||||
export interface GroupFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
servers: string[]; // Added servers array to include in form data
|
||||
}
|
||||
|
||||
// API response types
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
// Auth types
|
||||
@@ -62,10 +135,9 @@ export interface IUser {
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
loading: boolean;
|
||||
user: IUser | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
@@ -88,5 +160,4 @@ export interface AuthResponse {
|
||||
token?: string;
|
||||
user?: IUser;
|
||||
message?: string;
|
||||
errors?: Array<{ msg: string }>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/**
|
||||
* Combines multiple class names and deduplicates Tailwind CSS classes
|
||||
* This is a utility function for conditionally joining class names together
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
8
frontend/src/utils/key.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function generateRandomKey(length: number = 32): string {
|
||||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const array = new Uint8Array(length);
|
||||
crypto.getRandomValues(array);
|
||||
return Array.from(array)
|
||||
.map((x) => characters.charAt(x % characters.length))
|
||||
.join('');
|
||||
}
|
||||
31
frontend/src/utils/version.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
const NPM_REGISTRY = 'https://registry.npmjs.org';
|
||||
const PACKAGE_NAME = '@samanhappy/mcphub';
|
||||
|
||||
export const checkLatestVersion = async (): Promise<string | null> => {
|
||||
try {
|
||||
const response = await fetch(`${NPM_REGISTRY}/${PACKAGE_NAME}/latest`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch latest version: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data.version || null;
|
||||
} catch (error) {
|
||||
console.error('Error checking for latest version:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const compareVersions = (current: string, latest: string): number => {
|
||||
const currentParts = current.split('.').map(Number);
|
||||
const latestParts = latest.split('.').map(Number);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const currentPart = currentParts[i] || 0;
|
||||
const latestPart = latestParts[i] || 0;
|
||||
|
||||
if (currentPart > latestPart) return -1;
|
||||
if (currentPart < latestPart) return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
9
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: {
|
||||
readonly PACKAGE_VERSION: string;
|
||||
// Add other custom env variables here if needed
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
12
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class', // Use class strategy for dark mode
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -2,6 +2,11 @@ import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
// Import the package.json to get the version
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
// Get package.json version
|
||||
const packageJson = JSON.parse(readFileSync(path.resolve(__dirname, '../package.json'), 'utf-8'));
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
@@ -11,6 +16,13 @@ export default defineConfig({
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
define: {
|
||||
// Make package version available as global variable
|
||||
'import.meta.env.PACKAGE_VERSION': JSON.stringify(packageJson.version),
|
||||
},
|
||||
build: {
|
||||
sourcemap: true, // Enable source maps for production build
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"amap-maps": {
|
||||
"amap": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
|
||||
84
package.json
@@ -1,75 +1,99 @@
|
||||
{
|
||||
"name": "mcphub",
|
||||
"version": "0.0.1",
|
||||
"name": "@samanhappy/mcphub",
|
||||
"version": "0.5.4",
|
||||
"description": "A hub server for mcp servers",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"mcphub": "bin/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"bin",
|
||||
"mcp_settings.json",
|
||||
"servers.json",
|
||||
"frontend/dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "pnpm backend:build && pnpm frontend:build",
|
||||
"backend:build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"backend:dev": "tsx watch src/index.ts",
|
||||
"backend:debug": "tsx watch src/index.ts --inspect",
|
||||
"lint": "eslint . --ext .ts",
|
||||
"format": "prettier --write \"src/**/*.ts\"",
|
||||
"test": "jest",
|
||||
"frontend:dev": "cd frontend && vite",
|
||||
"frontend:build": "cd frontend && vite build",
|
||||
"frontend:preview": "cd frontend && vite preview",
|
||||
"dev": "concurrently \"pnpm backend:dev\" \"pnpm frontend:dev\""
|
||||
"dev": "concurrently \"pnpm backend:dev\" \"pnpm frontend:dev\"",
|
||||
"debug": "concurrently \"pnpm backend:debug\" \"pnpm frontend:dev\"",
|
||||
"prepublishOnly": "npm run build && node scripts/verify-dist.js"
|
||||
},
|
||||
"keywords": [
|
||||
"typescript",
|
||||
"server"
|
||||
"server",
|
||||
"mcp",
|
||||
"model context protocol"
|
||||
],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.9.0",
|
||||
"@radix-ui/react-accordion": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@shadcn/ui": "^0.0.4",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@types/react": "^19.0.12",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"@modelcontextprotocol/sdk": "^1.11.1",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-validator": "^7.2.1",
|
||||
"i18next": "^24.2.3",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.486.0",
|
||||
"next": "^15.2.4",
|
||||
"postcss": "^8.5.3",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-router-dom": "^7.5.0",
|
||||
"tailwind-merge": "^3.1.0",
|
||||
"tailwind-scrollbar-hide": "^2.0.0",
|
||||
"tailwindcss": "^4.0.17",
|
||||
"zod": "^3.24.2"
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@radix-ui/react-accordion": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@shadcn/ui": "^0.0.4",
|
||||
"@tailwindcss/postcss": "^4.1.3",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.5",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/node": "^20.8.2",
|
||||
"@types/react": "^19.0.12",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
||||
"@typescript-eslint/parser": "^6.7.4",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"concurrently": "^8.2.2",
|
||||
"eslint": "^8.50.0",
|
||||
"i18next": "^24.2.3",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"jest": "^29.7.0",
|
||||
"lucide-react": "^0.486.0",
|
||||
"next": "^15.2.4",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.0.3",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"tailwind-merge": "^3.1.0",
|
||||
"tailwind-scrollbar-hide": "^2.0.0",
|
||||
"tailwindcss": "^4.0.17",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.4.18"
|
||||
}
|
||||
"vite": "^5.4.18",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha256.fa0f513aa8191764d2b6b432420788c270f07b4f999099b65bb2010eec702a30"
|
||||
}
|
||||
374
pnpm-lock.yaml
generated
@@ -9,38 +9,11 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.9.0
|
||||
version: 1.9.0
|
||||
'@radix-ui/react-accordion':
|
||||
specifier: ^1.2.3
|
||||
version: 1.2.3(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-slot':
|
||||
specifier: ^1.1.2
|
||||
version: 1.1.2(@types/react@19.0.12)(react@19.1.0)
|
||||
'@shadcn/ui':
|
||||
specifier: ^0.0.4
|
||||
version: 0.0.4
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.3
|
||||
version: 4.1.3(vite@5.4.18(@types/node@20.17.28)(lightningcss@1.29.2))
|
||||
'@types/react':
|
||||
specifier: ^19.0.12
|
||||
version: 19.0.12
|
||||
'@types/react-dom':
|
||||
specifier: ^19.0.4
|
||||
version: 19.0.4(@types/react@19.0.12)
|
||||
autoprefixer:
|
||||
specifier: ^10.4.21
|
||||
version: 10.4.21(postcss@8.5.3)
|
||||
specifier: ^1.11.1
|
||||
version: 1.11.1
|
||||
bcryptjs:
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.2
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
dotenv:
|
||||
specifier: ^16.3.1
|
||||
version: 16.4.7
|
||||
@@ -50,52 +23,28 @@ importers:
|
||||
express-validator:
|
||||
specifier: ^7.2.1
|
||||
version: 7.2.1
|
||||
i18next:
|
||||
specifier: ^24.2.3
|
||||
version: 24.2.3(typescript@5.8.2)
|
||||
i18next-browser-languagedetector:
|
||||
specifier: ^8.0.4
|
||||
version: 8.0.4
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.2
|
||||
version: 9.0.2
|
||||
lucide-react:
|
||||
specifier: ^0.486.0
|
||||
version: 0.486.0(react@19.1.0)
|
||||
next:
|
||||
specifier: ^15.2.4
|
||||
version: 15.2.4(@babel/core@7.26.10)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
postcss:
|
||||
specifier: ^8.5.3
|
||||
version: 8.5.3
|
||||
react:
|
||||
specifier: ^19.1.0
|
||||
version: 19.1.0
|
||||
react-dom:
|
||||
specifier: ^19.1.0
|
||||
version: 19.1.0(react@19.1.0)
|
||||
react-i18next:
|
||||
specifier: ^15.4.1
|
||||
version: 15.4.1(i18next@24.2.3(typescript@5.8.2))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react-router-dom:
|
||||
specifier: ^7.5.0
|
||||
version: 7.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
tailwind-merge:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0
|
||||
tailwind-scrollbar-hide:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0(tailwindcss@4.0.17)
|
||||
tailwindcss:
|
||||
specifier: ^4.0.17
|
||||
version: 4.0.17
|
||||
zod:
|
||||
specifier: ^3.24.2
|
||||
version: 3.24.2
|
||||
uuid:
|
||||
specifier: ^11.1.0
|
||||
version: 11.1.0
|
||||
devDependencies:
|
||||
'@radix-ui/react-accordion':
|
||||
specifier: ^1.2.3
|
||||
version: 1.2.3(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-slot':
|
||||
specifier: ^1.1.2
|
||||
version: 1.1.2(@types/react@19.0.12)(react@19.1.0)
|
||||
'@shadcn/ui':
|
||||
specifier: ^0.0.4
|
||||
version: 0.0.4
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4.1.3
|
||||
version: 4.1.3
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.3
|
||||
version: 4.1.3(vite@5.4.19(@types/node@20.17.28)(lightningcss@1.29.2))
|
||||
'@types/bcryptjs':
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
@@ -111,6 +60,15 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^20.8.2
|
||||
version: 20.17.28
|
||||
'@types/react':
|
||||
specifier: ^19.0.12
|
||||
version: 19.0.12
|
||||
'@types/react-dom':
|
||||
specifier: ^19.0.4
|
||||
version: 19.0.4(@types/react@19.0.12)
|
||||
'@types/uuid':
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0
|
||||
'@typescript-eslint/eslint-plugin':
|
||||
specifier: ^6.7.4
|
||||
version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1)(typescript@5.8.2)
|
||||
@@ -119,19 +77,64 @@ importers:
|
||||
version: 6.21.0(eslint@8.57.1)(typescript@5.8.2)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^4.2.1
|
||||
version: 4.3.4(vite@5.4.18(@types/node@20.17.28)(lightningcss@1.29.2))
|
||||
version: 4.3.4(vite@5.4.19(@types/node@20.17.28)(lightningcss@1.29.2))
|
||||
autoprefixer:
|
||||
specifier: ^10.4.21
|
||||
version: 10.4.21(postcss@8.5.3)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
concurrently:
|
||||
specifier: ^8.2.2
|
||||
version: 8.2.2
|
||||
eslint:
|
||||
specifier: ^8.50.0
|
||||
version: 8.57.1
|
||||
i18next:
|
||||
specifier: ^24.2.3
|
||||
version: 24.2.3(typescript@5.8.2)
|
||||
i18next-browser-languagedetector:
|
||||
specifier: ^8.0.4
|
||||
version: 8.0.4
|
||||
jest:
|
||||
specifier: ^29.7.0
|
||||
version: 29.7.0(@types/node@20.17.28)(ts-node@10.9.2(@types/node@20.17.28)(typescript@5.8.2))
|
||||
lucide-react:
|
||||
specifier: ^0.486.0
|
||||
version: 0.486.0(react@19.1.0)
|
||||
next:
|
||||
specifier: ^15.2.4
|
||||
version: 15.2.4(@babel/core@7.26.10)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
postcss:
|
||||
specifier: ^8.5.3
|
||||
version: 8.5.3
|
||||
prettier:
|
||||
specifier: ^3.0.3
|
||||
version: 3.5.3
|
||||
react:
|
||||
specifier: ^19.1.0
|
||||
version: 19.1.0
|
||||
react-dom:
|
||||
specifier: ^19.1.0
|
||||
version: 19.1.0(react@19.1.0)
|
||||
react-i18next:
|
||||
specifier: ^15.4.1
|
||||
version: 15.4.1(i18next@24.2.3(typescript@5.8.2))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react-router-dom:
|
||||
specifier: ^7.6.0
|
||||
version: 7.6.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
tailwind-merge:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0
|
||||
tailwind-scrollbar-hide:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0(tailwindcss@4.0.17)
|
||||
tailwindcss:
|
||||
specifier: ^4.0.17
|
||||
version: 4.0.17
|
||||
ts-jest:
|
||||
specifier: ^29.1.1
|
||||
version: 29.3.0(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(jest@29.7.0(@types/node@20.17.28)(ts-node@10.9.2(@types/node@20.17.28)(typescript@5.8.2)))(typescript@5.8.2)
|
||||
@@ -146,7 +149,10 @@ importers:
|
||||
version: 5.8.2
|
||||
vite:
|
||||
specifier: ^5.4.18
|
||||
version: 5.4.18(@types/node@20.17.28)(lightningcss@1.29.2)
|
||||
version: 5.4.19(@types/node@20.17.28)(lightningcss@1.29.2)
|
||||
zod:
|
||||
specifier: ^3.24.2
|
||||
version: 3.24.2
|
||||
|
||||
packages:
|
||||
|
||||
@@ -861,8 +867,8 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.9':
|
||||
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
|
||||
|
||||
'@modelcontextprotocol/sdk@1.9.0':
|
||||
resolution: {integrity: sha512-Jq2EUCQpe0iyO5FGpzVYDNFR6oR53AIrwph9yWl7uSc7IWUMsrmpmSaTGra5hQNunXpM+9oit85p924jWuHzUA==}
|
||||
'@modelcontextprotocol/sdk@1.11.1':
|
||||
resolution: {integrity: sha512-9LfmxKTb1v+vUS1/emSk1f5ePmTLkb9Le9AxOB5T0XM59EUumwcS45z05h7aiZx3GI0Bl7mjb3FMEglYj+acuQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@next/env@15.2.4':
|
||||
@@ -1068,103 +1074,103 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.40.0':
|
||||
resolution: {integrity: sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg==}
|
||||
'@rollup/rollup-android-arm-eabi@4.40.1':
|
||||
resolution: {integrity: sha512-kxz0YeeCrRUHz3zyqvd7n+TVRlNyTifBsmnmNPtk3hQURUyG9eAB+usz6DAwagMusjx/zb3AjvDUvhFGDAexGw==}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-android-arm64@4.40.0':
|
||||
resolution: {integrity: sha512-PPA6aEEsTPRz+/4xxAmaoWDqh67N7wFbgFUJGMnanCFs0TV99M0M8QhhaSCks+n6EbQoFvLQgYOGXxlMGQe/6w==}
|
||||
'@rollup/rollup-android-arm64@4.40.1':
|
||||
resolution: {integrity: sha512-PPkxTOisoNC6TpnDKatjKkjRMsdaWIhyuMkA4UsBXT9WEZY4uHezBTjs6Vl4PbqQQeu6oION1w2voYZv9yquCw==}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.40.0':
|
||||
resolution: {integrity: sha512-GwYOcOakYHdfnjjKwqpTGgn5a6cUX7+Ra2HeNj/GdXvO2VJOOXCiYYlRFU4CubFM67EhbmzLOmACKEfvp3J1kQ==}
|
||||
'@rollup/rollup-darwin-arm64@4.40.1':
|
||||
resolution: {integrity: sha512-VWXGISWFY18v/0JyNUy4A46KCFCb9NVsH+1100XP31lud+TzlezBbz24CYzbnA4x6w4hx+NYCXDfnvDVO6lcAA==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.40.0':
|
||||
resolution: {integrity: sha512-CoLEGJ+2eheqD9KBSxmma6ld01czS52Iw0e2qMZNpPDlf7Z9mj8xmMemxEucinev4LgHalDPczMyxzbq+Q+EtA==}
|
||||
'@rollup/rollup-darwin-x64@4.40.1':
|
||||
resolution: {integrity: sha512-nIwkXafAI1/QCS7pxSpv/ZtFW6TXcNUEHAIA9EIyw5OzxJZQ1YDrX+CL6JAIQgZ33CInl1R6mHet9Y/UZTg2Bw==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.40.0':
|
||||
resolution: {integrity: sha512-r7yGiS4HN/kibvESzmrOB/PxKMhPTlz+FcGvoUIKYoTyGd5toHp48g1uZy1o1xQvybwwpqpe010JrcGG2s5nkg==}
|
||||
'@rollup/rollup-freebsd-arm64@4.40.1':
|
||||
resolution: {integrity: sha512-BdrLJ2mHTrIYdaS2I99mriyJfGGenSaP+UwGi1kB9BLOCu9SR8ZpbkmmalKIALnRw24kM7qCN0IOm6L0S44iWw==}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.40.0':
|
||||
resolution: {integrity: sha512-mVDxzlf0oLzV3oZOr0SMJ0lSDd3xC4CmnWJ8Val8isp9jRGl5Dq//LLDSPFrasS7pSm6m5xAcKaw3sHXhBjoRw==}
|
||||
'@rollup/rollup-freebsd-x64@4.40.1':
|
||||
resolution: {integrity: sha512-VXeo/puqvCG8JBPNZXZf5Dqq7BzElNJzHRRw3vjBE27WujdzuOPecDPc/+1DcdcTptNBep3861jNq0mYkT8Z6Q==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.40.0':
|
||||
resolution: {integrity: sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==}
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.40.1':
|
||||
resolution: {integrity: sha512-ehSKrewwsESPt1TgSE/na9nIhWCosfGSFqv7vwEtjyAqZcvbGIg4JAcV7ZEh2tfj/IlfBeZjgOXm35iOOjadcg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.40.0':
|
||||
resolution: {integrity: sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==}
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.40.1':
|
||||
resolution: {integrity: sha512-m39iO/aaurh5FVIu/F4/Zsl8xppd76S4qoID8E+dSRQvTyZTOI2gVk3T4oqzfq1PtcvOfAVlwLMK3KRQMaR8lg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==}
|
||||
'@rollup/rollup-linux-arm64-gnu@4.40.1':
|
||||
resolution: {integrity: sha512-Y+GHnGaku4aVLSgrT0uWe2o2Rq8te9hi+MwqGF9r9ORgXhmHK5Q71N757u0F8yU1OIwUIFy6YiJtKjtyktk5hg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.40.0':
|
||||
resolution: {integrity: sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==}
|
||||
'@rollup/rollup-linux-arm64-musl@4.40.1':
|
||||
resolution: {integrity: sha512-jEwjn3jCA+tQGswK3aEWcD09/7M5wGwc6+flhva7dsQNRZZTe30vkalgIzV4tjkopsTS9Jd7Y1Bsj6a4lzz8gQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==}
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.40.1':
|
||||
resolution: {integrity: sha512-ySyWikVhNzv+BV/IDCsrraOAZ3UaC8SZB67FZlqVwXwnFhPihOso9rPOxzZbjp81suB1O2Topw+6Ug3JNegejQ==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==}
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.40.1':
|
||||
resolution: {integrity: sha512-BvvA64QxZlh7WZWqDPPdt0GH4bznuL6uOO1pmgPnnv86rpUpc8ZxgZwcEgXvo02GRIZX1hQ0j0pAnhwkhwPqWg==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==}
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.40.1':
|
||||
resolution: {integrity: sha512-EQSP+8+1VuSulm9RKSMKitTav89fKbHymTf25n5+Yr6gAPZxYWpj3DzAsQqoaHAk9YX2lwEyAf9S4W8F4l3VBQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.40.0':
|
||||
resolution: {integrity: sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==}
|
||||
'@rollup/rollup-linux-riscv64-musl@4.40.1':
|
||||
resolution: {integrity: sha512-n/vQ4xRZXKuIpqukkMXZt9RWdl+2zgGNx7Uda8NtmLJ06NL8jiHxUawbwC+hdSq1rrw/9CghCpEONor+l1e2gA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==}
|
||||
'@rollup/rollup-linux-s390x-gnu@4.40.1':
|
||||
resolution: {integrity: sha512-h8d28xzYb98fMQKUz0w2fMc1XuGzLLjdyxVIbhbil4ELfk5/orZlSTpF/xdI9C8K0I8lCkq+1En2RJsawZekkg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==}
|
||||
'@rollup/rollup-linux-x64-gnu@4.40.1':
|
||||
resolution: {integrity: sha512-XiK5z70PEFEFqcNj3/zRSz/qX4bp4QIraTy9QjwJAb/Z8GM7kVUsD0Uk8maIPeTyPCP03ChdI+VVmJriKYbRHQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.40.0':
|
||||
resolution: {integrity: sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==}
|
||||
'@rollup/rollup-linux-x64-musl@4.40.1':
|
||||
resolution: {integrity: sha512-2BRORitq5rQ4Da9blVovzNCMaUlyKrzMSvkVR0D4qPuOy/+pMCrh1d7o01RATwVy+6Fa1WBw+da7QPeLWU/1mQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.40.0':
|
||||
resolution: {integrity: sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==}
|
||||
'@rollup/rollup-win32-arm64-msvc@4.40.1':
|
||||
resolution: {integrity: sha512-b2bcNm9Kbde03H+q+Jjw9tSfhYkzrDUf2d5MAd1bOJuVplXvFhWz7tRtWvD8/ORZi7qSCy0idW6tf2HgxSXQSg==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.40.0':
|
||||
resolution: {integrity: sha512-+m03kvI2f5syIqHXCZLPVYplP8pQch9JHyXKZ3AGMKlg8dCyr2PKHjwRLiW53LTrN/Nc3EqHOKxUxzoSPdKddA==}
|
||||
'@rollup/rollup-win32-ia32-msvc@4.40.1':
|
||||
resolution: {integrity: sha512-DfcogW8N7Zg7llVEfpqWMZcaErKfsj9VvmfSyRjCyo4BI3wPEfrzTtJkZG6gKP/Z92wFm6rz2aDO7/JfiR/whA==}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.40.0':
|
||||
resolution: {integrity: sha512-lpPE1cLfP5oPzVjKMx10pgBmKELQnFJXHgvtHCtuJWOv8MxqdEIMNtgHgBFf7Ea2/7EuVwa9fodWUfXAlXZLZQ==}
|
||||
'@rollup/rollup-win32-x64-msvc@4.40.1':
|
||||
resolution: {integrity: sha512-ECyOuDeH3C1I8jH2MK1RtBJW+YPMvSfT0a5NN0nHfQYnDSJ6tUiZH3gzwVP5/Kfh/+Tt7tpWVF9LXNTnhTJ3kA==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
@@ -1302,9 +1308,6 @@ packages:
|
||||
'@types/connect@3.4.38':
|
||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||
|
||||
'@types/cookie@0.6.0':
|
||||
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
|
||||
|
||||
'@types/estree@1.0.7':
|
||||
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
|
||||
|
||||
@@ -1379,6 +1382,9 @@ packages:
|
||||
'@types/strip-json-comments@0.0.30':
|
||||
resolution: {integrity: sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==}
|
||||
|
||||
'@types/uuid@10.0.0':
|
||||
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
|
||||
|
||||
'@types/yargs-parser@21.0.3':
|
||||
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
|
||||
|
||||
@@ -2796,6 +2802,7 @@ packages:
|
||||
node-domexception@1.0.0:
|
||||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||
engines: {node: '>=10.5.0'}
|
||||
deprecated: Use your platform's native DOMException instead
|
||||
|
||||
node-fetch@3.3.2:
|
||||
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
||||
@@ -3022,15 +3029,15 @@ packages:
|
||||
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
react-router-dom@7.5.0:
|
||||
resolution: {integrity: sha512-fFhGFCULy4vIseTtH5PNcY/VvDJK5gvOWcwJVHQp8JQcWVr85ENhJ3UpuF/zP1tQOIFYNRJHzXtyhU1Bdgw0RA==}
|
||||
react-router-dom@7.6.0:
|
||||
resolution: {integrity: sha512-DYgm6RDEuKdopSyGOWZGtDfSm7Aofb8CCzgkliTjtu/eDuB0gcsv6qdFhhi8HdtmA+KHkt5MfZ5K2PdzjugYsA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=18'
|
||||
react-dom: '>=18'
|
||||
|
||||
react-router@7.5.0:
|
||||
resolution: {integrity: sha512-estOHrRlDMKdlQa6Mj32gIks4J+AxNsYoE0DbTTxiMy2mPzZuWSDU+N85/r1IlNR7kGfznF3VCUlvc5IUO+B9g==}
|
||||
react-router@7.6.0:
|
||||
resolution: {integrity: sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=18'
|
||||
@@ -3100,8 +3107,8 @@ packages:
|
||||
deprecated: Rimraf versions prior to v4 are no longer supported
|
||||
hasBin: true
|
||||
|
||||
rollup@4.40.0:
|
||||
resolution: {integrity: sha512-Noe455xmA96nnqH5piFtLobsGbCij7Tu+tb3c1vYjNbTkfzGqXqQXG3wJaYXkRZuQ0vEYN4bhwg7QnIrqB5B+w==}
|
||||
rollup@4.40.1:
|
||||
resolution: {integrity: sha512-C5VvvgCCyfyotVITIAv+4efVytl5F7wt+/I2i9q9GZcEXW9BP52YYOXC58igUi+LFZVHukErIIqQSWwv/M3WRw==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -3410,9 +3417,6 @@ packages:
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
turbo-stream@2.4.0:
|
||||
resolution: {integrity: sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -3473,6 +3477,10 @@ packages:
|
||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
|
||||
uuid@11.1.0:
|
||||
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
|
||||
hasBin: true
|
||||
|
||||
v8-compile-cache-lib@3.0.1:
|
||||
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
|
||||
|
||||
@@ -3488,8 +3496,8 @@ packages:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
vite@5.4.18:
|
||||
resolution: {integrity: sha512-1oDcnEp3lVyHCuQ2YFelM4Alm2o91xNoMncRm1U7S+JdYfYOvbiGZ3/CxGttrOu2M/KcGz7cRC2DoNUA6urmMA==}
|
||||
vite@5.4.19:
|
||||
resolution: {integrity: sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -4255,7 +4263,7 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
|
||||
'@modelcontextprotocol/sdk@1.9.0':
|
||||
'@modelcontextprotocol/sdk@1.11.1':
|
||||
dependencies:
|
||||
content-type: 1.0.5
|
||||
cors: 2.8.5
|
||||
@@ -4425,64 +4433,64 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.0.12
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.40.0':
|
||||
'@rollup/rollup-android-arm-eabi@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-android-arm64@4.40.0':
|
||||
'@rollup/rollup-android-arm64@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.40.0':
|
||||
'@rollup/rollup-darwin-arm64@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.40.0':
|
||||
'@rollup/rollup-darwin-x64@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.40.0':
|
||||
'@rollup/rollup-freebsd-arm64@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.40.0':
|
||||
'@rollup/rollup-freebsd-x64@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.40.0':
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.40.0':
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.40.0':
|
||||
'@rollup/rollup-linux-arm64-gnu@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.40.0':
|
||||
'@rollup/rollup-linux-arm64-musl@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.40.0':
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.40.0':
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.40.0':
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.40.0':
|
||||
'@rollup/rollup-linux-riscv64-musl@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.40.0':
|
||||
'@rollup/rollup-linux-s390x-gnu@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.40.0':
|
||||
'@rollup/rollup-linux-x64-gnu@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.40.0':
|
||||
'@rollup/rollup-linux-x64-musl@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.40.0':
|
||||
'@rollup/rollup-win32-arm64-msvc@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.40.0':
|
||||
'@rollup/rollup-win32-ia32-msvc@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.40.0':
|
||||
'@rollup/rollup-win32-x64-msvc@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@shadcn/ui@0.0.4':
|
||||
@@ -4574,12 +4582,12 @@ snapshots:
|
||||
postcss: 8.5.3
|
||||
tailwindcss: 4.1.3
|
||||
|
||||
'@tailwindcss/vite@4.1.3(vite@5.4.18(@types/node@20.17.28)(lightningcss@1.29.2))':
|
||||
'@tailwindcss/vite@4.1.3(vite@5.4.19(@types/node@20.17.28)(lightningcss@1.29.2))':
|
||||
dependencies:
|
||||
'@tailwindcss/node': 4.1.3
|
||||
'@tailwindcss/oxide': 4.1.3
|
||||
tailwindcss: 4.1.3
|
||||
vite: 5.4.18(@types/node@20.17.28)(lightningcss@1.29.2)
|
||||
vite: 5.4.19(@types/node@20.17.28)(lightningcss@1.29.2)
|
||||
|
||||
'@tsconfig/node10@1.0.11': {}
|
||||
|
||||
@@ -4623,8 +4631,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 20.17.28
|
||||
|
||||
'@types/cookie@0.6.0': {}
|
||||
|
||||
'@types/estree@1.0.7': {}
|
||||
|
||||
'@types/express-serve-static-core@4.19.6':
|
||||
@@ -4708,6 +4714,8 @@ snapshots:
|
||||
|
||||
'@types/strip-json-comments@0.0.30': {}
|
||||
|
||||
'@types/uuid@10.0.0': {}
|
||||
|
||||
'@types/yargs-parser@21.0.3': {}
|
||||
|
||||
'@types/yargs@17.0.33':
|
||||
@@ -4802,14 +4810,14 @@ snapshots:
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
|
||||
'@vitejs/plugin-react@4.3.4(vite@5.4.18(@types/node@20.17.28)(lightningcss@1.29.2))':
|
||||
'@vitejs/plugin-react@4.3.4(vite@5.4.19(@types/node@20.17.28)(lightningcss@1.29.2))':
|
||||
dependencies:
|
||||
'@babel/core': 7.26.10
|
||||
'@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.10)
|
||||
'@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.10)
|
||||
'@types/babel__core': 7.20.5
|
||||
react-refresh: 0.14.2
|
||||
vite: 5.4.18(@types/node@20.17.28)(lightningcss@1.29.2)
|
||||
vite: 5.4.19(@types/node@20.17.28)(lightningcss@1.29.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -6636,19 +6644,17 @@ snapshots:
|
||||
|
||||
react-refresh@0.14.2: {}
|
||||
|
||||
react-router-dom@7.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
react-router-dom@7.6.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
react-router: 7.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react-router: 7.6.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
|
||||
react-router@7.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
react-router@7.6.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
'@types/cookie': 0.6.0
|
||||
cookie: 1.0.2
|
||||
react: 19.1.0
|
||||
set-cookie-parser: 2.7.1
|
||||
turbo-stream: 2.4.0
|
||||
optionalDependencies:
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
|
||||
@@ -6701,30 +6707,30 @@ snapshots:
|
||||
dependencies:
|
||||
glob: 7.2.3
|
||||
|
||||
rollup@4.40.0:
|
||||
rollup@4.40.1:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.7
|
||||
optionalDependencies:
|
||||
'@rollup/rollup-android-arm-eabi': 4.40.0
|
||||
'@rollup/rollup-android-arm64': 4.40.0
|
||||
'@rollup/rollup-darwin-arm64': 4.40.0
|
||||
'@rollup/rollup-darwin-x64': 4.40.0
|
||||
'@rollup/rollup-freebsd-arm64': 4.40.0
|
||||
'@rollup/rollup-freebsd-x64': 4.40.0
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.40.0
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.40.0
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.40.0
|
||||
'@rollup/rollup-linux-arm64-musl': 4.40.0
|
||||
'@rollup/rollup-linux-loongarch64-gnu': 4.40.0
|
||||
'@rollup/rollup-linux-powerpc64le-gnu': 4.40.0
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.40.0
|
||||
'@rollup/rollup-linux-riscv64-musl': 4.40.0
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.40.0
|
||||
'@rollup/rollup-linux-x64-gnu': 4.40.0
|
||||
'@rollup/rollup-linux-x64-musl': 4.40.0
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.40.0
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.40.0
|
||||
'@rollup/rollup-win32-x64-msvc': 4.40.0
|
||||
'@rollup/rollup-android-arm-eabi': 4.40.1
|
||||
'@rollup/rollup-android-arm64': 4.40.1
|
||||
'@rollup/rollup-darwin-arm64': 4.40.1
|
||||
'@rollup/rollup-darwin-x64': 4.40.1
|
||||
'@rollup/rollup-freebsd-arm64': 4.40.1
|
||||
'@rollup/rollup-freebsd-x64': 4.40.1
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.40.1
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.40.1
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.40.1
|
||||
'@rollup/rollup-linux-arm64-musl': 4.40.1
|
||||
'@rollup/rollup-linux-loongarch64-gnu': 4.40.1
|
||||
'@rollup/rollup-linux-powerpc64le-gnu': 4.40.1
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.40.1
|
||||
'@rollup/rollup-linux-riscv64-musl': 4.40.1
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.40.1
|
||||
'@rollup/rollup-linux-x64-gnu': 4.40.1
|
||||
'@rollup/rollup-linux-x64-musl': 4.40.1
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.40.1
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.40.1
|
||||
'@rollup/rollup-win32-x64-msvc': 4.40.1
|
||||
fsevents: 2.3.3
|
||||
|
||||
router@2.2.0:
|
||||
@@ -6775,7 +6781,7 @@ snapshots:
|
||||
|
||||
send@1.2.0:
|
||||
dependencies:
|
||||
debug: 4.3.6
|
||||
debug: 4.4.0
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
@@ -7073,8 +7079,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
turbo-stream@2.4.0: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
@@ -7120,6 +7124,8 @@ snapshots:
|
||||
|
||||
utils-merge@1.0.1: {}
|
||||
|
||||
uuid@11.1.0: {}
|
||||
|
||||
v8-compile-cache-lib@3.0.1: {}
|
||||
|
||||
v8-to-istanbul@9.3.0:
|
||||
@@ -7132,11 +7138,11 @@ snapshots:
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
vite@5.4.18(@types/node@20.17.28)(lightningcss@1.29.2):
|
||||
vite@5.4.19(@types/node@20.17.28)(lightningcss@1.29.2):
|
||||
dependencies:
|
||||
esbuild: 0.21.5
|
||||
postcss: 8.5.3
|
||||
rollup: 4.40.0
|
||||
rollup: 4.40.1
|
||||
optionalDependencies:
|
||||
'@types/node': 20.17.28
|
||||
fsevents: 2.3.3
|
||||
|
||||
44
scripts/verify-dist.js
Executable file
@@ -0,0 +1,44 @@
|
||||
// scripts/verify-dist.js
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const projectRoot = path.resolve(__dirname, '..');
|
||||
|
||||
// Check if frontend dist exists
|
||||
const frontendDistPath = path.join(projectRoot, 'frontend', 'dist');
|
||||
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
|
||||
|
||||
if (!fs.existsSync(frontendDistPath)) {
|
||||
console.error('❌ Error: frontend/dist directory does not exist!');
|
||||
console.error('Run "npm run frontend:build" to generate the frontend dist files.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(frontendIndexPath)) {
|
||||
console.error('❌ Error: frontend/dist/index.html does not exist!');
|
||||
console.error('Frontend build may be incomplete. Run "npm run frontend:build" again.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check if backend dist exists
|
||||
const backendDistPath = path.join(projectRoot, 'dist');
|
||||
const serverJsPath = path.join(backendDistPath, 'server.js');
|
||||
|
||||
if (!fs.existsSync(backendDistPath)) {
|
||||
console.error('❌ Error: dist directory does not exist!');
|
||||
console.error('Run "npm run backend:build" to generate the backend dist files.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(serverJsPath)) {
|
||||
console.error('❌ Error: dist/server.js does not exist!');
|
||||
console.error('Backend build may be incomplete. Run "npm run backend:build" again.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// All checks passed
|
||||
console.log('✅ Verification passed! Frontend and backend dist files are present.');
|
||||
console.log('📦 Package is ready for publishing.');
|
||||
74722
servers.json
Normal file
@@ -1,19 +1,20 @@
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { McpSettings } from '../types/index.js';
|
||||
import { getConfigFilePath } from '../utils/path.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const defaultConfig = {
|
||||
port: process.env.PORT || 3000,
|
||||
initTimeout: process.env.INIT_TIMEOUT || 300000,
|
||||
timeout: process.env.REQUEST_TIMEOUT || 60000,
|
||||
mcpHubName: 'mcphub',
|
||||
mcpHubVersion: '0.0.1',
|
||||
};
|
||||
|
||||
export const getSettingsPath = (): string => {
|
||||
return path.resolve(process.cwd(), 'mcp_settings.json');
|
||||
return getConfigFilePath('mcp_settings.json', 'Settings');
|
||||
};
|
||||
|
||||
export const loadSettings = (): McpSettings => {
|
||||
@@ -42,4 +43,4 @@ export const expandEnvVars = (value: string): string => {
|
||||
return value.replace(/\$\{([^}]+)\}/g, (_, key) => process.env[key] || '');
|
||||
};
|
||||
|
||||
export default defaultConfig;
|
||||
export default defaultConfig;
|
||||
|
||||
341
src/controllers/groupController.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
import {
|
||||
getAllGroups,
|
||||
getGroupByIdOrName,
|
||||
createGroup,
|
||||
updateGroup,
|
||||
updateGroupServers,
|
||||
deleteGroup,
|
||||
addServerToGroup,
|
||||
removeServerFromGroup,
|
||||
getServersInGroup
|
||||
} from '../services/groupService.js';
|
||||
|
||||
// Get all groups
|
||||
export const getGroups = (_: Request, res: Response): void => {
|
||||
try {
|
||||
const groups = getAllGroups();
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: groups,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get groups information',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get a specific group by ID
|
||||
export const getGroup = (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 group = getGroupByIdOrName(id);
|
||||
if (!group) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Group not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: group,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get group information',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Create a new group
|
||||
export const createNewGroup = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { name, description, servers } = req.body;
|
||||
if (!name) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Group name is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const serverList = Array.isArray(servers) ? servers : [];
|
||||
const newGroup = createGroup(name, description, serverList);
|
||||
if (!newGroup) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Failed to create group or group name already exists',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: newGroup,
|
||||
message: 'Group created successfully',
|
||||
};
|
||||
res.status(201).json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal server error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Update an existing group
|
||||
export const updateExistingGroup = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, description, servers } = req.body;
|
||||
if (!id) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Group ID is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow updating servers along with other fields
|
||||
const updateData: any = {};
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (description !== undefined) updateData.description = description;
|
||||
if (servers !== undefined) updateData.servers = servers;
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'At least one field (name, description, or servers) is required to update',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedGroup = updateGroup(id, updateData);
|
||||
if (!updatedGroup) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Group not found or name already exists',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: updatedGroup,
|
||||
message: 'Group updated successfully',
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal server error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Update servers in a group (batch update)
|
||||
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,
|
||||
message: 'Group ID is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(servers)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Servers must be an array of server names',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedGroup = updateGroupServers(id, servers);
|
||||
if (!updatedGroup) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Group not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: updatedGroup,
|
||||
message: 'Group servers updated successfully',
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal server error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Delete a group
|
||||
export const deleteExistingGroup = (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 success = deleteGroup(id);
|
||||
if (!success) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Group not found or failed to delete',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Group deleted successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal server error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Add server to a group
|
||||
export const addServerToExistingGroup = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { serverName } = req.body;
|
||||
if (!id) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Group ID is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!serverName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server name is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedGroup = addServerToGroup(id, serverName);
|
||||
if (!updatedGroup) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Group or server not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: updatedGroup,
|
||||
message: 'Server added to group successfully',
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal server error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Remove server from a group
|
||||
export const removeServerFromExistingGroup = (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 updatedGroup = removeServerFromGroup(id, serverName);
|
||||
if (!updatedGroup) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Group not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: updatedGroup,
|
||||
message: 'Server removed from group successfully',
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal server error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get servers in a group
|
||||
export const getGroupServers = (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 group = getGroupByIdOrName(id);
|
||||
if (!group) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Group not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: group.servers,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get group servers',
|
||||
});
|
||||
}
|
||||
};
|
||||
55
src/controllers/logController.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// filepath: /Users/sunmeng/code/github/mcphub/src/controllers/logController.ts
|
||||
import { Request, Response } from 'express';
|
||||
import logService from '../services/logService.js';
|
||||
|
||||
// Get all logs
|
||||
export const getAllLogs = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const logs = logService.getLogs();
|
||||
res.json({ success: true, data: logs });
|
||||
} catch (error) {
|
||||
console.error('Error getting logs:', error);
|
||||
res.status(500).json({ success: false, error: 'Error getting logs' });
|
||||
}
|
||||
};
|
||||
|
||||
// Clear all logs
|
||||
export const clearLogs = (req: Request, res: Response): void => {
|
||||
try {
|
||||
logService.clearLogs();
|
||||
res.json({ success: true, message: 'Logs cleared successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error clearing logs:', error);
|
||||
res.status(500).json({ success: false, error: 'Error clearing logs' });
|
||||
}
|
||||
};
|
||||
|
||||
// Stream logs via SSE
|
||||
export const streamLogs = (req: Request, res: Response): void => {
|
||||
try {
|
||||
// Set headers for SSE
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
});
|
||||
|
||||
// Send initial data
|
||||
const logs = logService.getLogs();
|
||||
res.write(`data: ${JSON.stringify({ type: 'initial', logs })}\n\n`);
|
||||
|
||||
// Subscribe to log events
|
||||
const unsubscribe = logService.subscribe((log) => {
|
||||
res.write(`data: ${JSON.stringify({ type: 'log', log })}\n\n`);
|
||||
});
|
||||
|
||||
// Handle client disconnect
|
||||
req.on('close', () => {
|
||||
unsubscribe();
|
||||
console.log('Client disconnected from log stream');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error streaming logs:', error);
|
||||
res.status(500).json({ success: false, error: 'Error streaming logs' });
|
||||
}
|
||||
};
|
||||
154
src/controllers/marketController.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
import {
|
||||
getMarketServers,
|
||||
getMarketServerByName,
|
||||
getMarketCategories,
|
||||
getMarketTags,
|
||||
searchMarketServers,
|
||||
filterMarketServersByCategory,
|
||||
filterMarketServersByTag
|
||||
} from '../services/marketService.js';
|
||||
|
||||
// Get all market servers
|
||||
export const getAllMarketServers = (_: Request, res: Response): void => {
|
||||
try {
|
||||
const marketServers = Object.values(getMarketServers());
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: marketServers,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get market servers information',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get a specific market server by name
|
||||
export const getMarketServer = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
if (!name) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server name is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const server = getMarketServerByName(name);
|
||||
if (!server) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Market server not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: server,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get market server information',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get all market categories
|
||||
export const getAllMarketCategories = (_: Request, res: Response): void => {
|
||||
try {
|
||||
const categories = getMarketCategories();
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: categories,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get market categories',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get all market tags
|
||||
export const getAllMarketTags = (_: Request, res: Response): void => {
|
||||
try {
|
||||
const tags = getMarketTags();
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: tags,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get market tags',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Search market servers
|
||||
export const searchMarketServersByQuery = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { query } = req.query;
|
||||
const searchQuery = typeof query === 'string' ? query : '';
|
||||
|
||||
const servers = searchMarketServers(searchQuery);
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: servers,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to search market servers',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Filter market servers by category
|
||||
export const getMarketServersByCategory = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { category } = req.params;
|
||||
|
||||
const servers = filterMarketServersByCategory(category);
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: servers,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to filter market servers by category',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Filter market servers by tag
|
||||
export const getMarketServersByTag = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { tag } = req.params;
|
||||
|
||||
const servers = filterMarketServersByTag(tag);
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: servers,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to filter market servers by tag',
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -5,10 +5,10 @@ import {
|
||||
addServer,
|
||||
removeServer,
|
||||
updateMcpServer,
|
||||
recreateMcpServer,
|
||||
notifyToolChanged,
|
||||
toggleServerStatus,
|
||||
} from '../services/mcpService.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
|
||||
export const getAllServers = (_: Request, res: Response): void => {
|
||||
try {
|
||||
@@ -69,9 +69,27 @@ export const createServer = async (req: Request, res: Response): Promise<void> =
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate the server type if specified
|
||||
if (config.type && !['stdio', 'sse', 'streamable-http'].includes(config.type)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server type must be one of: stdio, sse, streamable-http',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate that URL is provided for sse and streamable-http types
|
||||
if ((config.type === 'sse' || config.type === 'streamable-http') && !config.url) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `URL is required for ${config.type} server type`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await addServer(name, config);
|
||||
if (result.success) {
|
||||
recreateMcpServer();
|
||||
notifyToolChanged();
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Server added successfully',
|
||||
@@ -93,7 +111,6 @@ export const createServer = async (req: Request, res: Response): Promise<void> =
|
||||
export const deleteServer = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
|
||||
if (!name) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -103,9 +120,8 @@ export const deleteServer = async (req: Request, res: Response): Promise<void> =
|
||||
}
|
||||
|
||||
const result = removeServer(name);
|
||||
|
||||
if (result.success) {
|
||||
recreateMcpServer();
|
||||
notifyToolChanged();
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Server removed successfully',
|
||||
@@ -128,7 +144,6 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
|
||||
try {
|
||||
const { name } = req.params;
|
||||
const { config } = req.body;
|
||||
|
||||
if (!name) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -153,9 +168,27 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate the server type if specified
|
||||
if (config.type && !['stdio', 'sse', 'streamable-http'].includes(config.type)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server type must be one of: stdio, sse, streamable-http',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate that URL is provided for sse and streamable-http types
|
||||
if ((config.type === 'sse' || config.type === 'streamable-http') && !config.url) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `URL is required for ${config.type} server type`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await updateMcpServer(name, config);
|
||||
if (result.success) {
|
||||
recreateMcpServer();
|
||||
notifyToolChanged();
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Server updated successfully',
|
||||
@@ -178,7 +211,6 @@ export const getServerConfig = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
const settings = loadSettings();
|
||||
|
||||
if (!settings.mcpServers || !settings.mcpServers[name]) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -189,7 +221,6 @@ export const getServerConfig = (req: Request, res: Response): void => {
|
||||
|
||||
const serverInfo = getServersInfo().find((s) => s.name === name);
|
||||
const serverConfig = settings.mcpServers[name];
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: {
|
||||
@@ -213,7 +244,6 @@ export const toggleServer = async (req: Request, res: Response): Promise<void> =
|
||||
try {
|
||||
const { name } = req.params;
|
||||
const { enabled } = req.body;
|
||||
|
||||
if (!name) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -231,9 +261,8 @@ export const toggleServer = async (req: Request, res: Response): Promise<void> =
|
||||
}
|
||||
|
||||
const result = await toggleServerStatus(name, enabled);
|
||||
|
||||
if (result.success) {
|
||||
recreateMcpServer();
|
||||
notifyToolChanged();
|
||||
res.json({
|
||||
success: true,
|
||||
message: result.message || `Server ${enabled ? 'enabled' : 'disabled'} successfully`,
|
||||
@@ -251,3 +280,102 @@ export const toggleServer = async (req: Request, res: Response): Promise<void> =
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { routing, install } = req.body;
|
||||
|
||||
if (
|
||||
(!routing ||
|
||||
(typeof routing.enableGlobalRoute !== 'boolean' &&
|
||||
typeof routing.enableGroupNameRoute !== 'boolean' &&
|
||||
typeof routing.enableBearerAuth !== 'boolean' &&
|
||||
typeof routing.bearerAuthKey !== 'string')) &&
|
||||
(!install ||
|
||||
(typeof install.pythonIndexUrl !== 'string' && typeof install.npmRegistry !== 'string'))
|
||||
) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Invalid system configuration provided',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = loadSettings();
|
||||
if (!settings.systemConfig) {
|
||||
settings.systemConfig = {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
},
|
||||
install: {
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!settings.systemConfig.routing) {
|
||||
settings.systemConfig.routing = {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
};
|
||||
}
|
||||
|
||||
if (!settings.systemConfig.install) {
|
||||
settings.systemConfig.install = {
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
};
|
||||
}
|
||||
|
||||
if (routing) {
|
||||
if (typeof routing.enableGlobalRoute === 'boolean') {
|
||||
settings.systemConfig.routing.enableGlobalRoute = routing.enableGlobalRoute;
|
||||
}
|
||||
|
||||
if (typeof routing.enableGroupNameRoute === 'boolean') {
|
||||
settings.systemConfig.routing.enableGroupNameRoute = routing.enableGroupNameRoute;
|
||||
}
|
||||
|
||||
if (typeof routing.enableBearerAuth === 'boolean') {
|
||||
settings.systemConfig.routing.enableBearerAuth = routing.enableBearerAuth;
|
||||
}
|
||||
|
||||
if (typeof routing.bearerAuthKey === 'string') {
|
||||
settings.systemConfig.routing.bearerAuthKey = routing.bearerAuthKey;
|
||||
}
|
||||
}
|
||||
|
||||
if (install) {
|
||||
if (typeof install.pythonIndexUrl === 'string') {
|
||||
settings.systemConfig.install.pythonIndexUrl = install.pythonIndexUrl;
|
||||
}
|
||||
if (typeof install.npmRegistry === 'string') {
|
||||
settings.systemConfig.install.npmRegistry = install.npmRegistry;
|
||||
}
|
||||
}
|
||||
|
||||
if (saveSettings(settings)) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: settings.systemConfig,
|
||||
message: 'System configuration updated successfully',
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to save system configuration',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal server error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,8 +6,10 @@ const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this';
|
||||
|
||||
// Middleware to authenticate JWT token
|
||||
export const auth = (req: Request, res: Response, next: NextFunction): void => {
|
||||
// Get token from header
|
||||
const token = req.header('x-auth-token');
|
||||
// Get token from header or query parameter
|
||||
const headerToken = req.header('x-auth-token');
|
||||
const queryToken = req.query.token as string;
|
||||
const token = headerToken || queryToken;
|
||||
|
||||
// Check if no token
|
||||
if (!token) {
|
||||
|
||||
@@ -1,8 +1,36 @@
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
import fs from 'fs';
|
||||
import { auth } from './auth.js';
|
||||
import { initializeDefaultUser } from '../models/User.js';
|
||||
|
||||
// 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,
|
||||
@@ -17,7 +45,8 @@ export const errorHandler = (
|
||||
};
|
||||
|
||||
export const initMiddlewares = (app: express.Application): void => {
|
||||
app.use(express.static('frontend/dist'));
|
||||
// Serve static files from the dynamically determined frontend path
|
||||
app.use(express.static(frontendPath));
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (req.path !== '/sse' && req.path !== '/messages') {
|
||||
@@ -36,7 +65,8 @@ export const initMiddlewares = (app: express.Application): void => {
|
||||
app.use('/api', auth);
|
||||
|
||||
app.get('/', (_req: Request, res: Response) => {
|
||||
res.sendFile(path.join(process.cwd(), 'frontend', 'dist', 'index.html'));
|
||||
// Serve the frontend application
|
||||
res.sendFile(path.join(frontendPath, 'index.html'));
|
||||
});
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
@@ -7,13 +7,39 @@ import {
|
||||
updateServer,
|
||||
deleteServer,
|
||||
toggleServer,
|
||||
updateSystemConfig
|
||||
} from '../controllers/serverController.js';
|
||||
import {
|
||||
getGroups,
|
||||
getGroup,
|
||||
createNewGroup,
|
||||
updateExistingGroup,
|
||||
deleteExistingGroup,
|
||||
addServerToExistingGroup,
|
||||
removeServerFromExistingGroup,
|
||||
getGroupServers,
|
||||
updateGroupServersBatch
|
||||
} from '../controllers/groupController.js';
|
||||
import {
|
||||
getAllMarketServers,
|
||||
getMarketServer,
|
||||
getAllMarketCategories,
|
||||
getAllMarketTags,
|
||||
searchMarketServersByQuery,
|
||||
getMarketServersByCategory,
|
||||
getMarketServersByTag
|
||||
} from '../controllers/marketController.js';
|
||||
import {
|
||||
login,
|
||||
register,
|
||||
getCurrentUser,
|
||||
changePassword
|
||||
} from '../controllers/authController.js';
|
||||
import {
|
||||
getAllLogs,
|
||||
clearLogs,
|
||||
streamLogs
|
||||
} from '../controllers/logController.js';
|
||||
import { auth } from '../middlewares/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -26,6 +52,33 @@ export const initRoutes = (app: express.Application): void => {
|
||||
router.put('/servers/:name', updateServer);
|
||||
router.delete('/servers/:name', deleteServer);
|
||||
router.post('/servers/:name/toggle', toggleServer);
|
||||
router.put('/system-config', updateSystemConfig);
|
||||
|
||||
// Group management routes
|
||||
router.get('/groups', getGroups);
|
||||
router.get('/groups/:id', getGroup);
|
||||
router.post('/groups', createNewGroup);
|
||||
router.put('/groups/:id', updateExistingGroup);
|
||||
router.delete('/groups/:id', deleteExistingGroup);
|
||||
router.post('/groups/:id/servers', addServerToExistingGroup);
|
||||
router.delete('/groups/:id/servers/:serverName', removeServerFromExistingGroup);
|
||||
router.get('/groups/:id/servers', getGroupServers);
|
||||
// New route for batch updating servers in a group
|
||||
router.put('/groups/:id/servers/batch', updateGroupServersBatch);
|
||||
|
||||
// Market routes
|
||||
router.get('/market/servers', getAllMarketServers);
|
||||
router.get('/market/servers/search', searchMarketServersByQuery);
|
||||
router.get('/market/servers/:name', getMarketServer);
|
||||
router.get('/market/categories', getAllMarketCategories);
|
||||
router.get('/market/categories/:category', getMarketServersByCategory);
|
||||
router.get('/market/tags', getAllMarketTags);
|
||||
router.get('/market/tags/:tag', getMarketServersByTag);
|
||||
|
||||
// Log routes
|
||||
router.get('/logs', getAllLogs);
|
||||
router.delete('/logs', clearLogs);
|
||||
router.get('/logs/stream', streamLogs);
|
||||
|
||||
// Auth routes (these will NOT be protected by auth middleware)
|
||||
app.post('/auth/login', [
|
||||
|
||||
165
src/server.ts
@@ -1,15 +1,27 @@
|
||||
import express from 'express';
|
||||
import config from './config/index.js';
|
||||
import { initMcpServer, registerAllTools } from './services/mcpService.js';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import fs from 'fs';
|
||||
import { initMcpServer } from './services/mcpService.js';
|
||||
import { initMiddlewares } from './middlewares/index.js';
|
||||
import { initRoutes } from './routes/index.js';
|
||||
import { handleSseConnection, handleSseMessage } from './services/sseService.js';
|
||||
import { migrateUserData } from './utils/migration.js';
|
||||
import {
|
||||
handleSseConnection,
|
||||
handleSseMessage,
|
||||
handleMcpPostRequest,
|
||||
handleMcpOtherRequest,
|
||||
} from './services/sseService.js';
|
||||
import { initializeDefaultUser } from './models/User.js';
|
||||
|
||||
// Get the directory name in ESM
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export class AppServer {
|
||||
private app: express.Application;
|
||||
private port: number | string;
|
||||
private frontendPath: string | null = null;
|
||||
|
||||
constructor() {
|
||||
this.app = express();
|
||||
@@ -18,34 +30,165 @@ export class AppServer {
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
try {
|
||||
// Migrate user data from users.json to mcp_settings.json if needed
|
||||
migrateUserData();
|
||||
|
||||
// Initialize default admin user if no users exist
|
||||
await initializeDefaultUser();
|
||||
|
||||
const mcpServer = await initMcpServer(config.mcpHubName, config.mcpHubVersion);
|
||||
await registerAllTools(mcpServer, true);
|
||||
|
||||
initMiddlewares(this.app);
|
||||
initRoutes(this.app);
|
||||
this.app.get('/sse', (req, res) => handleSseConnection(req, res));
|
||||
this.app.post('/messages', handleSseMessage);
|
||||
console.log('Server initialized successfully');
|
||||
|
||||
initMcpServer(config.mcpHubName, config.mcpHubVersion)
|
||||
.then(() => {
|
||||
console.log('MCP server initialized successfully');
|
||||
this.app.get('/sse/:group?', (req, res) => handleSseConnection(req, res));
|
||||
this.app.post('/messages', handleSseMessage);
|
||||
this.app.post('/mcp/:group?', handleMcpPostRequest);
|
||||
this.app.get('/mcp/:group?', handleMcpOtherRequest);
|
||||
this.app.delete('/mcp/:group?', handleMcpOtherRequest);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error initializing MCP server:', error);
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
// Find and serve frontend
|
||||
this.findAndServeFrontend();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error initializing server:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private findAndServeFrontend(): void {
|
||||
// Find frontend path
|
||||
this.frontendPath = this.findFrontendDistPath();
|
||||
|
||||
if (this.frontendPath) {
|
||||
console.log(`Serving frontend from: ${this.frontendPath}`);
|
||||
this.app.use(express.static(this.frontendPath));
|
||||
|
||||
// Add the wildcard route for SPA
|
||||
if (fs.existsSync(path.join(this.frontendPath, 'index.html'))) {
|
||||
this.app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(this.frontendPath!, 'index.html'));
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.warn('Frontend dist directory not found. Server will run without frontend.');
|
||||
this.app.get('/', (_req, res) => {
|
||||
res
|
||||
.status(404)
|
||||
.send('Frontend not found. MCPHub API is running, but the UI is not available.');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.app.listen(this.port, () => {
|
||||
console.log(`Server is running on port ${this.port}`);
|
||||
if (this.frontendPath) {
|
||||
console.log(`Open http://localhost:${this.port} in your browser to access MCPHub UI`);
|
||||
} else {
|
||||
console.log(
|
||||
`MCPHub API is running on http://localhost:${this.port}, but the UI is not available`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getApp(): express.Application {
|
||||
return this.app;
|
||||
}
|
||||
|
||||
// Helper method to find frontend dist path in different environments
|
||||
private findFrontendDistPath(): string | null {
|
||||
// Debug flag for detailed logging
|
||||
const debug = process.env.DEBUG === 'true';
|
||||
|
||||
if (debug) {
|
||||
console.log('DEBUG: Current directory:', process.cwd());
|
||||
console.log('DEBUG: Script directory:', __dirname);
|
||||
}
|
||||
|
||||
// First, find the package root directory
|
||||
const packageRoot = this.findPackageRoot();
|
||||
|
||||
if (debug) {
|
||||
console.log('DEBUG: Using package root:', packageRoot);
|
||||
}
|
||||
|
||||
if (!packageRoot) {
|
||||
console.warn('Could not determine package root directory');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for frontend dist in the standard location
|
||||
const frontendDistPath = path.join(packageRoot, 'frontend', 'dist');
|
||||
|
||||
if (debug) {
|
||||
console.log(`DEBUG: Checking frontend at: ${frontendDistPath}`);
|
||||
}
|
||||
|
||||
if (
|
||||
fs.existsSync(frontendDistPath) &&
|
||||
fs.existsSync(path.join(frontendDistPath, 'index.html'))
|
||||
) {
|
||||
return frontendDistPath;
|
||||
}
|
||||
|
||||
console.warn('Frontend distribution not found at', frontendDistPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper method to find the package root (where package.json is located)
|
||||
private findPackageRoot(): string | null {
|
||||
const debug = process.env.DEBUG === 'true';
|
||||
|
||||
// Possible locations for package.json
|
||||
const possibleRoots = [
|
||||
// Standard npm package location
|
||||
path.resolve(__dirname, '..', '..'),
|
||||
// Current working directory
|
||||
process.cwd(),
|
||||
// When running from dist directory
|
||||
path.resolve(__dirname, '..'),
|
||||
// When installed via npx
|
||||
path.resolve(__dirname, '..', '..', '..'),
|
||||
];
|
||||
|
||||
// Special handling for npx
|
||||
if (process.argv[1] && process.argv[1].includes('_npx')) {
|
||||
const npxDir = path.dirname(process.argv[1]);
|
||||
possibleRoots.unshift(path.resolve(npxDir, '..'));
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log('DEBUG: Checking for package.json in:', possibleRoots);
|
||||
}
|
||||
|
||||
for (const root of possibleRoots) {
|
||||
const packageJsonPath = path.join(root, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
if (pkg.name === 'mcphub' || pkg.name === '@samanhappy/mcphub') {
|
||||
if (debug) {
|
||||
console.log(`DEBUG: Found package.json at ${packageJsonPath}`);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
} catch (e) {
|
||||
if (debug) {
|
||||
console.error(`DEBUG: Failed to parse package.json at ${packageJsonPath}:`, e);
|
||||
}
|
||||
// Continue to the next potential root
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default AppServer;
|
||||
|
||||
232
src/services/groupService.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { IGroup, McpSettings } from '../types/index.js';
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
import { notifyToolChanged } from './mcpService.js';
|
||||
|
||||
// Get all groups
|
||||
export const getAllGroups = (): IGroup[] => {
|
||||
const settings = loadSettings();
|
||||
return settings.groups || [];
|
||||
};
|
||||
|
||||
// Get group by ID or name
|
||||
export const getGroupByIdOrName = (key: string): IGroup | undefined => {
|
||||
const settings = loadSettings();
|
||||
const routingConfig = settings.systemConfig?.routing || {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
};
|
||||
const groups = getAllGroups();
|
||||
return (
|
||||
groups.find(
|
||||
(group) => group.id === key || (group.name === key && routingConfig.enableGroupNameRoute),
|
||||
) || undefined
|
||||
);
|
||||
};
|
||||
|
||||
// Create a new group
|
||||
export const createGroup = (
|
||||
name: string,
|
||||
description?: string,
|
||||
servers: string[] = [],
|
||||
): IGroup | null => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
const groups = settings.groups || [];
|
||||
|
||||
// Check if group with same name already exists
|
||||
if (groups.some((group) => group.name === name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter out non-existent servers
|
||||
const validServers = servers.filter((serverName) => settings.mcpServers[serverName]);
|
||||
|
||||
const newGroup: IGroup = {
|
||||
id: uuidv4(),
|
||||
name,
|
||||
description,
|
||||
servers: validServers,
|
||||
};
|
||||
|
||||
// Initialize groups array if it doesn't exist
|
||||
if (!settings.groups) {
|
||||
settings.groups = [];
|
||||
}
|
||||
|
||||
settings.groups.push(newGroup);
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return newGroup;
|
||||
} catch (error) {
|
||||
console.error('Failed to create group:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Update an existing group
|
||||
export const updateGroup = (id: string, data: Partial<IGroup>): IGroup | null => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.groups) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const groupIndex = settings.groups.findIndex((group) => group.id === id);
|
||||
if (groupIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for name uniqueness if name is being updated
|
||||
if (data.name && settings.groups.some((g) => g.name === data.name && g.id !== id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If servers array is provided, validate server existence
|
||||
if (data.servers) {
|
||||
data.servers = data.servers.filter((serverName) => settings.mcpServers[serverName]);
|
||||
}
|
||||
|
||||
const updatedGroup = {
|
||||
...settings.groups[groupIndex],
|
||||
...data,
|
||||
};
|
||||
|
||||
settings.groups[groupIndex] = updatedGroup;
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
notifyToolChanged();
|
||||
return updatedGroup;
|
||||
} catch (error) {
|
||||
console.error(`Failed to update group ${id}:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Update servers in a group (batch update)
|
||||
export const updateGroupServers = (groupId: string, servers: string[]): IGroup | null => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.groups) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const groupIndex = settings.groups.findIndex((group) => group.id === groupId);
|
||||
if (groupIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter out non-existent servers
|
||||
const validServers = servers.filter((serverName) => settings.mcpServers[serverName]);
|
||||
|
||||
settings.groups[groupIndex].servers = validServers;
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
notifyToolChanged();
|
||||
return settings.groups[groupIndex];
|
||||
} catch (error) {
|
||||
console.error(`Failed to update servers for group ${groupId}:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Delete a group
|
||||
export const deleteGroup = (id: string): boolean => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.groups) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const initialLength = settings.groups.length;
|
||||
settings.groups = settings.groups.filter((group) => group.id !== id);
|
||||
|
||||
if (settings.groups.length === initialLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return saveSettings(settings);
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete group ${id}:`, error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Add server to group
|
||||
export const addServerToGroup = (groupId: string, serverName: string): IGroup | null => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.groups) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify server exists
|
||||
if (!settings.mcpServers[serverName]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const groupIndex = settings.groups.findIndex((group) => group.id === groupId);
|
||||
if (groupIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const group = settings.groups[groupIndex];
|
||||
|
||||
// Add server to group if not already in it
|
||||
if (!group.servers.includes(serverName)) {
|
||||
group.servers.push(serverName);
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
notifyToolChanged();
|
||||
return group;
|
||||
} catch (error) {
|
||||
console.error(`Failed to add server ${serverName} to group ${groupId}:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Remove server from group
|
||||
export const removeServerFromGroup = (groupId: string, serverName: string): IGroup | null => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.groups) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const groupIndex = settings.groups.findIndex((group) => group.id === groupId);
|
||||
if (groupIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const group = settings.groups[groupIndex];
|
||||
group.servers = group.servers.filter((name) => name !== serverName);
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return group;
|
||||
} catch (error) {
|
||||
console.error(`Failed to remove server ${serverName} from group ${groupId}:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Get all servers in a group
|
||||
export const getServersInGroup = (groupId: string): string[] => {
|
||||
const group = getGroupByIdOrName(groupId);
|
||||
return group ? group.servers : [];
|
||||
};
|
||||
204
src/services/logService.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
// filepath: /Users/sunmeng/code/github/mcphub/src/services/logService.ts
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as os from 'os';
|
||||
import * as process from 'process';
|
||||
|
||||
interface LogEntry {
|
||||
timestamp: number;
|
||||
type: 'info' | 'error' | 'warn' | 'debug';
|
||||
source: string;
|
||||
message: string;
|
||||
processId?: string;
|
||||
}
|
||||
|
||||
// ANSI color codes for console output
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bright: '\x1b[1m',
|
||||
dim: '\x1b[2m',
|
||||
underscore: '\x1b[4m',
|
||||
blink: '\x1b[5m',
|
||||
reverse: '\x1b[7m',
|
||||
hidden: '\x1b[8m',
|
||||
|
||||
black: '\x1b[30m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
magenta: '\x1b[35m',
|
||||
cyan: '\x1b[36m',
|
||||
white: '\x1b[37m',
|
||||
|
||||
bgBlack: '\x1b[40m',
|
||||
bgRed: '\x1b[41m',
|
||||
bgGreen: '\x1b[42m',
|
||||
bgYellow: '\x1b[43m',
|
||||
bgBlue: '\x1b[44m',
|
||||
bgMagenta: '\x1b[45m',
|
||||
bgCyan: '\x1b[46m',
|
||||
bgWhite: '\x1b[47m'
|
||||
};
|
||||
|
||||
// Level colors for different log types
|
||||
const levelColors = {
|
||||
info: colors.green,
|
||||
error: colors.red,
|
||||
warn: colors.yellow,
|
||||
debug: colors.cyan
|
||||
};
|
||||
|
||||
// Maximum number of logs to keep in memory
|
||||
const MAX_LOGS = 1000;
|
||||
|
||||
class LogService {
|
||||
private logs: LogEntry[] = [];
|
||||
private logEmitter = new EventEmitter();
|
||||
private childProcesses: { [id: string]: ChildProcess } = {};
|
||||
private mainProcessId: string;
|
||||
private hostname: string;
|
||||
|
||||
constructor() {
|
||||
this.mainProcessId = process.pid.toString();
|
||||
this.hostname = os.hostname();
|
||||
this.overrideConsole();
|
||||
}
|
||||
|
||||
// Format a timestamp for display
|
||||
private formatTimestamp(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
// Format a log message for console output
|
||||
private formatLogMessage(type: 'info' | 'error' | 'warn' | 'debug', source: string, message: string, processId?: string): string {
|
||||
const timestamp = this.formatTimestamp(Date.now());
|
||||
const pid = processId || this.mainProcessId;
|
||||
const level = type.toUpperCase();
|
||||
const levelColor = levelColors[type];
|
||||
|
||||
return `${colors.dim}[${timestamp}]${colors.reset} ${levelColor}${colors.bright}[${level}]${colors.reset} ${colors.blue}[${pid}]${colors.reset} ${colors.magenta}[${source}]${colors.reset} ${message}`;
|
||||
}
|
||||
|
||||
// Override console methods to capture logs
|
||||
private overrideConsole() {
|
||||
const originalConsoleLog = console.log;
|
||||
const originalConsoleError = console.error;
|
||||
const originalConsoleWarn = console.warn;
|
||||
const originalConsoleDebug = console.debug;
|
||||
|
||||
console.log = (...args: any[]) => {
|
||||
const message = args.map(arg => this.formatArgument(arg)).join(' ');
|
||||
this.addLog('info', 'main', message);
|
||||
originalConsoleLog.apply(console, [this.formatLogMessage('info', 'main', message)]);
|
||||
};
|
||||
|
||||
console.error = (...args: any[]) => {
|
||||
const message = args.map(arg => this.formatArgument(arg)).join(' ');
|
||||
this.addLog('error', 'main', message);
|
||||
originalConsoleError.apply(console, [this.formatLogMessage('error', 'main', message)]);
|
||||
};
|
||||
|
||||
console.warn = (...args: any[]) => {
|
||||
const message = args.map(arg => this.formatArgument(arg)).join(' ');
|
||||
this.addLog('warn', 'main', message);
|
||||
originalConsoleWarn.apply(console, [this.formatLogMessage('warn', 'main', message)]);
|
||||
};
|
||||
|
||||
console.debug = (...args: any[]) => {
|
||||
const message = args.map(arg => this.formatArgument(arg)).join(' ');
|
||||
this.addLog('debug', 'main', message);
|
||||
originalConsoleDebug.apply(console, [this.formatLogMessage('debug', 'main', message)]);
|
||||
};
|
||||
}
|
||||
|
||||
// Format an argument for logging
|
||||
private formatArgument(arg: any): string {
|
||||
if (arg === null) return 'null';
|
||||
if (arg === undefined) return 'undefined';
|
||||
if (typeof arg === 'object') {
|
||||
try {
|
||||
return JSON.stringify(arg, null, 2);
|
||||
} catch (e) {
|
||||
return String(arg);
|
||||
}
|
||||
}
|
||||
return String(arg);
|
||||
}
|
||||
|
||||
// Add a log entry to the logs array
|
||||
private addLog(type: 'info' | 'error' | 'warn' | 'debug', source: string, message: string, processId?: string) {
|
||||
const log: LogEntry = {
|
||||
timestamp: Date.now(),
|
||||
type,
|
||||
source,
|
||||
message,
|
||||
processId: processId || this.mainProcessId
|
||||
};
|
||||
|
||||
this.logs.push(log);
|
||||
|
||||
// Limit the number of logs kept in memory
|
||||
if (this.logs.length > MAX_LOGS) {
|
||||
this.logs.shift();
|
||||
}
|
||||
|
||||
// Emit the log event for SSE subscribers
|
||||
this.logEmitter.emit('log', log);
|
||||
}
|
||||
|
||||
// Capture output from a child process
|
||||
public captureChildProcess(command: string, args: string[], processId: string): ChildProcess {
|
||||
const childProcess = spawn(command, args);
|
||||
this.childProcesses[processId] = childProcess;
|
||||
|
||||
childProcess.stdout.on('data', (data) => {
|
||||
const output = data.toString().trim();
|
||||
if (output) {
|
||||
this.addLog('info', 'child-process', output, processId);
|
||||
console.log(this.formatLogMessage('info', 'child-process', output, processId));
|
||||
}
|
||||
});
|
||||
|
||||
childProcess.stderr.on('data', (data) => {
|
||||
const output = data.toString().trim();
|
||||
if (output) {
|
||||
this.addLog('error', 'child-process', output, processId);
|
||||
console.error(this.formatLogMessage('error', 'child-process', output, processId));
|
||||
}
|
||||
});
|
||||
|
||||
childProcess.on('close', (code) => {
|
||||
const message = `Process exited with code ${code}`;
|
||||
this.addLog('info', 'child-process', message, processId);
|
||||
console.log(this.formatLogMessage('info', 'child-process', message, processId));
|
||||
delete this.childProcesses[processId];
|
||||
});
|
||||
|
||||
return childProcess;
|
||||
}
|
||||
|
||||
// Get all logs
|
||||
public getLogs(): LogEntry[] {
|
||||
return this.logs;
|
||||
}
|
||||
|
||||
// Subscribe to log events
|
||||
public subscribe(callback: (log: LogEntry) => void): () => void {
|
||||
this.logEmitter.on('log', callback);
|
||||
return () => {
|
||||
this.logEmitter.off('log', callback);
|
||||
};
|
||||
}
|
||||
|
||||
// Clear all logs
|
||||
public clearLogs(): void {
|
||||
this.logs = [];
|
||||
this.logEmitter.emit('clear');
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance
|
||||
const logService = new LogService();
|
||||
export default logService;
|
||||
116
src/services/marketService.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import fs from 'fs';
|
||||
import { MarketServer } from '../types/index.js';
|
||||
import { getConfigFilePath } from '../utils/path.js';
|
||||
|
||||
// Get path to the servers.json file
|
||||
export const getServersJsonPath = (): string => {
|
||||
return getConfigFilePath('servers.json', 'Servers');
|
||||
};
|
||||
|
||||
// Load all market servers from servers.json
|
||||
export const getMarketServers = (): Record<string, MarketServer> => {
|
||||
try {
|
||||
const serversJsonPath = getServersJsonPath();
|
||||
const data = fs.readFileSync(serversJsonPath, 'utf8');
|
||||
const serversObj = JSON.parse(data) as Record<string, MarketServer>;
|
||||
|
||||
const sortedEntries = Object.entries(serversObj).sort(([, serverA], [, serverB]) => {
|
||||
if (serverA.is_official && !serverB.is_official) return -1;
|
||||
if (!serverA.is_official && serverB.is_official) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return Object.fromEntries(sortedEntries);
|
||||
} catch (error) {
|
||||
console.error('Failed to load servers from servers.json:', error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
// Get a specific market server by name
|
||||
export const getMarketServerByName = (name: string): MarketServer | null => {
|
||||
const servers = getMarketServers();
|
||||
return servers[name] || null;
|
||||
};
|
||||
|
||||
// Get all categories from market servers
|
||||
export const getMarketCategories = (): string[] => {
|
||||
const servers = getMarketServers();
|
||||
const categories = new Set<string>();
|
||||
|
||||
Object.values(servers).forEach((server) => {
|
||||
server.categories?.forEach((category) => {
|
||||
categories.add(category);
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(categories).sort();
|
||||
};
|
||||
|
||||
// Get all tags from market servers
|
||||
export const getMarketTags = (): string[] => {
|
||||
const servers = getMarketServers();
|
||||
const tags = new Set<string>();
|
||||
|
||||
Object.values(servers).forEach((server) => {
|
||||
server.tags?.forEach((tag) => {
|
||||
tags.add(tag);
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(tags).sort();
|
||||
};
|
||||
|
||||
// Search market servers by query
|
||||
export const searchMarketServers = (query: string): MarketServer[] => {
|
||||
const servers = getMarketServers();
|
||||
const searchTerms = query
|
||||
.toLowerCase()
|
||||
.split(' ')
|
||||
.filter((term) => term.length > 0);
|
||||
|
||||
if (searchTerms.length === 0) {
|
||||
return Object.values(servers);
|
||||
}
|
||||
|
||||
return Object.values(servers).filter((server) => {
|
||||
// Search in name, display_name, description, categories, and tags
|
||||
const searchableText = [
|
||||
server.name,
|
||||
server.display_name,
|
||||
server.description,
|
||||
...(server.categories || []),
|
||||
...(server.tags || []),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
return searchTerms.some((term) => searchableText.includes(term));
|
||||
});
|
||||
};
|
||||
|
||||
// Filter market servers by category
|
||||
export const filterMarketServersByCategory = (category: string): MarketServer[] => {
|
||||
const servers = getMarketServers();
|
||||
|
||||
if (!category) {
|
||||
return Object.values(servers);
|
||||
}
|
||||
|
||||
return Object.values(servers).filter((server) => {
|
||||
return server.categories?.includes(category);
|
||||
});
|
||||
};
|
||||
|
||||
// Filter market servers by tag
|
||||
export const filterMarketServersByTag = (tag: string): MarketServer[] => {
|
||||
const servers = getMarketServers();
|
||||
|
||||
if (!tag) {
|
||||
return Object.values(servers);
|
||||
}
|
||||
|
||||
return Object.values(servers).filter((server) => {
|
||||
return server.tags?.includes(tag);
|
||||
});
|
||||
};
|
||||
@@ -1,44 +1,52 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
||||
import * as z from 'zod';
|
||||
import { ZodType, ZodRawShape } from 'zod';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { ServerInfo, ServerConfig } from '../types/index.js';
|
||||
import { loadSettings, saveSettings, expandEnvVars } from '../config/index.js';
|
||||
import config from '../config/index.js';
|
||||
import { getGroup } from './sseService.js';
|
||||
import { getServersInGroup } from './groupService.js';
|
||||
|
||||
let mcpServer: McpServer;
|
||||
const servers: { [sessionId: string]: Server } = {};
|
||||
|
||||
export const initMcpServer = (name: string, version: string): McpServer => {
|
||||
mcpServer = new McpServer({ name, version });
|
||||
return mcpServer;
|
||||
export const initMcpServer = async (name: string, version: string): Promise<void> => {
|
||||
await registerAllTools(true);
|
||||
};
|
||||
|
||||
export const setMcpServer = (server: McpServer): void => {
|
||||
mcpServer = server;
|
||||
export const getMcpServer = (sessionId: string): Server => {
|
||||
if (!servers[sessionId]) {
|
||||
const server = createMcpServer(config.mcpHubName, config.mcpHubVersion);
|
||||
servers[sessionId] = server;
|
||||
}
|
||||
return servers[sessionId];
|
||||
};
|
||||
|
||||
export const getMcpServer = (): McpServer => {
|
||||
return mcpServer;
|
||||
export const deleteMcpServer = (sessionId: string): void => {
|
||||
delete servers[sessionId];
|
||||
};
|
||||
|
||||
export const recreateMcpServer = async () => {
|
||||
console.log('Re-creating McpServer instance');
|
||||
const newServer = createMcpServer(config.mcpHubName, config.mcpHubVersion);
|
||||
await registerAllTools(newServer, true);
|
||||
const oldServer = getMcpServer();
|
||||
setMcpServer(newServer);
|
||||
oldServer.close();
|
||||
console.log('McpServer instance successfully re-created');
|
||||
export const notifyToolChanged = async () => {
|
||||
await registerAllTools(false);
|
||||
Object.values(servers).forEach((server) => {
|
||||
server
|
||||
.sendToolListChanged()
|
||||
.catch((error) => {
|
||||
console.warn('Failed to send tool list changed notification:', error.message);
|
||||
})
|
||||
.then(() => {
|
||||
console.log('Tool list changed notification sent successfully');
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Store all server information
|
||||
let serverInfos: ServerInfo[] = [];
|
||||
|
||||
// Initialize MCP server clients
|
||||
export const initializeClientsFromSettings = (): ServerInfo[] => {
|
||||
export const initializeClientsFromSettings = (isInit: boolean): ServerInfo[] => {
|
||||
const settings = loadSettings();
|
||||
const existingServerInfos = serverInfos;
|
||||
serverInfos = [];
|
||||
@@ -50,13 +58,14 @@ export const initializeClientsFromSettings = (): ServerInfo[] => {
|
||||
serverInfos.push({
|
||||
name,
|
||||
status: 'disconnected',
|
||||
error: null,
|
||||
tools: [],
|
||||
createTime: Date.now(),
|
||||
enabled: false
|
||||
enabled: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// Check if server is already connected
|
||||
const existingServer = existingServerInfos.find(
|
||||
(s) => s.name === name && s.status === 'connected',
|
||||
@@ -64,28 +73,51 @@ export const initializeClientsFromSettings = (): ServerInfo[] => {
|
||||
if (existingServer) {
|
||||
serverInfos.push({
|
||||
...existingServer,
|
||||
enabled: conf.enabled === undefined ? true : conf.enabled
|
||||
enabled: conf.enabled === undefined ? true : conf.enabled,
|
||||
});
|
||||
console.log(`Server '${name}' is already connected.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let transport;
|
||||
if (conf.url) {
|
||||
if (conf.type === 'streamable-http') {
|
||||
transport = new StreamableHTTPClientTransport(new URL(conf.url || ''));
|
||||
} else if (conf.url) {
|
||||
// Default to SSE only when 'conf.type' is not specified and 'conf.url' is available
|
||||
transport = new SSEClientTransport(new URL(conf.url));
|
||||
} else if (conf.command && conf.args) {
|
||||
// If type is stdio or if command and args are provided without type
|
||||
const env: Record<string, string> = conf.env || {};
|
||||
env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
|
||||
|
||||
// Add UV_DEFAULT_INDEX from settings if available (for Python packages)
|
||||
const settings = loadSettings(); // Add UV_DEFAULT_INDEX from settings if available (for Python packages)
|
||||
if (settings.systemConfig?.install?.pythonIndexUrl && conf.command === 'uvx') {
|
||||
env['UV_DEFAULT_INDEX'] = settings.systemConfig.install.pythonIndexUrl;
|
||||
}
|
||||
|
||||
// Add npm_config_registry from settings if available (for NPM packages)
|
||||
if (
|
||||
settings.systemConfig?.install?.npmRegistry &&
|
||||
(conf.command === 'npm' || conf.command === 'npx')
|
||||
) {
|
||||
env['npm_config_registry'] = settings.systemConfig.install.npmRegistry;
|
||||
}
|
||||
|
||||
transport = new StdioClientTransport({
|
||||
command: conf.command,
|
||||
args: conf.args,
|
||||
env: env,
|
||||
});
|
||||
transport.stderr?.on('data', (data) => {
|
||||
console.error(`Error from server ${name}: ${data}`);
|
||||
});
|
||||
} else {
|
||||
console.warn(`Skipping server '${name}': missing required configuration`);
|
||||
serverInfos.push({
|
||||
name,
|
||||
status: 'disconnected',
|
||||
error: 'Missing required configuration',
|
||||
tools: [],
|
||||
createTime: Date.now(),
|
||||
});
|
||||
@@ -105,16 +137,55 @@ export const initializeClientsFromSettings = (): ServerInfo[] => {
|
||||
},
|
||||
},
|
||||
);
|
||||
client.connect(transport, { timeout: Number(config.timeout) }).catch((error) => {
|
||||
console.error(`Failed to connect client for server ${name} by error: ${error}`);
|
||||
const serverInfo = getServerInfoByName(name);
|
||||
if (serverInfo) {
|
||||
serverInfo.status = 'disconnected';
|
||||
}
|
||||
});
|
||||
const timeout = isInit ? Number(config.initTimeout) : Number(config.timeout);
|
||||
client
|
||||
.connect(transport, { timeout: timeout })
|
||||
.then(() => {
|
||||
console.log(`Successfully connected client for server: ${name}`);
|
||||
|
||||
client
|
||||
.listTools({}, { timeout: timeout })
|
||||
.then((tools) => {
|
||||
console.log(`Successfully listed ${tools.tools.length} tools for server: ${name}`);
|
||||
const serverInfo = getServerByName(name);
|
||||
if (!serverInfo) {
|
||||
console.warn(`Server info not found for server: ${name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
serverInfo.tools = tools.tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description || '',
|
||||
inputSchema: tool.inputSchema || {},
|
||||
}));
|
||||
serverInfo.status = 'connected';
|
||||
serverInfo.error = null;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to list tools for server ${name} by error: ${error} with stack: ${error.stack}`,
|
||||
);
|
||||
const serverInfo = getServerByName(name);
|
||||
if (serverInfo) {
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = `Failed to list tools: ${error.stack} `;
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to connect client for server ${name} by error: ${error} with stack: ${error.stack}`,
|
||||
);
|
||||
const serverInfo = getServerByName(name);
|
||||
if (serverInfo) {
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = `Failed to connect: ${error.stack} `;
|
||||
}
|
||||
});
|
||||
serverInfos.push({
|
||||
name,
|
||||
status: 'connecting',
|
||||
error: null,
|
||||
tools: [],
|
||||
client,
|
||||
transport,
|
||||
@@ -127,62 +198,20 @@ export const initializeClientsFromSettings = (): ServerInfo[] => {
|
||||
};
|
||||
|
||||
// Register all MCP tools
|
||||
export const registerAllTools = async (server: McpServer, forceInit: boolean): Promise<void> => {
|
||||
initializeClientsFromSettings();
|
||||
for (const serverInfo of serverInfos) {
|
||||
if (serverInfo.status === 'connected' && !forceInit) continue;
|
||||
if (!serverInfo.client || !serverInfo.transport) continue;
|
||||
|
||||
try {
|
||||
serverInfo.status = 'connecting';
|
||||
console.log(`Connecting to server: ${serverInfo.name}...`);
|
||||
|
||||
const tools = await serverInfo.client.listTools({}, { timeout: Number(config.timeout) });
|
||||
serverInfo.tools = tools.tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description || '',
|
||||
inputSchema: tool.inputSchema.properties || {},
|
||||
}));
|
||||
|
||||
serverInfo.status = 'connected';
|
||||
console.log(`Successfully connected to server: ${serverInfo.name}`);
|
||||
|
||||
for (const tool of tools.tools) {
|
||||
console.log(`Registering tool: ${JSON.stringify(tool)}`);
|
||||
await server.tool(
|
||||
tool.name,
|
||||
tool.description || '',
|
||||
json2zod(tool.inputSchema.properties, tool.inputSchema.required),
|
||||
async (params: Record<string, unknown>) => {
|
||||
const currentServer = getServerInfoByName(serverInfo.name)!;
|
||||
console.log(`Calling tool: ${tool.name} with params: ${JSON.stringify(params)}`);
|
||||
const result = await currentServer.client!.callTool({
|
||||
name: tool.name,
|
||||
arguments: params,
|
||||
});
|
||||
console.log(`Tool call result: ${JSON.stringify(result)}`);
|
||||
return result as CallToolResult;
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to connect to server for client: ${serverInfo.name} by error: ${error}`,
|
||||
);
|
||||
serverInfo.status = 'disconnected';
|
||||
}
|
||||
}
|
||||
export const registerAllTools = async (isInit: boolean): Promise<void> => {
|
||||
initializeClientsFromSettings(isInit);
|
||||
};
|
||||
|
||||
// Get all server information
|
||||
export const getServersInfo = (): Omit<ServerInfo, 'client' | 'transport'>[] => {
|
||||
const settings = loadSettings();
|
||||
const infos = serverInfos.map(({ name, status, tools, createTime }) => {
|
||||
const infos = serverInfos.map(({ name, status, tools, createTime, error }) => {
|
||||
const serverConfig = settings.mcpServers[name];
|
||||
const enabled = serverConfig ? (serverConfig.enabled !== false) : true;
|
||||
const enabled = serverConfig ? serverConfig.enabled !== false : true;
|
||||
return {
|
||||
name,
|
||||
status,
|
||||
error,
|
||||
tools,
|
||||
createTime,
|
||||
enabled,
|
||||
@@ -195,11 +224,16 @@ export const getServersInfo = (): Omit<ServerInfo, 'client' | 'transport'>[] =>
|
||||
return infos;
|
||||
};
|
||||
|
||||
// Get server information by name
|
||||
const getServerInfoByName = (name: string): ServerInfo | undefined => {
|
||||
// Get server by name
|
||||
const getServerByName = (name: string): ServerInfo | undefined => {
|
||||
return serverInfos.find((serverInfo) => serverInfo.name === name);
|
||||
};
|
||||
|
||||
// Get server by tool name
|
||||
const getServerByTool = (toolName: string): ServerInfo | undefined => {
|
||||
return serverInfos.find((serverInfo) => serverInfo.tools.some((tool) => tool.name === toolName));
|
||||
};
|
||||
|
||||
// Add new server
|
||||
export const addServer = async (
|
||||
name: string,
|
||||
@@ -216,7 +250,7 @@ export const addServer = async (
|
||||
return { success: false, message: 'Failed to save settings' };
|
||||
}
|
||||
|
||||
registerAllTools(mcpServer, false);
|
||||
registerAllTools(false);
|
||||
return { success: true, message: 'Server added successfully' };
|
||||
} catch (error) {
|
||||
console.error(`Failed to add server: ${name}`, error);
|
||||
@@ -228,7 +262,6 @@ export const addServer = async (
|
||||
export const removeServer = (name: string): { success: boolean; message?: string } => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
|
||||
if (!settings.mcpServers[name]) {
|
||||
return { success: false, message: 'Server not found' };
|
||||
}
|
||||
@@ -263,13 +296,7 @@ export const updateMcpServer = async (
|
||||
return { success: false, message: 'Failed to save settings' };
|
||||
}
|
||||
|
||||
const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
|
||||
if (serverInfo) {
|
||||
serverInfo.client!.close();
|
||||
serverInfo.transport!.close();
|
||||
console.log(`Closed client and transport for server: ${name}`);
|
||||
// TODO kill process
|
||||
}
|
||||
closeServer(name);
|
||||
|
||||
serverInfos = serverInfos.filter((serverInfo) => serverInfo.name !== name);
|
||||
return { success: true, message: 'Server updated successfully' };
|
||||
@@ -279,10 +306,21 @@ export const updateMcpServer = async (
|
||||
}
|
||||
};
|
||||
|
||||
// Close server client and transport
|
||||
function closeServer(name: string) {
|
||||
const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
|
||||
if (serverInfo && serverInfo.client && serverInfo.transport) {
|
||||
serverInfo.client.close();
|
||||
serverInfo.transport.close();
|
||||
console.log(`Closed client and transport for server: ${serverInfo.name}`);
|
||||
// TODO kill process
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle server enabled status
|
||||
export const toggleServerStatus = async (
|
||||
name: string,
|
||||
enabled: boolean
|
||||
enabled: boolean,
|
||||
): Promise<{ success: boolean; message?: string }> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
@@ -292,22 +330,17 @@ export const toggleServerStatus = async (
|
||||
|
||||
// Update the enabled status in settings
|
||||
settings.mcpServers[name].enabled = enabled;
|
||||
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return { success: false, message: 'Failed to save settings' };
|
||||
}
|
||||
|
||||
// If disabling, disconnect the server and remove from active servers
|
||||
if (!enabled) {
|
||||
const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
|
||||
if (serverInfo && serverInfo.client && serverInfo.transport) {
|
||||
serverInfo.client.close();
|
||||
serverInfo.transport.close();
|
||||
console.log(`Closed client and transport for server: ${name}`);
|
||||
}
|
||||
|
||||
closeServer(name);
|
||||
|
||||
// Update the server info to show as disconnected and disabled
|
||||
const index = serverInfos.findIndex(s => s.name === name);
|
||||
const index = serverInfos.findIndex((s) => s.name === name);
|
||||
if (index !== -1) {
|
||||
serverInfos[index] = {
|
||||
...serverInfos[index],
|
||||
@@ -324,93 +357,62 @@ export const toggleServerStatus = async (
|
||||
}
|
||||
};
|
||||
|
||||
// Create McpServer instance
|
||||
export const createMcpServer = (name: string, version: string): McpServer => {
|
||||
return new McpServer({ name, version });
|
||||
const handleListToolsRequest = async (_: any, extra: any) => {
|
||||
const sessionId = extra.sessionId || '';
|
||||
const group = getGroup(sessionId);
|
||||
console.log(`Handling ListToolsRequest for group: ${group}`);
|
||||
const allServerInfos = serverInfos.filter((serverInfo) => {
|
||||
if (serverInfo.enabled === false) return false;
|
||||
if (!group) return true;
|
||||
const serversInGroup = getServersInGroup(group);
|
||||
if (!serversInGroup || serversInGroup.length === 0) return serverInfo.name === group;
|
||||
return serversInGroup.includes(serverInfo.name);
|
||||
});
|
||||
|
||||
const allTools = [];
|
||||
for (const serverInfo of allServerInfos) {
|
||||
if (serverInfo.tools && serverInfo.tools.length > 0) {
|
||||
allTools.push(...serverInfo.tools);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tools: allTools,
|
||||
};
|
||||
};
|
||||
|
||||
// Helper function: Convert JSON Schema to Zod Schema
|
||||
function json2zod(inputSchema: unknown, required: unknown): ZodRawShape {
|
||||
if (typeof inputSchema !== 'object' || inputSchema === null) {
|
||||
throw new Error('Invalid input schema');
|
||||
const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
console.log(`Handling CallToolRequest for tool: ${request.params.name}`);
|
||||
try {
|
||||
const serverInfo = getServerByTool(request.params.name);
|
||||
if (!serverInfo) {
|
||||
throw new Error(`Server not found: ${request.params.name}`);
|
||||
}
|
||||
const client = serverInfo.client;
|
||||
if (!client) {
|
||||
throw new Error(`Client not found for server: ${request.params.name}`);
|
||||
}
|
||||
const result = await client.callTool(request.params);
|
||||
console.log(`Tool call result: ${JSON.stringify(result)}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Error handling CallToolRequest: ${error}`);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error: ${error}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const properties = inputSchema as Record<string, any>;
|
||||
const processedSchema: ZodRawShape = {};
|
||||
|
||||
for (const key in properties) {
|
||||
const prop = properties[key];
|
||||
|
||||
if (prop instanceof ZodType) {
|
||||
processedSchema[key] = prop;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof prop !== 'object' || prop === null) {
|
||||
throw new Error(`Invalid property definition for ${key}`);
|
||||
}
|
||||
|
||||
let zodType: ZodType;
|
||||
|
||||
if (prop.type === 'array' && prop.items) {
|
||||
if (prop.items.type === 'string') {
|
||||
zodType = z.array(z.string());
|
||||
} else if (prop.items.type === 'number') {
|
||||
zodType = z.array(z.number());
|
||||
} else if (prop.items.type === 'integer') {
|
||||
zodType = z.array(z.number().int());
|
||||
} else if (prop.items.type === 'boolean') {
|
||||
zodType = z.array(z.boolean());
|
||||
} else if (prop.items.type === 'object' && prop.items.properties) {
|
||||
zodType = z.array(z.object(json2zod(prop.items.properties, prop.items.required)));
|
||||
} else {
|
||||
zodType = z.array(z.any());
|
||||
}
|
||||
} else {
|
||||
switch (prop.type) {
|
||||
case 'string':
|
||||
if (prop.enum && Array.isArray(prop.enum)) {
|
||||
zodType = z.enum(prop.enum as [string, ...string[]]);
|
||||
} else {
|
||||
zodType = z.string();
|
||||
}
|
||||
break;
|
||||
case 'number':
|
||||
zodType = z.number();
|
||||
break;
|
||||
case 'boolean':
|
||||
zodType = z.boolean();
|
||||
break;
|
||||
case 'integer':
|
||||
zodType = z.number().int();
|
||||
break;
|
||||
case 'object':
|
||||
if (prop.properties) {
|
||||
zodType = z.object(json2zod(prop.properties, prop.required));
|
||||
} else {
|
||||
zodType = z.record(z.any());
|
||||
}
|
||||
break;
|
||||
default:
|
||||
zodType = z.any();
|
||||
}
|
||||
}
|
||||
|
||||
if (prop.description) {
|
||||
zodType = zodType.describe(prop.description);
|
||||
}
|
||||
|
||||
if (prop.default !== undefined) {
|
||||
zodType = zodType.default(prop.default);
|
||||
}
|
||||
|
||||
required = Array.isArray(required) ? required : [];
|
||||
if (Array.isArray(required) && required.includes(key)) {
|
||||
processedSchema[key] = zodType;
|
||||
} else {
|
||||
processedSchema[key] = zodType.optional();
|
||||
}
|
||||
}
|
||||
|
||||
return processedSchema;
|
||||
}
|
||||
// Create McpServer instance
|
||||
export const createMcpServer = (name: string, version: string): Server => {
|
||||
const server = new Server({ name, version }, { capabilities: { tools: {} } });
|
||||
server.setRequestHandler(ListToolsRequestSchema, handleListToolsRequest);
|
||||
server.setRequestHandler(CallToolRequestSchema, handleCallToolRequest);
|
||||
return server;
|
||||
};
|
||||
|
||||
@@ -1,34 +1,174 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { getMcpServer } from './mcpService.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { deleteMcpServer, getMcpServer } from './mcpService.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
|
||||
const transports: { [sessionId: string]: SSEServerTransport } = {};
|
||||
const transports: { [sessionId: string]: { transport: Transport; group: string } } = {};
|
||||
|
||||
export const getGroup = (sessionId: string): string => {
|
||||
return transports[sessionId]?.group || '';
|
||||
};
|
||||
|
||||
// Helper function to validate bearer auth
|
||||
const validateBearerAuth = (req: Request): boolean => {
|
||||
const settings = loadSettings();
|
||||
const routingConfig = settings.systemConfig?.routing || {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
};
|
||||
|
||||
if (routingConfig.enableBearerAuth) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7); // Remove "Bearer " prefix
|
||||
return token === routingConfig.bearerAuthKey;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const handleSseConnection = async (req: Request, res: Response): Promise<void> => {
|
||||
// Check bearer auth
|
||||
if (!validateBearerAuth(req)) {
|
||||
res.status(401).send('Bearer authentication required or invalid token');
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = loadSettings();
|
||||
const routingConfig = settings.systemConfig?.routing || {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
};
|
||||
const group = req.params.group;
|
||||
|
||||
// Check if this is a global route (no group) and if it's allowed
|
||||
if (!group && !routingConfig.enableGlobalRoute) {
|
||||
res.status(403).send('Global routes are disabled. Please specify a group ID.');
|
||||
return;
|
||||
}
|
||||
|
||||
const transport = new SSEServerTransport('/messages', res);
|
||||
transports[transport.sessionId] = transport;
|
||||
transports[transport.sessionId] = { transport, group: group };
|
||||
|
||||
res.on('close', () => {
|
||||
delete transports[transport.sessionId];
|
||||
deleteMcpServer(transport.sessionId);
|
||||
console.log(`SSE connection closed: ${transport.sessionId}`);
|
||||
});
|
||||
|
||||
console.log(`New SSE connection established: ${transport.sessionId}`);
|
||||
await getMcpServer().connect(transport);
|
||||
console.log(
|
||||
`New SSE connection established: ${transport.sessionId} with group: ${group || 'global'}`,
|
||||
);
|
||||
await getMcpServer(transport.sessionId).connect(transport);
|
||||
};
|
||||
|
||||
export const handleSseMessage = async (req: Request, res: Response): Promise<void> => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const transport = transports[sessionId];
|
||||
// Check bearer auth
|
||||
if (!validateBearerAuth(req)) {
|
||||
res.status(401).send('Bearer authentication required or invalid token');
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const { transport, group } = transports[sessionId];
|
||||
req.params.group = group;
|
||||
req.query.group = group;
|
||||
console.log(`Received message for sessionId: ${sessionId} in group: ${group}`);
|
||||
if (transport) {
|
||||
await transport.handlePostMessage(req, res);
|
||||
await (transport as SSEServerTransport).handlePostMessage(req, res);
|
||||
} else {
|
||||
console.error(`No transport found for sessionId: ${sessionId}`);
|
||||
res.status(400).send('No transport found for sessionId');
|
||||
}
|
||||
};
|
||||
|
||||
export const handleMcpPostRequest = async (req: Request, res: Response): Promise<void> => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
const group = req.params.group;
|
||||
console.log(`Handling MCP post request for sessionId: ${sessionId} and group: ${group}`);
|
||||
// Check bearer auth
|
||||
if (!validateBearerAuth(req)) {
|
||||
res.status(401).send('Bearer authentication required or invalid token');
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = loadSettings();
|
||||
const routingConfig = settings.systemConfig?.routing || {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
};
|
||||
if (!group && !routingConfig.enableGlobalRoute) {
|
||||
res.status(403).send('Global routes are disabled. Please specify a group ID.');
|
||||
return;
|
||||
}
|
||||
|
||||
let transport: StreamableHTTPServerTransport;
|
||||
if (sessionId && transports[sessionId]) {
|
||||
console.log(`Reusing existing transport for sessionId: ${sessionId}`);
|
||||
transport = transports[sessionId].transport as StreamableHTTPServerTransport;
|
||||
} else if (!sessionId && isInitializeRequest(req.body)) {
|
||||
transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: (sessionId) => {
|
||||
transports[sessionId] = { transport, group };
|
||||
},
|
||||
});
|
||||
|
||||
transport.onclose = () => {
|
||||
if (transport.sessionId) {
|
||||
delete transports[transport.sessionId];
|
||||
deleteMcpServer(transport.sessionId);
|
||||
console.log(`MCP connection closed: ${transport.sessionId}`);
|
||||
}
|
||||
};
|
||||
|
||||
console.log(`MCP connection established: ${transport.sessionId}`);
|
||||
await getMcpServer(transport.sessionId || 'mcp').connect(transport);
|
||||
} else {
|
||||
res.status(400).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32000,
|
||||
message: 'Bad Request: No valid session ID provided',
|
||||
},
|
||||
id: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Handling request using transport with type ${transport.constructor.name}`);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
};
|
||||
|
||||
export const handleMcpOtherRequest = async (req: Request, res: Response) => {
|
||||
console.log('Handling MCP other request');
|
||||
// Check bearer auth
|
||||
if (!validateBearerAuth(req)) {
|
||||
res.status(401).send('Bearer authentication required or invalid token');
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (!sessionId || !transports[sessionId]) {
|
||||
res.status(400).send('Invalid or missing session ID');
|
||||
return;
|
||||
}
|
||||
|
||||
const { transport } = transports[sessionId];
|
||||
await (transport as StreamableHTTPServerTransport).handleRequest(req, res);
|
||||
};
|
||||
|
||||
export const getConnectionCount = (): number => {
|
||||
return Object.keys(transports).length;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
|
||||
// User interface
|
||||
export interface IUser {
|
||||
@@ -9,17 +10,94 @@ export interface IUser {
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
// Group interface for server grouping
|
||||
export interface IGroup {
|
||||
id: string; // Unique UUID for the group
|
||||
name: string; // Display name of the group
|
||||
description?: string; // Optional description of the group
|
||||
servers: string[]; // Array of server names that belong to this group
|
||||
}
|
||||
|
||||
// Market server types
|
||||
export interface MarketServerRepository {
|
||||
type: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface MarketServerAuthor {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface MarketServerInstallation {
|
||||
type: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface MarketServerArgument {
|
||||
description: string;
|
||||
required: boolean;
|
||||
example: string;
|
||||
}
|
||||
|
||||
export interface MarketServerExample {
|
||||
title: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export interface MarketServerTool {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface MarketServer {
|
||||
name: string;
|
||||
display_name: string;
|
||||
description: string;
|
||||
repository: MarketServerRepository;
|
||||
homepage: string;
|
||||
author: MarketServerAuthor;
|
||||
license: string;
|
||||
categories: string[];
|
||||
tags: string[];
|
||||
examples: MarketServerExample[];
|
||||
installations: {
|
||||
[key: string]: MarketServerInstallation;
|
||||
};
|
||||
arguments: Record<string, MarketServerArgument>;
|
||||
tools: MarketServerTool[];
|
||||
is_official?: boolean;
|
||||
}
|
||||
|
||||
// Represents the settings for MCP servers
|
||||
export interface McpSettings {
|
||||
users?: IUser[]; // Array of user credentials and permissions
|
||||
mcpServers: {
|
||||
[key: string]: ServerConfig; // Key-value pairs of server names and their configurations
|
||||
};
|
||||
groups?: IGroup[]; // Array of server groups
|
||||
systemConfig?: {
|
||||
routing?: {
|
||||
enableGlobalRoute?: boolean; // Controls whether the /sse endpoint without group is enabled
|
||||
enableGroupNameRoute?: boolean; // Controls whether group routing by name is allowed
|
||||
enableBearerAuth?: boolean; // Controls whether bearer auth is enabled for group routes
|
||||
bearerAuthKey?: string; // The bearer auth key to validate against
|
||||
};
|
||||
install?: {
|
||||
pythonIndexUrl?: string; // Python package repository URL (UV_DEFAULT_INDEX)
|
||||
npmRegistry?: string; // NPM registry URL (npm_config_registry)
|
||||
};
|
||||
// Add other system configuration sections here in the future
|
||||
};
|
||||
}
|
||||
|
||||
// Configuration details for an individual server
|
||||
export interface ServerConfig {
|
||||
url?: string; // URL for SSE-based servers
|
||||
type?: 'stdio' | 'sse' | 'streamable-http'; // Type of server
|
||||
url?: string; // URL for SSE or streamable HTTP servers
|
||||
command?: string; // Command to execute for stdio-based servers
|
||||
args?: string[]; // Arguments for the command
|
||||
env?: Record<string, string>; // Environment variables
|
||||
@@ -30,9 +108,10 @@ export interface ServerConfig {
|
||||
export interface ServerInfo {
|
||||
name: string; // Unique name of the server
|
||||
status: 'connected' | 'connecting' | 'disconnected'; // Current connection status
|
||||
error: string | null; // Error message if any
|
||||
tools: ToolInfo[]; // List of tools available on the server
|
||||
client?: Client; // Client instance for communication
|
||||
transport?: SSEClientTransport | StdioClientTransport; // Transport mechanism used
|
||||
transport?: SSEClientTransport | StdioClientTransport | StreamableHTTPClientTransport; // Transport mechanism used
|
||||
createTime: number; // Timestamp of when the server was created
|
||||
enabled?: boolean; // Flag to indicate if the server is enabled
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
// filepath: /Users/sunmeng/code/github/mcphub/src/utils/migration.ts
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
import { IUser } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Migrates user data from the old users.json file to mcp_settings.json
|
||||
* This is a one-time migration to support the refactoring from separate
|
||||
* users.json to integrated user data in mcp_settings.json
|
||||
*/
|
||||
export const migrateUserData = (): void => {
|
||||
const oldUsersFilePath = path.join(process.cwd(), 'data', 'users.json');
|
||||
|
||||
// Check if the old users file exists
|
||||
if (fs.existsSync(oldUsersFilePath)) {
|
||||
try {
|
||||
// Read users from the old file
|
||||
const usersData = fs.readFileSync(oldUsersFilePath, 'utf8');
|
||||
const users = JSON.parse(usersData) as IUser[];
|
||||
|
||||
if (users && Array.isArray(users) && users.length > 0) {
|
||||
console.log(`Migrating ${users.length} users from users.json to mcp_settings.json`);
|
||||
|
||||
// Load current settings
|
||||
const settings = loadSettings();
|
||||
|
||||
// Merge users, giving priority to existing settings users
|
||||
const existingUsernames = new Set((settings.users || []).map(u => u.username));
|
||||
const newUsers = users.filter(u => !existingUsernames.has(u.username));
|
||||
|
||||
settings.users = [...(settings.users || []), ...newUsers];
|
||||
|
||||
// Save updated settings
|
||||
if (saveSettings(settings)) {
|
||||
console.log('User data migration completed successfully');
|
||||
|
||||
// Rename the old file as backup
|
||||
const backupPath = `${oldUsersFilePath}.bak.${Date.now()}`;
|
||||
fs.renameSync(oldUsersFilePath, backupPath);
|
||||
console.log(`Renamed old users file to ${backupPath}`);
|
||||
}
|
||||
} else {
|
||||
console.log('No users found in users.json, skipping migration');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during user data migration:', error);
|
||||
}
|
||||
} else {
|
||||
console.log('users.json not found, no migration needed');
|
||||
}
|
||||
};
|
||||
42
src/utils/path.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
|
||||
// Get current file's directory
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
// Project root directory should be the parent directory of src
|
||||
const rootDir = dirname(dirname(__dirname));
|
||||
|
||||
/**
|
||||
* Find the path to a configuration file by checking multiple potential locations.
|
||||
* @param filename The name of the file to locate (e.g., 'servers.json', 'mcp_settings.json')
|
||||
* @param description Brief description of the file for logging purposes
|
||||
* @returns The path to the file
|
||||
*/
|
||||
export const getConfigFilePath = (filename: string, description = 'Configuration'): string => {
|
||||
// Try to find the correct path to the file
|
||||
const potentialPaths = [
|
||||
// Prioritize process.cwd() as the first location to check
|
||||
path.resolve(process.cwd(), filename),
|
||||
// Use path relative to the root directory
|
||||
path.join(rootDir, filename),
|
||||
// If installed with npx, may need to look one level up
|
||||
path.join(dirname(rootDir), filename)
|
||||
];
|
||||
|
||||
for (const filePath of potentialPaths) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
// If all paths do not exist, use default path
|
||||
// Using the default path is acceptable because it ensures the application can proceed
|
||||
// even if the configuration file is missing. This fallback is particularly useful in
|
||||
// development environments or when the file is optional.
|
||||
const defaultPath = path.resolve(process.cwd(), filename);
|
||||
console.debug(`${description} file not found at any expected location, using default path: ${defaultPath}`);
|
||||
return defaultPath;
|
||||
};
|
||||