Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
436318b24c | ||
|
|
3e5a64d533 | ||
|
|
6988618c41 | ||
|
|
964ab4a5d7 | ||
|
|
b59243e410 | ||
|
|
85c461bbfa | ||
|
|
f477e1f942 | ||
|
|
7feb5b2bcb | ||
|
|
66d592da1c | ||
|
|
adef02d3b9 | ||
|
|
6a065ca2f2 | ||
|
|
d94a58ebca | ||
|
|
6af13f85d4 | ||
|
|
f026e621a5 | ||
|
|
1f17780000 | ||
|
|
f94acb8bef | ||
|
|
51289b8ca8 | ||
|
|
2c4f9d1c24 | ||
|
|
2e1f73ef64 | ||
|
|
40d8792294 | ||
|
|
7f887a7031 | ||
|
|
5a2be4d4fe | ||
|
|
f6c5cde9ce | ||
|
|
1e6d10a0c3 | ||
|
|
1ea8c3c866 | ||
|
|
6b56a9a554 | ||
|
|
a1d2c679d6 | ||
|
|
fe10273fc3 | ||
|
|
649a454ba8 | ||
|
|
bc8be7578f | ||
|
|
c5a8cfd88f | ||
|
|
8905757c69 | ||
|
|
c896fef8ee | ||
|
|
09b8a478b9 |
63
.github/workflows/build.yml
vendored
@@ -2,11 +2,11 @@ name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
tags: ['v*.*.*']
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
build-base:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -28,9 +28,15 @@ jobs:
|
||||
with:
|
||||
images: samanhappy/mcphub
|
||||
tags: |
|
||||
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
# edge 变体
|
||||
type=raw,value=edge,enable=${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
|
||||
|
||||
# semver
|
||||
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
# latest
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
@@ -39,4 +45,53 @@ 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=base
|
||||
platforms: linux/amd64,linux/arm64
|
||||
build-args: |
|
||||
INSTALL_EXT=false
|
||||
|
||||
build-full:
|
||||
needs: build-base # 确保在 base 变体完成后再构建 full 变体
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }} # 只在发布标签时构建 full 变体
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: samanhappy/mcphub
|
||||
tags: |
|
||||
# semver with full suffix
|
||||
type=semver,pattern={{version}}-full,enable=${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
# latest-full
|
||||
type=raw,value=latest-full,enable=${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
# edge-full
|
||||
type=raw,value=edge-full,enable=${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max,scope=full
|
||||
platforms: linux/amd64,linux/arm64
|
||||
build-args: |
|
||||
INSTALL_EXT=true
|
||||
2
.github/workflows/release.yml
vendored
@@ -15,3 +15,5 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
|
||||
19
Dockerfile
@@ -1,4 +1,4 @@
|
||||
FROM python:3.12-slim-bookworm AS base
|
||||
FROM python:3.13-slim-bookworm AS base
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
|
||||
@@ -12,6 +12,21 @@ RUN npm install -g pnpm
|
||||
ARG REQUEST_TIMEOUT=60000
|
||||
ENV REQUEST_TIMEOUT=$REQUEST_TIMEOUT
|
||||
|
||||
ENV PNPM_HOME=/usr/local/share/pnpm
|
||||
ENV PATH=$PNPM_HOME:$PATH
|
||||
RUN mkdir -p $PNPM_HOME && \
|
||||
pnpm add -g @amap/amap-maps-mcp-server @playwright/mcp@latest tavily-mcp@latest @modelcontextprotocol/server-github @modelcontextprotocol/server-slack
|
||||
|
||||
ARG INSTALL_EXT=false
|
||||
RUN if [ "$INSTALL_EXT" = "true" ]; then \
|
||||
ARCH=$(uname -m); \
|
||||
if [ "$ARCH" = "x86_64" ]; then \
|
||||
npx -y playwright install --with-deps chrome; \
|
||||
else \
|
||||
echo "Skipping Chrome installation on non-amd64 architecture: $ARCH"; \
|
||||
fi; \
|
||||
fi
|
||||
|
||||
RUN uv tool install mcp-server-fetch
|
||||
ENV UV_PYTHON_INSTALL_MIRROR="http://mirrors.aliyun.com/pypi/simple/"
|
||||
|
||||
@@ -20,8 +35,6 @@ WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install
|
||||
|
||||
RUN pnpm install @amap/amap-maps-mcp-server @playwright/mcp@latest tavily-mcp@latest @modelcontextprotocol/server-github @modelcontextprotocol/server-slack
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm frontend:build && pnpm build
|
||||
|
||||
139
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 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": {
|
||||
"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,83 @@ 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 Access
|
||||
|
||||
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`
|
||||
|
||||
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.
|
||||
**Dashboard Overview**:
|
||||
- Live status of all MCP servers
|
||||
- Enable/disable or reconfigure servers
|
||||
- Group management for organizing servers
|
||||
- User administration for access control
|
||||
|
||||
### SSE Endpoint
|
||||
|
||||
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, Cherry Studio) via:
|
||||
```
|
||||
http://localhost:3000/sse
|
||||
```
|
||||
|
||||
## Local Development
|
||||
**Group-Specific Endpoints (Recommended)**:
|
||||
|
||||
### Clone the Repository
|
||||

|
||||
|
||||
Clone MCPHub from GitHub:
|
||||
For targeted access to specific server groups, use the group-based SSE endpoint:
|
||||
```
|
||||
http://localhost:3000/sse/{groupId}
|
||||
```
|
||||
|
||||
Where `{groupId}` is the ID 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
|
||||
|
||||
## 🧑💻 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.
|
||||
## 🔍 Tech Stack
|
||||
|
||||
### Start the Development Server
|
||||
- **Backend**: Node.js, Express, TypeScript
|
||||
- **Frontend**: React, Vite, Tailwind CSS
|
||||
- **Auth**: JWT & bcrypt
|
||||
- **Protocol**: Model Context Protocol SDK
|
||||
|
||||
Install dependencies and launch MCPHub:
|
||||
## 👥 Contributing
|
||||
|
||||
```bash
|
||||
cd mcphub && pnpm install && pnpm dev
|
||||
```
|
||||
Contributions are welcome!
|
||||
|
||||
## Community and Contributions
|
||||
- New features & optimizations
|
||||
- Documentation improvements
|
||||
- Bug reports & fixes
|
||||
- Translations & suggestions
|
||||
|
||||
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!
|
||||
## 📄 License
|
||||
|
||||
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.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [Apache 2.0 license](LICENSE).
|
||||
Licensed under the [Apache 2.0 License](LICENSE).
|
||||
|
||||
196
README.zh.md
@@ -1,137 +1,145 @@
|
||||
# MCPHub:一键部署你的专属 MCP 服务
|
||||
# MCPHub:一站式 MCP 服务器聚合平台
|
||||
|
||||
[English Version](README.md) | 中文版
|
||||
|
||||
MCPHub 是一个集中管理的 MCP 服务器聚合平台,可以将多个 MCP(Model Context Protocol)服务整合为一个 SSE 端点。它通过提供一个集中的管理界面来简化服务管理,满足您对 MCP 服务的所有需求。
|
||||
MCPHub 是一个统一的 MCP(Model Context Protocol,模型上下文协议)服务器聚合平台,可以根据场景将多个服务器聚合到不同的 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-maps": {
|
||||
"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 服务器状态
|
||||
- 启用/禁用或重新配置服务器
|
||||
- 分组管理,组织服务器访问
|
||||
- 用户管理,设定权限
|
||||
|
||||
### SSE 端点集成
|
||||
|
||||
通过以下地址连接 AI 客户端(如 Claude Desktop、Cursor、Cherry Studio 等):
|
||||
```
|
||||
http://localhost:3000/sse
|
||||
```
|
||||
|
||||
### 控制面板访问
|
||||
**基于分组的 SSE 端点(推荐)**:
|
||||
|
||||
在浏览器中打开 `http://localhost:3000` 并使用你在 `mcp_settings.json` 文件中设置的凭据登录。默认凭据为:
|
||||
- **用户名**:`admin`
|
||||
- **密码**:`admin123`
|
||||

|
||||
|
||||
控制面板提供以下功能:
|
||||
- **实时监控**:随时查看所有 MCP 服务的运行状态。
|
||||
- **服务状态指示**:快速识别各服务是否在线。
|
||||
- **动态管理**:无需重启即可动态添加或移除 MCP 服务。
|
||||
要针对特定服务器分组进行访问,请使用基于分组的 SSE 端点:
|
||||
```
|
||||
http://localhost:3000/sse/{groupId}
|
||||
```
|
||||
|
||||
### SSE 端点
|
||||
其中 `{groupId}` 是您在控制面板中创建的分组 ID。这样做可以:
|
||||
- 连接到按用例组织的特定 MCP 服务器子集
|
||||
- 隔离不同的 AI 工具,使其只能访问相关服务器
|
||||
- 为不同环境或团队实现更精细的访问控制
|
||||
|
||||
您可以将主机应用(如 Claude Desktop、Cursor、Cherry Studio 等)无缝连接至 MCPHub 的 SSE 端点: `http://localhost:3000/sse`
|
||||
|
||||
## 本地开发
|
||||
|
||||
### 克隆仓库
|
||||
|
||||
从 GitHub 克隆 MCPHub:
|
||||
## 🧑💻 本地开发
|
||||
|
||||
```bash
|
||||
git clone https://github.com/samanhappy/mcphub.git
|
||||
cd mcphub
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### 可选配置
|
||||
此命令将在开发模式下启动前后端,并启用热重载。
|
||||
|
||||
通过编辑 `mcp_settings.json` 文件来自定义 MCP 服务器设置。
|
||||
## 🔍 技术栈
|
||||
|
||||
### 启动开发服务器
|
||||
- **后端**:Node.js、Express、TypeScript
|
||||
- **前端**:React、Vite、Tailwind CSS
|
||||
- **认证**:JWT & bcrypt
|
||||
- **协议**:Model Context Protocol SDK
|
||||
|
||||
进入项目目录,安装依赖并启动 MCPHub:
|
||||
## 👥 贡献指南
|
||||
|
||||
```bash
|
||||
cd mcphub && pnpm install && pnpm dev
|
||||
```
|
||||
期待您的贡献!
|
||||
|
||||
## 社区与贡献
|
||||
- 新功能与优化
|
||||
- 文档完善
|
||||
- Bug 报告与修复
|
||||
- 翻译与建议
|
||||
|
||||
MCPHub 只是我一时兴起开发的小项目,没想到竟收获了这么多关注,非常感谢大家的支持!目前 MCPHub 还有不少地方需要优化和完善,我也专门建了个交流群,方便大家交流反馈。如果你也对这个项目感兴趣,欢迎一起参与建设!
|
||||
欢迎加入企微交流共建群
|
||||
|
||||

|
||||
<img src="assets/wegroup.png" width="500">
|
||||
|
||||
## 许可证
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 [Apache 2.0 许可证](LICENSE)。
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 36 KiB |
BIN
assets/group.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/group.zh.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 102 KiB |
BIN
assets/wegroup.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
@@ -1,369 +1,38 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { BrowserRouter as Router, Route, Routes, Navigate, useNavigate } from 'react-router-dom'
|
||||
import { Server, ApiResponse } from './types'
|
||||
import ServerCard from './components/ServerCard'
|
||||
import AddServerForm from './components/AddServerForm'
|
||||
import EditServerForm from './components/EditServerForm'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import ChangePasswordPage from './pages/ChangePasswordPage'
|
||||
import ProtectedRoute from './components/ProtectedRoute'
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
|
||||
// 配置选项
|
||||
const CONFIG = {
|
||||
// 初始化启动阶段的配置
|
||||
startup: {
|
||||
maxAttempts: 60, // 初始化阶段最大尝试次数
|
||||
pollingInterval: 3000 // 初始阶段轮询间隔 (3秒)
|
||||
},
|
||||
// 正常运行阶段的配置
|
||||
normal: {
|
||||
pollingInterval: 10000 // 正常运行时的轮询间隔 (10秒)
|
||||
}
|
||||
}
|
||||
|
||||
// Dashboard component that contains the main application
|
||||
const Dashboard = () => {
|
||||
const { t } = useTranslation()
|
||||
const [servers, setServers] = useState<Server[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
const [editingServer, setEditingServer] = useState<Server | null>(null)
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true)
|
||||
const [fetchAttempts, setFetchAttempts] = useState(0)
|
||||
const { auth, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// 轮询定时器引用
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
// 保存当前尝试次数,避免依赖循环
|
||||
const attemptsRef = useRef<number>(0)
|
||||
|
||||
// 清理定时器
|
||||
const clearTimer = () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
// 开始正常轮询
|
||||
const startNormalPolling = useCallback(() => {
|
||||
// 确保没有其他定时器在运行
|
||||
clearTimer()
|
||||
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/servers', {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
});
|
||||
const data = await response.json()
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data)
|
||||
} else if (data && Array.isArray(data)) {
|
||||
setServers(data)
|
||||
} else {
|
||||
console.error('Invalid server data format:', data)
|
||||
setServers([])
|
||||
}
|
||||
|
||||
// 重置错误状态
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error('Error fetching servers during normal polling:', err)
|
||||
|
||||
// 使用友好的错误消息
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'))
|
||||
} else if (err instanceof TypeError && (
|
||||
err.message.includes('NetworkError') ||
|
||||
err.message.includes('Failed to fetch')
|
||||
)) {
|
||||
setError(t('errors.serverConnection'))
|
||||
} else {
|
||||
setError(t('errors.serverFetch'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 立即执行一次
|
||||
fetchServers()
|
||||
|
||||
// 设置定期轮询
|
||||
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval)
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
// 重置尝试计数
|
||||
if (refreshKey > 0) {
|
||||
attemptsRef.current = 0;
|
||||
setFetchAttempts(0);
|
||||
}
|
||||
|
||||
// 初始化加载阶段的请求函数
|
||||
const fetchInitialData = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/servers', {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
});
|
||||
const data = await response.json()
|
||||
|
||||
// 处理API响应中的包装对象,提取data字段
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data)
|
||||
setIsInitialLoading(false)
|
||||
// 初始化成功,开始正常轮询
|
||||
startNormalPolling()
|
||||
return true
|
||||
} else if (data && Array.isArray(data)) {
|
||||
// 兼容性处理,如果API直接返回数组
|
||||
setServers(data)
|
||||
setIsInitialLoading(false)
|
||||
// 初始化成功,开始正常轮询
|
||||
startNormalPolling()
|
||||
return true
|
||||
} else {
|
||||
// 如果数据格式不符合预期,设置为空数组
|
||||
console.error('Invalid server data format:', data)
|
||||
setServers([])
|
||||
setIsInitialLoading(false)
|
||||
// 初始化成功但数据为空,开始正常轮询
|
||||
startNormalPolling()
|
||||
return true
|
||||
}
|
||||
} catch (err) {
|
||||
// 增加尝试次数计数,使用 ref 避免触发 effect 重新运行
|
||||
attemptsRef.current += 1;
|
||||
console.error(`Initial loading attempt ${attemptsRef.current} failed:`, err)
|
||||
|
||||
// 更新状态用于显示
|
||||
setFetchAttempts(attemptsRef.current)
|
||||
|
||||
// 设置适当的错误消息
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'))
|
||||
} else {
|
||||
setError(t('errors.initialStartup'))
|
||||
}
|
||||
|
||||
// 如果已超过最大尝试次数,放弃初始化并切换到正常轮询
|
||||
if (attemptsRef.current >= CONFIG.startup.maxAttempts) {
|
||||
console.log('Maximum startup attempts reached, switching to normal polling')
|
||||
setIsInitialLoading(false)
|
||||
// 清除初始化的轮询
|
||||
clearTimer()
|
||||
// 切换到正常轮询模式
|
||||
startNormalPolling()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时,根据当前状态设置适当的轮询
|
||||
if (isInitialLoading) {
|
||||
// 确保没有其他定时器在运行
|
||||
clearTimer()
|
||||
|
||||
// 立即执行一次初始请求
|
||||
fetchInitialData()
|
||||
|
||||
// 设置初始阶段的轮询间隔
|
||||
intervalRef.current = setInterval(fetchInitialData, CONFIG.startup.pollingInterval)
|
||||
console.log(`Started initial polling with interval: ${CONFIG.startup.pollingInterval}ms`)
|
||||
} else {
|
||||
// 已经初始化完成,开始正常轮询
|
||||
startNormalPolling()
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
clearTimer()
|
||||
}
|
||||
}, [refreshKey, t, isInitialLoading, startNormalPolling])
|
||||
|
||||
// 手动触发刷新
|
||||
const triggerRefresh = () => {
|
||||
// 清除当前的定时器
|
||||
clearTimer()
|
||||
|
||||
// 如果在初始化阶段,重置初始化状态
|
||||
if (isInitialLoading) {
|
||||
setIsInitialLoading(true)
|
||||
attemptsRef.current = 0
|
||||
setFetchAttempts(0)
|
||||
}
|
||||
|
||||
// refreshKey 的改变会触发 useEffect 再次运行
|
||||
setRefreshKey(prevKey => prevKey + 1)
|
||||
}
|
||||
|
||||
const handleServerAdd = () => {
|
||||
setRefreshKey(prevKey => prevKey + 1)
|
||||
}
|
||||
|
||||
const handleServerEdit = (server: Server) => {
|
||||
// Fetch settings to get the full server config before editing
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
fetch(`/api/settings`, {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then((settingsData: ApiResponse<{ mcpServers: Record<string, any> }>) => {
|
||||
if (
|
||||
settingsData &&
|
||||
settingsData.success &&
|
||||
settingsData.data &&
|
||||
settingsData.data.mcpServers &&
|
||||
settingsData.data.mcpServers[server.name]
|
||||
) {
|
||||
const serverConfig = settingsData.data.mcpServers[server.name]
|
||||
const fullServerData = {
|
||||
name: server.name,
|
||||
status: server.status,
|
||||
tools: server.tools || [],
|
||||
config: serverConfig,
|
||||
}
|
||||
|
||||
console.log('Editing server with config:', fullServerData)
|
||||
setEditingServer(fullServerData)
|
||||
} else {
|
||||
console.error('Failed to get server config from settings:', settingsData)
|
||||
setError(t('server.invalidConfig', { serverName: server.name }))
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error fetching server settings:', err)
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
}
|
||||
|
||||
const handleEditComplete = () => {
|
||||
setEditingServer(null)
|
||||
setRefreshKey(prevKey => prevKey + 1)
|
||||
}
|
||||
|
||||
const handleServerRemove = async (serverName: string) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(`/api/servers/${serverName}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
})
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setError(result.message || t('server.deleteError', { serverName }))
|
||||
return
|
||||
}
|
||||
|
||||
setRefreshKey(prevKey => prevKey + 1)
|
||||
} catch (err) {
|
||||
setError(t('errors.general') + ': ' + (err instanceof Error ? err.message : String(err)))
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 p-8">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-red-600 text-lg font-medium">{t('app.error')}</h3>
|
||||
<p className="text-gray-600 mt-1">{error}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="ml-4 text-gray-500 hover:text-gray-700"
|
||||
aria-label={t('app.closeButton')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 111.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>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">{t('app.title')}</h1>
|
||||
<div className="flex items-center">
|
||||
<AddServerForm onAdd={handleServerAdd} />
|
||||
<button
|
||||
onClick={() => navigate('/change-password')}
|
||||
className="ml-4 bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
|
||||
>
|
||||
{t('app.changePassword')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="ml-4 bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600"
|
||||
>
|
||||
{t('app.logout')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{servers.length === 0 ? (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<p className="text-gray-600">{t('app.noServers')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{servers.map((server, index) => (
|
||||
<ServerCard
|
||||
key={index}
|
||||
server={server}
|
||||
onRemove={handleServerRemove}
|
||||
onEdit={handleServerEdit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{editingServer && (
|
||||
<EditServerForm
|
||||
server={editingServer}
|
||||
onEdit={handleEditComplete}
|
||||
onCancel={() => setEditingServer(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
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';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
{/* 公共路由 */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* 受保护的路由,使用 MainLayout 作为布局容器 */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/change-password" element={<ChangePasswordPage />} />
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/servers" element={<ServersPage />} />
|
||||
<Route path="/groups" element={<GroupsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* 未匹配的路由重定向到首页 */}
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
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 bg-opacity-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
|
||||
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 bg-opacity-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
|
||||
141
frontend/src/components/GroupCard.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
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'
|
||||
|
||||
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 [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) {
|
||||
alert(t('common.copyFailed') || 'Copy failed')
|
||||
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
|
||||
@@ -10,12 +10,14 @@ interface ServerCardProps {
|
||||
server: Server
|
||||
onRemove: (serverName: string) => void
|
||||
onEdit: (server: Server) => void
|
||||
onToggle?: (server: Server, enabled: boolean) => void
|
||||
}
|
||||
|
||||
const ServerCard = ({ server, onRemove, onEdit }: ServerCardProps) => {
|
||||
const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isToggling, setIsToggling] = useState(false)
|
||||
|
||||
const handleRemove = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
@@ -27,38 +29,83 @@ const ServerCard = ({ server, onRemove, onEdit }: ServerCardProps) => {
|
||||
onEdit(server)
|
||||
}
|
||||
|
||||
const handleToggle = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (isToggling || !onToggle) return
|
||||
|
||||
setIsToggling(true)
|
||||
try {
|
||||
await onToggle(server, !(server.enabled !== false))
|
||||
} finally {
|
||||
setIsToggling(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
onRemove(server.name)
|
||||
setShowDeleteDialog(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<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 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>
|
||||
<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 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">
|
||||
<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>
|
||||
</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
|
||||
@@ -67,18 +114,7 @@ const ServerCard = ({ server, onRemove, onEdit }: ServerCardProps) => {
|
||||
onConfirm={handleConfirmDelete}
|
||||
serverName={server.name}
|
||||
/>
|
||||
|
||||
{isExpanded && server.tools && (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-medium 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { ChevronDown, ChevronRight, Edit, Trash, Copy, Check } from 'lucide-react'
|
||||
|
||||
export { ChevronDown, ChevronRight }
|
||||
export { ChevronDown, ChevronRight, Edit, Trash, Copy, Check }
|
||||
|
||||
const LucideIcons = {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Edit,
|
||||
Trash,
|
||||
Copy,
|
||||
Check
|
||||
}
|
||||
|
||||
export default LucideIcons
|
||||
17
frontend/src/components/layout/Content.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
interface ContentProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const Content: React.FC<ContentProps> = ({ children }) => {
|
||||
return (
|
||||
<main className="flex-1 p-6 overflow-auto">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default Content;
|
||||
61
frontend/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
interface HeaderProps {
|
||||
onToggleSidebar: () => void;
|
||||
}
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { auth, logout } = useAuth();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="bg-white shadow-sm z-10">
|
||||
<div className="flex justify-between items-center px-4 py-3">
|
||||
<div className="flex items-center">
|
||||
{/* 侧边栏切换按钮 */}
|
||||
<button
|
||||
onClick={onToggleSidebar}
|
||||
className="p-2 rounded-md text-gray-500 hover:text-gray-900 hover:bg-gray-100 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>
|
||||
</div>
|
||||
|
||||
{/* 用户信息和操作 */}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
89
frontend/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
path: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
// 菜单项配置
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
path: '/',
|
||||
label: t('nav.dashboard'),
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z" />
|
||||
<path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/servers',
|
||||
label: t('nav.servers'),
|
||||
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="M2 5a2 2 0 012-2h12a2 2 0 012 2v2a2 2 0 01-2 2H4a2 2 0 01-2-2V5zm14 1a1 1 0 11-2 0 1 1 0 012 0zM2 13a2 2 0 012-2h12a2 2 0 012 2v2a2 2 0 01-2 2H4a2 2 0 01-2-2v-2zm14 1a1 1 0 11-2 0 1 1 0 012 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 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: '/settings',
|
||||
label: t('nav.settings'),
|
||||
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" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`bg-white shadow-sm transition-all duration-300 ease-in-out ${
|
||||
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>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
@@ -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>
|
||||
|
||||
100
frontend/src/components/ui/ToggleGroup.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
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
|
||||
};
|
||||
};
|
||||
306
frontend/src/hooks/useServerData.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server, ApiResponse } from '@/types';
|
||||
|
||||
// 配置选项
|
||||
const CONFIG = {
|
||||
// 初始化启动阶段的配置
|
||||
startup: {
|
||||
maxAttempts: 60, // 初始化阶段最大尝试次数
|
||||
pollingInterval: 3000 // 初始阶段轮询间隔 (3秒)
|
||||
},
|
||||
// 正常运行阶段的配置
|
||||
normal: {
|
||||
pollingInterval: 10000 // 正常运行时的轮询间隔 (10秒)
|
||||
}
|
||||
};
|
||||
|
||||
export const useServerData = () => {
|
||||
const { t } = useTranslation();
|
||||
const [servers, setServers] = useState<Server[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const [fetchAttempts, setFetchAttempts] = useState(0);
|
||||
|
||||
// 轮询定时器引用
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
// 保存当前尝试次数,避免依赖循环
|
||||
const attemptsRef = useRef<number>(0);
|
||||
|
||||
// 清理定时器
|
||||
const clearTimer = () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 开始正常轮询
|
||||
const startNormalPolling = useCallback(() => {
|
||||
// 确保没有其他定时器在运行
|
||||
clearTimer();
|
||||
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/servers', {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
} else if (data && Array.isArray(data)) {
|
||||
setServers(data);
|
||||
} else {
|
||||
console.error('Invalid server data format:', data);
|
||||
setServers([]);
|
||||
}
|
||||
|
||||
// 重置错误状态
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching servers during normal polling:', err);
|
||||
|
||||
// 使用友好的错误消息
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else if (err instanceof TypeError && (
|
||||
err.message.includes('NetworkError') ||
|
||||
err.message.includes('Failed to fetch')
|
||||
)) {
|
||||
setError(t('errors.serverConnection'));
|
||||
} else {
|
||||
setError(t('errors.serverFetch'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 立即执行一次
|
||||
fetchServers();
|
||||
|
||||
// 设置定期轮询
|
||||
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
// 重置尝试计数
|
||||
if (refreshKey > 0) {
|
||||
attemptsRef.current = 0;
|
||||
setFetchAttempts(0);
|
||||
}
|
||||
|
||||
// 初始化加载阶段的请求函数
|
||||
const fetchInitialData = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/servers', {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
// 处理API响应中的包装对象,提取data字段
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
setIsInitialLoading(false);
|
||||
// 初始化成功,开始正常轮询
|
||||
startNormalPolling();
|
||||
return true;
|
||||
} else if (data && Array.isArray(data)) {
|
||||
// 兼容性处理,如果API直接返回数组
|
||||
setServers(data);
|
||||
setIsInitialLoading(false);
|
||||
// 初始化成功,开始正常轮询
|
||||
startNormalPolling();
|
||||
return true;
|
||||
} else {
|
||||
// 如果数据格式不符合预期,设置为空数组
|
||||
console.error('Invalid server data format:', data);
|
||||
setServers([]);
|
||||
setIsInitialLoading(false);
|
||||
// 初始化成功但数据为空,开始正常轮询
|
||||
startNormalPolling();
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
// 增加尝试次数计数,使用 ref 避免触发 effect 重新运行
|
||||
attemptsRef.current += 1;
|
||||
console.error(`Initial loading attempt ${attemptsRef.current} failed:`, err);
|
||||
|
||||
// 更新状态用于显示
|
||||
setFetchAttempts(attemptsRef.current);
|
||||
|
||||
// 设置适当的错误消息
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else {
|
||||
setError(t('errors.initialStartup'));
|
||||
}
|
||||
|
||||
// 如果已超过最大尝试次数,放弃初始化并切换到正常轮询
|
||||
if (attemptsRef.current >= CONFIG.startup.maxAttempts) {
|
||||
console.log('Maximum startup attempts reached, switching to normal polling');
|
||||
setIsInitialLoading(false);
|
||||
// 清除初始化的轮询
|
||||
clearTimer();
|
||||
// 切换到正常轮询模式
|
||||
startNormalPolling();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 组件挂载时,根据当前状态设置适当的轮询
|
||||
if (isInitialLoading) {
|
||||
// 确保没有其他定时器在运行
|
||||
clearTimer();
|
||||
|
||||
// 立即执行一次初始请求
|
||||
fetchInitialData();
|
||||
|
||||
// 设置初始阶段的轮询间隔
|
||||
intervalRef.current = setInterval(fetchInitialData, CONFIG.startup.pollingInterval);
|
||||
console.log(`Started initial polling with interval: ${CONFIG.startup.pollingInterval}ms`);
|
||||
} else {
|
||||
// 已经初始化完成,开始正常轮询
|
||||
startNormalPolling();
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
clearTimer();
|
||||
};
|
||||
}, [refreshKey, t, isInitialLoading, startNormalPolling]);
|
||||
|
||||
// 手动触发刷新
|
||||
const triggerRefresh = () => {
|
||||
// 清除当前的定时器
|
||||
clearTimer();
|
||||
|
||||
// 如果在初始化阶段,重置初始化状态
|
||||
if (isInitialLoading) {
|
||||
setIsInitialLoading(true);
|
||||
attemptsRef.current = 0;
|
||||
setFetchAttempts(0);
|
||||
}
|
||||
|
||||
// refreshKey 的改变会触发 useEffect 再次运行
|
||||
setRefreshKey(prevKey => prevKey + 1);
|
||||
};
|
||||
|
||||
// 服务器相关操作
|
||||
const handleServerAdd = () => {
|
||||
setRefreshKey(prevKey => prevKey + 1);
|
||||
};
|
||||
|
||||
const handleServerEdit = async (server: Server) => {
|
||||
try {
|
||||
// Fetch settings to get the full server config before editing
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(`/api/settings`, {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
});
|
||||
|
||||
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> = await response.json();
|
||||
|
||||
if (
|
||||
settingsData &&
|
||||
settingsData.success &&
|
||||
settingsData.data &&
|
||||
settingsData.data.mcpServers &&
|
||||
settingsData.data.mcpServers[server.name]
|
||||
) {
|
||||
const serverConfig = settingsData.data.mcpServers[server.name];
|
||||
return {
|
||||
name: server.name,
|
||||
status: server.status,
|
||||
tools: server.tools || [],
|
||||
config: serverConfig,
|
||||
};
|
||||
} else {
|
||||
console.error('Failed to get server config from settings:', settingsData);
|
||||
setError(t('server.invalidConfig', { serverName: server.name }));
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching server settings:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerRemove = async (serverName: string) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(`/api/servers/${serverName}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(result.message || t('server.deleteError', { serverName }));
|
||||
return false;
|
||||
}
|
||||
|
||||
setRefreshKey(prevKey => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(t('errors.general') + ': ' + (err instanceof Error ? err.message : String(err)));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerToggle = async (server: Server, enabled: boolean) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(`/api/servers/${server.name}/toggle`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || ''
|
||||
},
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to toggle server:', result);
|
||||
setError(t('server.toggleError', { serverName: server.name }));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update the UI immediately to reflect the change
|
||||
setRefreshKey(prevKey => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error toggling server:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
servers,
|
||||
error,
|
||||
setError,
|
||||
isLoading: isInitialLoading,
|
||||
fetchAttempts,
|
||||
triggerRefresh,
|
||||
handleServerAdd,
|
||||
handleServerEdit,
|
||||
handleServerRemove,
|
||||
handleServerToggle
|
||||
};
|
||||
};
|
||||
33
frontend/src/layouts/MainLayout.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Header from '@/components/layout/Header';
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
import Content from '@/components/layout/Content';
|
||||
|
||||
const MainLayout: React.FC = () => {
|
||||
// 控制侧边栏展开/折叠状态
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false);
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setSidebarCollapsed(!sidebarCollapsed);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-gray-100">
|
||||
{/* 顶部导航 */}
|
||||
<Header onToggleSidebar={toggleSidebar} />
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* 侧边导航 */}
|
||||
<Sidebar collapsed={sidebarCollapsed} />
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<Content>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainLayout;
|
||||
@@ -7,7 +7,9 @@
|
||||
"loading": "Loading...",
|
||||
"logout": "Logout",
|
||||
"profile": "Profile",
|
||||
"changePassword": "Change Password"
|
||||
"changePassword": "Change Password",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"welcomeUser": "Welcome, {{username}}"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
@@ -32,6 +34,7 @@
|
||||
"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",
|
||||
@@ -51,7 +54,14 @@
|
||||
"envVars": "Environment Variables",
|
||||
"key": "key",
|
||||
"value": "value",
|
||||
"remove": "Remove"
|
||||
"enabled": "Enabled",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"remove": "Remove",
|
||||
"toggleError": "Failed to toggle server {{serverName}}",
|
||||
"alreadyExists": "Server {{serverName}} already exists",
|
||||
"invalidData": "Invalid server data provided",
|
||||
"notFound": "Server {{serverName}} not found"
|
||||
},
|
||||
"status": {
|
||||
"online": "Online",
|
||||
@@ -68,7 +78,65 @@
|
||||
"initialStartup": "The server might be starting up. Please wait a moment as this process can take some time on first launch..."
|
||||
},
|
||||
"common": {
|
||||
"processing": "Processing...",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel"
|
||||
"cancel": "Cancel",
|
||||
"refresh": "Refresh",
|
||||
"create": "Create",
|
||||
"submitting": "Submitting...",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"servers": "Servers",
|
||||
"groups": "Groups",
|
||||
"settings": "Settings",
|
||||
"changePassword": "Change Password"
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"totalServers": "Total Servers",
|
||||
"onlineServers": "Online Servers",
|
||||
"offlineServers": "Offline Servers",
|
||||
"connectingServers": "Connecting Servers",
|
||||
"recentServers": "Recent Servers"
|
||||
},
|
||||
"servers": {
|
||||
"title": "Servers Management"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Group Management"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"language": "Language"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,9 @@
|
||||
"loading": "加载中...",
|
||||
"logout": "退出登录",
|
||||
"profile": "个人资料",
|
||||
"changePassword": "修改密码"
|
||||
"changePassword": "修改密码",
|
||||
"toggleSidebar": "切换侧边栏",
|
||||
"welcomeUser": "欢迎, {{username}}"
|
||||
},
|
||||
"auth": {
|
||||
"login": "登录",
|
||||
@@ -32,6 +34,7 @@
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"confirmDelete": "您确定要删除此服务器吗?",
|
||||
"deleteWarning": "删除服务器 '{{name}}' 将会移除该服务器及其所有数据。此操作无法撤销。",
|
||||
"status": "状态",
|
||||
"tools": "工具",
|
||||
"name": "服务器名称",
|
||||
@@ -51,7 +54,14 @@
|
||||
"envVars": "环境变量",
|
||||
"key": "键",
|
||||
"value": "值",
|
||||
"remove": "移除"
|
||||
"enabled": "已启用",
|
||||
"enable": "启用",
|
||||
"disable": "禁用",
|
||||
"remove": "移除",
|
||||
"toggleError": "切换服务器 {{serverName}} 状态失败",
|
||||
"alreadyExists": "服务器 {{serverName}} 已经存在",
|
||||
"invalidData": "提供的服务器数据无效",
|
||||
"notFound": "找不到服务器 {{serverName}}"
|
||||
},
|
||||
"status": {
|
||||
"online": "在线",
|
||||
@@ -68,7 +78,65 @@
|
||||
"initialStartup": "服务器可能正在启动中。首次启动可能需要一些时间,请耐心等候..."
|
||||
},
|
||||
"common": {
|
||||
"processing": "处理中...",
|
||||
"save": "保存",
|
||||
"cancel": "取消"
|
||||
"cancel": "取消",
|
||||
"refresh": "刷新",
|
||||
"create": "创建",
|
||||
"submitting": "提交中...",
|
||||
"delete": "删除"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "仪表盘",
|
||||
"servers": "服务器",
|
||||
"settings": "设置",
|
||||
"changePassword": "修改密码",
|
||||
"groups": "分组"
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"title": "仪表盘",
|
||||
"totalServers": "服务器总数",
|
||||
"onlineServers": "在线服务器",
|
||||
"offlineServers": "离线服务器",
|
||||
"connectingServers": "连接中服务",
|
||||
"recentServers": "最近的服务器"
|
||||
},
|
||||
"servers": {
|
||||
"title": "服务器管理"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
"language": "语言"
|
||||
},
|
||||
"groups": {
|
||||
"title": "分组管理"
|
||||
}
|
||||
},
|
||||
"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}} 台服务器"
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ChangePasswordForm from '../components/ChangePasswordForm';
|
||||
|
||||
const ChangePasswordPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSuccess = () => {
|
||||
setTimeout(() => {
|
||||
navigate('/');
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-md mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-6">{t('auth.changePassword')}</h1>
|
||||
<ChangePasswordForm onSuccess={handleSuccess} onCancel={handleCancel} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangePasswordPage;
|
||||
206
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useServerData } from '@/hooks/useServerData';
|
||||
import { ServerStatus } from '@/types';
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { servers, error, setError, isLoading } = useServerData();
|
||||
|
||||
// 计算服务器统计信息
|
||||
const serverStats = {
|
||||
total: servers.length,
|
||||
online: servers.filter(server => server.status === 'connected').length,
|
||||
offline: servers.filter(server => server.status === 'disconnected').length,
|
||||
connecting: servers.filter(server => server.status === 'connecting').length
|
||||
};
|
||||
|
||||
// Map status to translation keys
|
||||
const statusTranslations = {
|
||||
connected: 'status.online',
|
||||
disconnected: 'status.offline',
|
||||
connecting: 'status.connecting'
|
||||
}
|
||||
|
||||
// 计算各状态百分比(用于仪表板展示)
|
||||
const getStatusPercentage = (status: ServerStatus) => {
|
||||
if (servers.length === 0) return 0;
|
||||
return Math.round((servers.filter(server => server.status === status).length / servers.length) * 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-8">{t('pages.dashboard.title')}</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-red-600 text-lg font-medium">{t('app.error')}</h3>
|
||||
<p className="text-gray-600 mt-1">{error}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="ml-4 text-gray-500 hover:text-gray-700"
|
||||
aria-label={t('app.closeButton')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 111.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>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<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>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{/* 服务器总数 */}
|
||||
<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">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.totalServers')}</h2>
|
||||
<p className="text-3xl font-bold text-gray-900">{serverStats.total}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 在线服务器 */}
|
||||
<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">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.onlineServers')}</h2>
|
||||
<p className="text-3xl font-bold text-gray-900">{serverStats.online}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 h-2 bg-gray-200 rounded-full">
|
||||
<div
|
||||
className="h-full bg-green-500 rounded-full"
|
||||
style={{ width: `${getStatusPercentage('connected')}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 离线服务器 */}
|
||||
<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">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.offlineServers')}</h2>
|
||||
<p className="text-3xl font-bold text-gray-900">{serverStats.offline}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 h-2 bg-gray-200 rounded-full">
|
||||
<div
|
||||
className="h-full bg-red-500 rounded-full"
|
||||
style={{ width: `${getStatusPercentage('disconnected')}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 连接中服务器 */}
|
||||
<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">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.connectingServers')}</h2>
|
||||
<p className="text-3xl font-bold text-gray-900">{serverStats.connecting}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 h-2 bg-gray-200 rounded-full">
|
||||
<div
|
||||
className="h-full bg-yellow-500 rounded-full"
|
||||
style={{ width: `${getStatusPercentage('connecting')}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 最近活动列表 */}
|
||||
{servers.length > 0 && !isLoading && (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">{t('pages.dashboard.recentServers')}</h2>
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('server.name')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('server.status')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('server.tools')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('server.enabled')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{servers.slice(0, 5).map((server, index) => (
|
||||
<tr key={index}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{server.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${server.status === 'connected'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: server.status === 'disconnected'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{t(statusTranslations[server.status] || server.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{server.tools?.length || 0}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{server.enabled !== false ? (
|
||||
<span className="text-green-600">✓</span>
|
||||
) : (
|
||||
<span className="text-red-600">✗</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
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;
|
||||
111
frontend/src/pages/ServersPage.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server } from '@/types';
|
||||
import ServerCard from '@/components/ServerCard';
|
||||
import AddServerForm from '@/components/AddServerForm';
|
||||
import EditServerForm from '@/components/EditServerForm';
|
||||
import { useServerData } from '@/hooks/useServerData';
|
||||
|
||||
const ServersPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
servers,
|
||||
error,
|
||||
setError,
|
||||
isLoading,
|
||||
handleServerAdd,
|
||||
handleServerEdit,
|
||||
handleServerRemove,
|
||||
handleServerToggle
|
||||
} = useServerData();
|
||||
const [editingServer, setEditingServer] = useState<Server | null>(null);
|
||||
|
||||
const handleEditClick = async (server: Server) => {
|
||||
const fullServerData = await handleServerEdit(server);
|
||||
if (fullServerData) {
|
||||
setEditingServer(fullServerData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditComplete = () => {
|
||||
setEditingServer(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-red-600 text-lg font-medium">{t('app.error')}</h3>
|
||||
<p className="text-gray-600 mt-1">{error}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="ml-4 text-gray-500 hover:text-gray-700"
|
||||
aria-label={t('app.closeButton')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 111.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>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<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('app.noServers')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{servers.map((server, index) => (
|
||||
<ServerCard
|
||||
key={index}
|
||||
server={server}
|
||||
onRemove={handleServerRemove}
|
||||
onEdit={handleEditClick}
|
||||
onToggle={handleServerToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingServer && (
|
||||
<EditServerForm
|
||||
server={editingServer}
|
||||
onEdit={handleEditComplete}
|
||||
onCancel={() => setEditingServer(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServersPage;
|
||||
55
frontend/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ChangePasswordForm from '@/components/ChangePasswordForm';
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handlePasswordChangeSuccess = () => {
|
||||
setTimeout(() => {
|
||||
navigate('/');
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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} />
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
||||
@@ -1,57 +1,73 @@
|
||||
// 服务器状态类型
|
||||
// Server status types
|
||||
export type ServerStatus = 'connecting' | 'connected' | 'disconnected';
|
||||
|
||||
// 工具输入模式类型
|
||||
// Tool input schema types
|
||||
export interface ToolInputSchema {
|
||||
type: string;
|
||||
properties?: Record<string, any>;
|
||||
properties?: Record<string, unknown>;
|
||||
required?: string[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 工具类型
|
||||
// Tool types
|
||||
export interface Tool {
|
||||
name: string;
|
||||
description?: string;
|
||||
description: string;
|
||||
inputSchema: ToolInputSchema;
|
||||
}
|
||||
|
||||
// 服务器配置类型
|
||||
// Server config types
|
||||
export interface ServerConfig {
|
||||
url?: string;
|
||||
command?: string;
|
||||
args?: string[] | string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
// 服务器类型
|
||||
// Server types
|
||||
export interface Server {
|
||||
name: string;
|
||||
status: ServerStatus;
|
||||
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[];
|
||||
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
|
||||
@@ -61,10 +77,9 @@ export interface IUser {
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
loading: boolean;
|
||||
user: IUser | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
@@ -87,5 +102,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));
|
||||
}
|
||||
@@ -30,6 +30,7 @@
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@types/react": "^19.0.12",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -50,6 +51,7 @@
|
||||
"tailwind-merge": "^3.1.0",
|
||||
"tailwind-scrollbar-hide": "^2.0.0",
|
||||
"tailwindcss": "^4.0.17",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -70,6 +72,6 @@
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.2.6"
|
||||
"vite": "^5.4.18"
|
||||
}
|
||||
}
|
||||
207
pnpm-lock.yaml
generated
@@ -22,13 +22,16 @@ importers:
|
||||
version: 0.0.4
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.3
|
||||
version: 4.1.3(vite@5.4.17(@types/node@20.17.28)(lightningcss@1.29.2))
|
||||
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)
|
||||
'@types/uuid':
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0
|
||||
autoprefixer:
|
||||
specifier: ^10.4.21
|
||||
version: 10.4.21(postcss@8.5.3)
|
||||
@@ -89,6 +92,9 @@ importers:
|
||||
tailwindcss:
|
||||
specifier: ^4.0.17
|
||||
version: 4.0.17
|
||||
uuid:
|
||||
specifier: ^11.1.0
|
||||
version: 11.1.0
|
||||
zod:
|
||||
specifier: ^3.24.2
|
||||
version: 3.24.2
|
||||
@@ -119,7 +125,7 @@ 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.17(@types/node@20.17.28)(lightningcss@1.29.2))
|
||||
version: 4.3.4(vite@5.4.18(@types/node@20.17.28)(lightningcss@1.29.2))
|
||||
concurrently:
|
||||
specifier: ^8.2.2
|
||||
version: 8.2.2
|
||||
@@ -145,8 +151,8 @@ importers:
|
||||
specifier: ^5.2.2
|
||||
version: 5.8.2
|
||||
vite:
|
||||
specifier: ^5.2.6
|
||||
version: 5.4.17(@types/node@20.17.28)(lightningcss@1.29.2)
|
||||
specifier: ^5.4.18
|
||||
version: 5.4.18(@types/node@20.17.28)(lightningcss@1.29.2)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -1068,103 +1074,103 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.39.0':
|
||||
resolution: {integrity: sha512-lGVys55Qb00Wvh8DMAocp5kIcaNzEFTmGhfFd88LfaogYTRKrdxgtlO5H6S49v2Nd8R2C6wLOal0qv6/kCkOwA==}
|
||||
'@rollup/rollup-android-arm-eabi@4.40.0':
|
||||
resolution: {integrity: sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg==}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-android-arm64@4.39.0':
|
||||
resolution: {integrity: sha512-It9+M1zE31KWfqh/0cJLrrsCPiF72PoJjIChLX+rEcujVRCb4NLQ5QzFkzIZW8Kn8FTbvGQBY5TkKBau3S8cCQ==}
|
||||
'@rollup/rollup-android-arm64@4.40.0':
|
||||
resolution: {integrity: sha512-PPA6aEEsTPRz+/4xxAmaoWDqh67N7wFbgFUJGMnanCFs0TV99M0M8QhhaSCks+n6EbQoFvLQgYOGXxlMGQe/6w==}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.39.0':
|
||||
resolution: {integrity: sha512-lXQnhpFDOKDXiGxsU9/l8UEGGM65comrQuZ+lDcGUx+9YQ9dKpF3rSEGepyeR5AHZ0b5RgiligsBhWZfSSQh8Q==}
|
||||
'@rollup/rollup-darwin-arm64@4.40.0':
|
||||
resolution: {integrity: sha512-GwYOcOakYHdfnjjKwqpTGgn5a6cUX7+Ra2HeNj/GdXvO2VJOOXCiYYlRFU4CubFM67EhbmzLOmACKEfvp3J1kQ==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.39.0':
|
||||
resolution: {integrity: sha512-mKXpNZLvtEbgu6WCkNij7CGycdw9cJi2k9v0noMb++Vab12GZjFgUXD69ilAbBh034Zwn95c2PNSz9xM7KYEAQ==}
|
||||
'@rollup/rollup-darwin-x64@4.40.0':
|
||||
resolution: {integrity: sha512-CoLEGJ+2eheqD9KBSxmma6ld01czS52Iw0e2qMZNpPDlf7Z9mj8xmMemxEucinev4LgHalDPczMyxzbq+Q+EtA==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.39.0':
|
||||
resolution: {integrity: sha512-jivRRlh2Lod/KvDZx2zUR+I4iBfHcu2V/BA2vasUtdtTN2Uk3jfcZczLa81ESHZHPHy4ih3T/W5rPFZ/hX7RtQ==}
|
||||
'@rollup/rollup-freebsd-arm64@4.40.0':
|
||||
resolution: {integrity: sha512-r7yGiS4HN/kibvESzmrOB/PxKMhPTlz+FcGvoUIKYoTyGd5toHp48g1uZy1o1xQvybwwpqpe010JrcGG2s5nkg==}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.39.0':
|
||||
resolution: {integrity: sha512-8RXIWvYIRK9nO+bhVz8DwLBepcptw633gv/QT4015CpJ0Ht8punmoHU/DuEd3iw9Hr8UwUV+t+VNNuZIWYeY7Q==}
|
||||
'@rollup/rollup-freebsd-x64@4.40.0':
|
||||
resolution: {integrity: sha512-mVDxzlf0oLzV3oZOr0SMJ0lSDd3xC4CmnWJ8Val8isp9jRGl5Dq//LLDSPFrasS7pSm6m5xAcKaw3sHXhBjoRw==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.39.0':
|
||||
resolution: {integrity: sha512-mz5POx5Zu58f2xAG5RaRRhp3IZDK7zXGk5sdEDj4o96HeaXhlUwmLFzNlc4hCQi5sGdR12VDgEUqVSHer0lI9g==}
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.40.0':
|
||||
resolution: {integrity: sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.39.0':
|
||||
resolution: {integrity: sha512-+YDwhM6gUAyakl0CD+bMFpdmwIoRDzZYaTWV3SDRBGkMU/VpIBYXXEvkEcTagw/7VVkL2vA29zU4UVy1mP0/Yw==}
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.40.0':
|
||||
resolution: {integrity: sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.39.0':
|
||||
resolution: {integrity: sha512-EKf7iF7aK36eEChvlgxGnk7pdJfzfQbNvGV/+l98iiMwU23MwvmV0Ty3pJ0p5WQfm3JRHOytSIqD9LB7Bq7xdQ==}
|
||||
'@rollup/rollup-linux-arm64-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.39.0':
|
||||
resolution: {integrity: sha512-vYanR6MtqC7Z2SNr8gzVnzUul09Wi1kZqJaek3KcIlI/wq5Xtq4ZPIZ0Mr/st/sv/NnaPwy/D4yXg5x0B3aUUA==}
|
||||
'@rollup/rollup-linux-arm64-musl@4.40.0':
|
||||
resolution: {integrity: sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.39.0':
|
||||
resolution: {integrity: sha512-NMRUT40+h0FBa5fb+cpxtZoGAggRem16ocVKIv5gDB5uLDgBIwrIsXlGqYbLwW8YyO3WVTk1FkFDjMETYlDqiw==}
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.39.0':
|
||||
resolution: {integrity: sha512-0pCNnmxgduJ3YRt+D+kJ6Ai/r+TaePu9ZLENl+ZDV/CdVczXl95CbIiwwswu4L+K7uOIGf6tMo2vm8uadRaICQ==}
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.39.0':
|
||||
resolution: {integrity: sha512-t7j5Zhr7S4bBtksT73bO6c3Qa2AV/HqiGlj9+KB3gNF5upcVkx+HLgxTm8DK4OkzsOYqbdqbLKwvGMhylJCPhQ==}
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.39.0':
|
||||
resolution: {integrity: sha512-m6cwI86IvQ7M93MQ2RF5SP8tUjD39Y7rjb1qjHgYh28uAPVU8+k/xYWvxRO3/tBN2pZkSMa5RjnPuUIbrwVxeA==}
|
||||
'@rollup/rollup-linux-riscv64-musl@4.40.0':
|
||||
resolution: {integrity: sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.39.0':
|
||||
resolution: {integrity: sha512-iRDJd2ebMunnk2rsSBYlsptCyuINvxUfGwOUldjv5M4tpa93K8tFMeYGpNk2+Nxl+OBJnBzy2/JCscGeO507kA==}
|
||||
'@rollup/rollup-linux-s390x-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.39.0':
|
||||
resolution: {integrity: sha512-t9jqYw27R6Lx0XKfEFe5vUeEJ5pF3SGIM6gTfONSMb7DuG6z6wfj2yjcoZxHg129veTqU7+wOhY6GX8wmf90dA==}
|
||||
'@rollup/rollup-linux-x64-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.39.0':
|
||||
resolution: {integrity: sha512-ThFdkrFDP55AIsIZDKSBWEt/JcWlCzydbZHinZ0F/r1h83qbGeenCt/G/wG2O0reuENDD2tawfAj2s8VK7Bugg==}
|
||||
'@rollup/rollup-linux-x64-musl@4.40.0':
|
||||
resolution: {integrity: sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.39.0':
|
||||
resolution: {integrity: sha512-jDrLm6yUtbOg2TYB3sBF3acUnAwsIksEYjLeHL+TJv9jg+TmTwdyjnDex27jqEMakNKf3RwwPahDIt7QXCSqRQ==}
|
||||
'@rollup/rollup-win32-arm64-msvc@4.40.0':
|
||||
resolution: {integrity: sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.39.0':
|
||||
resolution: {integrity: sha512-6w9uMuza+LbLCVoNKL5FSLE7yvYkq9laSd09bwS0tMjkwXrmib/4KmoJcrKhLWHvw19mwU+33ndC69T7weNNjQ==}
|
||||
'@rollup/rollup-win32-ia32-msvc@4.40.0':
|
||||
resolution: {integrity: sha512-+m03kvI2f5syIqHXCZLPVYplP8pQch9JHyXKZ3AGMKlg8dCyr2PKHjwRLiW53LTrN/Nc3EqHOKxUxzoSPdKddA==}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.39.0':
|
||||
resolution: {integrity: sha512-yAkUOkIKZlK5dl7u6dg897doBgLXmUHhIINM2c+sND3DZwnrdQkkSiDh7N75Ll4mM4dxSkYfXqU9fW3lLkMFug==}
|
||||
'@rollup/rollup-win32-x64-msvc@4.40.0':
|
||||
resolution: {integrity: sha512-lpPE1cLfP5oPzVjKMx10pgBmKELQnFJXHgvtHCtuJWOv8MxqdEIMNtgHgBFf7Ea2/7EuVwa9fodWUfXAlXZLZQ==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
@@ -1379,6 +1385,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==}
|
||||
|
||||
@@ -3100,8 +3109,8 @@ packages:
|
||||
deprecated: Rimraf versions prior to v4 are no longer supported
|
||||
hasBin: true
|
||||
|
||||
rollup@4.39.0:
|
||||
resolution: {integrity: sha512-thI8kNc02yNvnmJp8dr3fNWJ9tCONDhp6TV35X6HkKGGs9E6q7YWCHbe5vKiTa7TAiNcFEmXKj3X/pG2b3ci0g==}
|
||||
rollup@4.40.0:
|
||||
resolution: {integrity: sha512-Noe455xmA96nnqH5piFtLobsGbCij7Tu+tb3c1vYjNbTkfzGqXqQXG3wJaYXkRZuQ0vEYN4bhwg7QnIrqB5B+w==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -3473,6 +3482,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 +3501,8 @@ packages:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
vite@5.4.17:
|
||||
resolution: {integrity: sha512-5+VqZryDj4wgCs55o9Lp+p8GE78TLVg0lasCH5xFZ4jacZjtqZa6JUw9/p0WeAojaOfncSM6v77InkFPGnvPvg==}
|
||||
vite@5.4.18:
|
||||
resolution: {integrity: sha512-1oDcnEp3lVyHCuQ2YFelM4Alm2o91xNoMncRm1U7S+JdYfYOvbiGZ3/CxGttrOu2M/KcGz7cRC2DoNUA6urmMA==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -4425,64 +4438,64 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.0.12
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.39.0':
|
||||
'@rollup/rollup-android-arm-eabi@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-android-arm64@4.39.0':
|
||||
'@rollup/rollup-android-arm64@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.39.0':
|
||||
'@rollup/rollup-darwin-arm64@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.39.0':
|
||||
'@rollup/rollup-darwin-x64@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.39.0':
|
||||
'@rollup/rollup-freebsd-arm64@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.39.0':
|
||||
'@rollup/rollup-freebsd-x64@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.39.0':
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.39.0':
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.39.0':
|
||||
'@rollup/rollup-linux-arm64-gnu@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.39.0':
|
||||
'@rollup/rollup-linux-arm64-musl@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.39.0':
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.39.0':
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.39.0':
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.39.0':
|
||||
'@rollup/rollup-linux-riscv64-musl@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.39.0':
|
||||
'@rollup/rollup-linux-s390x-gnu@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.39.0':
|
||||
'@rollup/rollup-linux-x64-gnu@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.39.0':
|
||||
'@rollup/rollup-linux-x64-musl@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.39.0':
|
||||
'@rollup/rollup-win32-arm64-msvc@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.39.0':
|
||||
'@rollup/rollup-win32-ia32-msvc@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.39.0':
|
||||
'@rollup/rollup-win32-x64-msvc@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@shadcn/ui@0.0.4':
|
||||
@@ -4574,12 +4587,12 @@ snapshots:
|
||||
postcss: 8.5.3
|
||||
tailwindcss: 4.1.3
|
||||
|
||||
'@tailwindcss/vite@4.1.3(vite@5.4.17(@types/node@20.17.28)(lightningcss@1.29.2))':
|
||||
'@tailwindcss/vite@4.1.3(vite@5.4.18(@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.17(@types/node@20.17.28)(lightningcss@1.29.2)
|
||||
vite: 5.4.18(@types/node@20.17.28)(lightningcss@1.29.2)
|
||||
|
||||
'@tsconfig/node10@1.0.11': {}
|
||||
|
||||
@@ -4708,6 +4721,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 +4817,14 @@ snapshots:
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
|
||||
'@vitejs/plugin-react@4.3.4(vite@5.4.17(@types/node@20.17.28)(lightningcss@1.29.2))':
|
||||
'@vitejs/plugin-react@4.3.4(vite@5.4.18(@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.17(@types/node@20.17.28)(lightningcss@1.29.2)
|
||||
vite: 5.4.18(@types/node@20.17.28)(lightningcss@1.29.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -6701,30 +6716,30 @@ snapshots:
|
||||
dependencies:
|
||||
glob: 7.2.3
|
||||
|
||||
rollup@4.39.0:
|
||||
rollup@4.40.0:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.7
|
||||
optionalDependencies:
|
||||
'@rollup/rollup-android-arm-eabi': 4.39.0
|
||||
'@rollup/rollup-android-arm64': 4.39.0
|
||||
'@rollup/rollup-darwin-arm64': 4.39.0
|
||||
'@rollup/rollup-darwin-x64': 4.39.0
|
||||
'@rollup/rollup-freebsd-arm64': 4.39.0
|
||||
'@rollup/rollup-freebsd-x64': 4.39.0
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.39.0
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.39.0
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.39.0
|
||||
'@rollup/rollup-linux-arm64-musl': 4.39.0
|
||||
'@rollup/rollup-linux-loongarch64-gnu': 4.39.0
|
||||
'@rollup/rollup-linux-powerpc64le-gnu': 4.39.0
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.39.0
|
||||
'@rollup/rollup-linux-riscv64-musl': 4.39.0
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.39.0
|
||||
'@rollup/rollup-linux-x64-gnu': 4.39.0
|
||||
'@rollup/rollup-linux-x64-musl': 4.39.0
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.39.0
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.39.0
|
||||
'@rollup/rollup-win32-x64-msvc': 4.39.0
|
||||
'@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
|
||||
fsevents: 2.3.3
|
||||
|
||||
router@2.2.0:
|
||||
@@ -7120,6 +7135,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 +7149,11 @@ snapshots:
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
vite@5.4.17(@types/node@20.17.28)(lightningcss@1.29.2):
|
||||
vite@5.4.18(@types/node@20.17.28)(lightningcss@1.29.2):
|
||||
dependencies:
|
||||
esbuild: 0.21.5
|
||||
postcss: 8.5.3
|
||||
rollup: 4.39.0
|
||||
rollup: 4.40.0
|
||||
optionalDependencies:
|
||||
'@types/node': 20.17.28
|
||||
fsevents: 2.3.3
|
||||
|
||||
341
src/controllers/groupController.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
import {
|
||||
getAllGroups,
|
||||
getGroupById,
|
||||
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 = getGroupById(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 = getGroupById(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',
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
addServer,
|
||||
removeServer,
|
||||
updateMcpServer,
|
||||
recreateMcpServer,
|
||||
notifyToolChanged,
|
||||
toggleServerStatus,
|
||||
} from '../services/mcpService.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
|
||||
@@ -70,7 +71,7 @@ export const createServer = async (req: Request, res: Response): Promise<void> =
|
||||
|
||||
const result = await addServer(name, config);
|
||||
if (result.success) {
|
||||
recreateMcpServer();
|
||||
notifyToolChanged();
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Server added successfully',
|
||||
@@ -92,7 +93,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,
|
||||
@@ -102,9 +102,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',
|
||||
@@ -127,7 +126,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,
|
||||
@@ -154,7 +152,7 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
|
||||
|
||||
const result = await updateMcpServer(name, config);
|
||||
if (result.success) {
|
||||
recreateMcpServer();
|
||||
notifyToolChanged();
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Server updated successfully',
|
||||
@@ -177,7 +175,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,
|
||||
@@ -188,7 +185,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: {
|
||||
@@ -207,3 +203,44 @@ export const getServerConfig = (req: Request, res: Response): void => {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
message: 'Server name is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof enabled !== 'boolean') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Enabled status must be a boolean',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await toggleServerStatus(name, enabled);
|
||||
if (result.success) {
|
||||
notifyToolChanged();
|
||||
res.json({
|
||||
success: true,
|
||||
message: result.message || `Server ${enabled ? 'enabled' : 'disabled'} successfully`,
|
||||
});
|
||||
} else {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: result.message || 'Server not found or failed to toggle status',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal server error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
import express from 'express';
|
||||
import { check } from 'express-validator';
|
||||
import path from 'path';
|
||||
import {
|
||||
getAllServers,
|
||||
getAllSettings,
|
||||
createServer,
|
||||
updateServer,
|
||||
deleteServer,
|
||||
toggleServer,
|
||||
} from '../controllers/serverController.js';
|
||||
import {
|
||||
getGroups,
|
||||
getGroup,
|
||||
createNewGroup,
|
||||
updateExistingGroup,
|
||||
deleteExistingGroup,
|
||||
addServerToExistingGroup,
|
||||
removeServerFromExistingGroup,
|
||||
getGroupServers,
|
||||
updateGroupServersBatch
|
||||
} from '../controllers/groupController.js';
|
||||
import {
|
||||
login,
|
||||
register,
|
||||
@@ -24,6 +37,19 @@ export const initRoutes = (app: express.Application): void => {
|
||||
router.post('/servers', createServer);
|
||||
router.put('/servers/:name', updateServer);
|
||||
router.delete('/servers/:name', deleteServer);
|
||||
router.post('/servers/:name/toggle', toggleServer);
|
||||
|
||||
// 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);
|
||||
|
||||
// Auth routes (these will NOT be protected by auth middleware)
|
||||
app.post('/auth/login', [
|
||||
@@ -46,6 +72,10 @@ export const initRoutes = (app: express.Application): void => {
|
||||
], changePassword);
|
||||
|
||||
app.use('/api', router);
|
||||
|
||||
app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(process.cwd(), 'frontend', 'dist', 'index.html'));
|
||||
});
|
||||
};
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import express from 'express';
|
||||
import config from './config/index.js';
|
||||
import { initMcpServer, registerAllTools } from './services/mcpService.js';
|
||||
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';
|
||||
@@ -20,17 +20,24 @@ export class AppServer {
|
||||
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/:groupId?', (req, res) => handleSseConnection(req, res));
|
||||
this.app.post('/messages', handleSseMessage);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error initializing MCP server:', error);
|
||||
throw error;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error initializing server:', error);
|
||||
throw error;
|
||||
|
||||
223
src/services/groupService.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
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
|
||||
export const getGroupById = (id: string): IGroup | undefined => {
|
||||
const groups = getAllGroups();
|
||||
return groups.find((group) => group.id === id);
|
||||
};
|
||||
|
||||
// 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 = getGroupById(groupId);
|
||||
return group ? group.servers : [];
|
||||
};
|
||||
@@ -1,37 +1,40 @@
|
||||
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 { ServerInfo, ServerConfig } from '../types/index.js';
|
||||
import { loadSettings, saveSettings, expandEnvVars } from '../config/index.js';
|
||||
import config from '../config/index.js';
|
||||
import { get } from 'http';
|
||||
import { getGroupId } from './sseService.js';
|
||||
import { getServersInGroup } from './groupService.js';
|
||||
|
||||
let mcpServer: McpServer;
|
||||
let currentServer: 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> => {
|
||||
currentServer = createMcpServer(name, version);
|
||||
await registerAllTools(currentServer, true);
|
||||
};
|
||||
|
||||
export const setMcpServer = (server: McpServer): void => {
|
||||
mcpServer = server;
|
||||
export const setMcpServer = (server: Server): void => {
|
||||
currentServer = server;
|
||||
};
|
||||
|
||||
export const getMcpServer = (): McpServer => {
|
||||
return mcpServer;
|
||||
export const getMcpServer = (): Server => {
|
||||
return currentServer;
|
||||
};
|
||||
|
||||
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(currentServer, true);
|
||||
currentServer
|
||||
.sendToolListChanged()
|
||||
.catch((error) => {
|
||||
console.error('Failed to send tool list changed notification:', error);
|
||||
})
|
||||
.then(() => {
|
||||
console.log('Tool list changed notification sent successfully');
|
||||
});
|
||||
};
|
||||
|
||||
// Store all server information
|
||||
@@ -44,12 +47,28 @@ export const initializeClientsFromSettings = (): ServerInfo[] => {
|
||||
serverInfos = [];
|
||||
|
||||
for (const [name, conf] of Object.entries(settings.mcpServers)) {
|
||||
// Skip disabled servers
|
||||
if (conf.enabled === false) {
|
||||
console.log(`Skipping disabled server: ${name}`);
|
||||
serverInfos.push({
|
||||
name,
|
||||
status: 'disconnected',
|
||||
tools: [],
|
||||
createTime: Date.now(),
|
||||
enabled: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if server is already connected
|
||||
const existingServer = existingServerInfos.find(
|
||||
(s) => s.name === name && s.status === 'connected',
|
||||
);
|
||||
if (existingServer) {
|
||||
serverInfos.push(existingServer);
|
||||
serverInfos.push({
|
||||
...existingServer,
|
||||
enabled: conf.enabled === undefined ? true : conf.enabled,
|
||||
});
|
||||
console.log(`Server '${name}' is already connected.`);
|
||||
continue;
|
||||
}
|
||||
@@ -91,7 +110,7 @@ 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);
|
||||
const serverInfo = getServerByName(name);
|
||||
if (serverInfo) {
|
||||
serverInfo.status = 'disconnected';
|
||||
}
|
||||
@@ -111,7 +130,7 @@ export const initializeClientsFromSettings = (): ServerInfo[] => {
|
||||
};
|
||||
|
||||
// Register all MCP tools
|
||||
export const registerAllTools = async (server: McpServer, forceInit: boolean): Promise<void> => {
|
||||
export const registerAllTools = async (server: Server, forceInit: boolean): Promise<void> => {
|
||||
initializeClientsFromSettings();
|
||||
for (const serverInfo of serverInfos) {
|
||||
if (serverInfo.status === 'connected' && !forceInit) continue;
|
||||
@@ -120,35 +139,15 @@ export const registerAllTools = async (server: McpServer, forceInit: boolean): P
|
||||
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 || {},
|
||||
inputSchema: tool.inputSchema || {},
|
||||
}));
|
||||
|
||||
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}`,
|
||||
@@ -160,19 +159,35 @@ export const registerAllTools = async (server: McpServer, forceInit: boolean): P
|
||||
|
||||
// Get all server information
|
||||
export const getServersInfo = (): Omit<ServerInfo, 'client' | 'transport'>[] => {
|
||||
return serverInfos.map(({ name, status, tools, createTime }) => ({
|
||||
name,
|
||||
status,
|
||||
tools,
|
||||
createTime,
|
||||
}));
|
||||
const settings = loadSettings();
|
||||
const infos = serverInfos.map(({ name, status, tools, createTime }) => {
|
||||
const serverConfig = settings.mcpServers[name];
|
||||
const enabled = serverConfig ? serverConfig.enabled !== false : true;
|
||||
return {
|
||||
name,
|
||||
status,
|
||||
tools,
|
||||
createTime,
|
||||
enabled,
|
||||
};
|
||||
});
|
||||
infos.sort((a, b) => {
|
||||
if (a.enabled === b.enabled) return 0;
|
||||
return a.enabled ? -1 : 1;
|
||||
});
|
||||
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,
|
||||
@@ -189,7 +204,7 @@ export const addServer = async (
|
||||
return { success: false, message: 'Failed to save settings' };
|
||||
}
|
||||
|
||||
registerAllTools(mcpServer, false);
|
||||
registerAllTools(currentServer, false);
|
||||
return { success: true, message: 'Server added successfully' };
|
||||
} catch (error) {
|
||||
console.error(`Failed to add server: ${name}`, error);
|
||||
@@ -201,7 +216,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' };
|
||||
}
|
||||
@@ -236,13 +250,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' };
|
||||
@@ -252,93 +260,104 @@ export const updateMcpServer = async (
|
||||
}
|
||||
};
|
||||
|
||||
// Create McpServer instance
|
||||
export const createMcpServer = (name: string, version: string): McpServer => {
|
||||
return new McpServer({ name, version });
|
||||
// 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,
|
||||
): Promise<{ success: boolean; message?: string }> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.mcpServers[name]) {
|
||||
return { success: false, message: 'Server not found' };
|
||||
}
|
||||
|
||||
// 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) {
|
||||
closeServer(name);
|
||||
|
||||
// Update the server info to show as disconnected and disabled
|
||||
const index = serverInfos.findIndex((s) => s.name === name);
|
||||
if (index !== -1) {
|
||||
serverInfos[index] = {
|
||||
...serverInfos[index],
|
||||
status: 'disconnected',
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, message: `Server ${enabled ? 'enabled' : 'disabled'} successfully` };
|
||||
} catch (error) {
|
||||
console.error(`Failed to toggle server status: ${name}`, error);
|
||||
return { success: false, message: 'Failed to toggle server status' };
|
||||
}
|
||||
};
|
||||
|
||||
// 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');
|
||||
}
|
||||
// Create McpServer instance
|
||||
export const createMcpServer = (name: string, version: string): Server => {
|
||||
const server = new Server({ name, version }, { capabilities: { tools: {} } });
|
||||
server.setRequestHandler(ListToolsRequestSchema, async (_, extra) => {
|
||||
const sessionId = extra.sessionId || '';
|
||||
const groupId = getGroupId(sessionId);
|
||||
console.log(`Handling ListToolsRequest for groupId: ${groupId}`);
|
||||
const allServerInfos = serverInfos.filter((serverInfo) => {
|
||||
if (serverInfo.enabled === false) return false;
|
||||
if (!groupId) return true;
|
||||
const serversInGroup = getServersInGroup(groupId);
|
||||
return serversInGroup.includes(serverInfo.name);
|
||||
});
|
||||
|
||||
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();
|
||||
const allTools = [];
|
||||
for (const serverInfo of allServerInfos) {
|
||||
if (serverInfo.tools && serverInfo.tools.length > 0) {
|
||||
allTools.push(...serverInfo.tools);
|
||||
}
|
||||
}
|
||||
|
||||
if (prop.description) {
|
||||
zodType = zodType.describe(prop.description);
|
||||
}
|
||||
return {
|
||||
tools: allTools,
|
||||
};
|
||||
});
|
||||
|
||||
if (prop.default !== undefined) {
|
||||
zodType = zodType.default(prop.default);
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request, _) => {
|
||||
console.log(`Handling CallToolRequest for tool: ${request.params.name}`);
|
||||
try {
|
||||
if (!request.params.arguments) {
|
||||
throw new Error('Arguments are required');
|
||||
}
|
||||
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 { error: `Failed to call tool: ${error}` };
|
||||
}
|
||||
|
||||
required = Array.isArray(required) ? required : [];
|
||||
if (Array.isArray(required) && required.includes(key)) {
|
||||
processedSchema[key] = zodType;
|
||||
} else {
|
||||
processedSchema[key] = zodType.optional();
|
||||
}
|
||||
}
|
||||
|
||||
return processedSchema;
|
||||
}
|
||||
});
|
||||
return server;
|
||||
};
|
||||
|
||||
@@ -2,11 +2,16 @@ import { Request, Response } from 'express';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { getMcpServer } from './mcpService.js';
|
||||
|
||||
const transports: { [sessionId: string]: SSEServerTransport } = {};
|
||||
const transports: { [sessionId: string]: { transport: SSEServerTransport; groupId: string } } = {};
|
||||
|
||||
export const getGroupId = (sessionId: string): string => {
|
||||
return transports[sessionId]?.groupId || '';
|
||||
};
|
||||
|
||||
export const handleSseConnection = async (req: Request, res: Response): Promise<void> => {
|
||||
const transport = new SSEServerTransport('/messages', res);
|
||||
transports[transport.sessionId] = transport;
|
||||
const groupId = req.params.groupId;
|
||||
transports[transport.sessionId] = { transport, groupId };
|
||||
|
||||
res.on('close', () => {
|
||||
delete transports[transport.sessionId];
|
||||
@@ -19,8 +24,10 @@ export const handleSseConnection = async (req: Request, res: Response): Promise<
|
||||
|
||||
export const handleSseMessage = async (req: Request, res: Response): Promise<void> => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const transport = transports[sessionId];
|
||||
|
||||
const { transport, groupId } = transports[sessionId];
|
||||
req.params.groupId = groupId;
|
||||
req.query.groupId = groupId;
|
||||
console.log(`Received message for sessionId: ${sessionId} in groupId: ${groupId}`);
|
||||
if (transport) {
|
||||
await transport.handlePostMessage(req, res);
|
||||
} else {
|
||||
|
||||
@@ -9,12 +9,21 @@ 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Configuration details for an individual server
|
||||
@@ -23,6 +32,7 @@ export interface ServerConfig {
|
||||
command?: string; // Command to execute for stdio-based servers
|
||||
args?: string[]; // Arguments for the command
|
||||
env?: Record<string, string>; // Environment variables
|
||||
enabled?: boolean; // Flag to enable/disable the server
|
||||
}
|
||||
|
||||
// Information about a server's status and tools
|
||||
@@ -33,6 +43,7 @@ export interface ServerInfo {
|
||||
client?: Client; // Client instance for communication
|
||||
transport?: SSEClientTransport | StdioClientTransport; // Transport mechanism used
|
||||
createTime: number; // Timestamp of when the server was created
|
||||
enabled?: boolean; // Flag to indicate if the server is enabled
|
||||
}
|
||||
|
||||
// Details about a tool available on the server
|
||||
|
||||