Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afd1ee7a50 | ||
|
|
6bf22025e1 | ||
|
|
2c00f1d606 | ||
|
|
3d49c652ad | ||
|
|
c4008f617d | ||
|
|
6a5039a90e | ||
|
|
39222bdcd8 | ||
|
|
436318b24c |
16
.github/workflows/build.yml
vendored
@@ -31,17 +31,11 @@ jobs:
|
||||
with:
|
||||
images: samanhappy/mcphub
|
||||
tags: |
|
||||
# edge 变体
|
||||
type=raw,value=edge,enable=${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
|
||||
type=raw,value=edge-full,enable=${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && matrix.variant == 'full' }}
|
||||
|
||||
# semver
|
||||
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/') && matrix.variant == 'base' }}
|
||||
type=semver,pattern={{version}}-full,enable=${{ startsWith(github.ref, 'refs/tags/') && matrix.variant == 'full' }}
|
||||
|
||||
# latest
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') && matrix.variant == 'base' }}
|
||||
type=raw,value=latest-full,enable=${{ startsWith(github.ref, 'refs/tags/') && matrix.variant == 'full' }}
|
||||
type=raw,value=edge${{ matrix.variant == 'full' && '-full' || '' }},enable=${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
|
||||
type=semver,pattern={{version}}${{ matrix.variant == 'full' && '-full' || '' }},enable=${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
type=raw,value=latest${{ matrix.variant == 'full' && '-full' || '' }},enable=${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
uses: docker/build-push-action@v5
|
||||
|
||||
@@ -2,7 +2,7 @@ FROM python:3.13-slim-bookworm AS base
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
|
||||
RUN apt-get update && apt-get install -y curl gnupg \
|
||||
RUN apt-get update && apt-get install -y curl gnupg git \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
@@ -37,6 +37,9 @@ RUN pnpm install
|
||||
|
||||
COPY . .
|
||||
|
||||
# Download the latest servers.json from mcpm.sh and replace the existing file
|
||||
RUN curl -s -f --connect-timeout 10 https://mcpm.sh/api/servers.json -o servers.json || echo "Failed to download servers.json, using bundled version"
|
||||
|
||||
RUN pnpm frontend:build && pnpm build
|
||||
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
|
||||
@@ -25,7 +25,7 @@ Create a `mcp_settings.json` file to customize your server settings:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"amap-maps": {
|
||||
"amap": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@amap/amap-maps-mcp-server"],
|
||||
"env": {
|
||||
|
||||
@@ -25,7 +25,7 @@ MCPHub 是一个统一的 MCP(Model Context Protocol,模型上下文协议
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"amap-maps": {
|
||||
"amap": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@amap/amap-maps-mcp-server"],
|
||||
"env": {
|
||||
|
||||
BIN
assets/cursor-mcp.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
assets/cursor-query.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
assets/cursor-tools.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
BIN
assets/group.png
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
BIN
assets/market.zh.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
assets/wegroup.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
232
doc/intro2.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# 本地部署、一键安装、分组路由:MCPHub 重塑 MCP 服务器体验
|
||||
|
||||
## 概述
|
||||
|
||||
现代 AI 应用场景中,将大模型(LLM)与各种数据源和工具无缝对接,往往需要手动编写大量胶水代码,并且无法快速复用。MCP(Model Context Protocol)协议由 Anthropic 在 2024 年开源,旨在提供类似“USB‑C”接口般的标准化通信方式,简化 AI 助手与内容仓库、业务系统等的集成流程。然而,MCP 服务器部署常常需要大量环境依赖、手动配置及持续运行,开发者常因安装和配置耗费大量时间和精力。MCPHub 作为一款开源的一站式聚合平台,通过直观的 Web UI、Docker 镜像和热插拔配置,实现本地或容器里的“一键安装”与“分组路由”,大幅降低 MCP 服务器的使用门槛和运维成本。
|
||||
|
||||
## MCPHub 是什么
|
||||
|
||||
### MCP 协议简介
|
||||
|
||||
Model Context Protocol(MCP)是一种开放标准,类似“USB‑C”接口,为 AI 助手与内容仓库、业务系统和第三方服务之间提供统一通信协议。它支持 stdio 与 SSE(最新协议中被 Streamable HTTP 取代)两种通信方式,既能满足实时流式数据交换,也可用于批量任务。2024 年由 Anthropic 团队开源发布后,MCP 已在各类 AI 客户端(如 Claude Desktop)中得到应用,成功实现与 GitHub、Slack、网页自动化工具等的无缝对接。
|
||||
|
||||
### MCPHub 项目概览
|
||||
|
||||
MCPHub 是一个统一的 MCP 服务器聚合平台,内置 MCP 服务器市场实现一键安装。前端基于 React、Vite 和 Tailwind CSS 构建,后端兼容任意使用 npx 或 uvx 命令启动的 MCP 服务器。它通过一个集中式 Dashboard 实时展示各服务器的运行状态,并支持在运行时热插拔增删改服务器配置,无需停机维护。支持分组式访问控制,可以通过独立的 SSE 端点访问不同的 MCP 服务器组合,管理员可灵活定义不同团队或环境的权限策略。官方提供 Docker 镜像,仅需一条命令即可快速启动本地或云端服务。
|
||||
|
||||

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

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

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

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

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

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

|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { ToastProvider } from './contexts/ToastContext';
|
||||
import MainLayout from './layouts/MainLayout';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
@@ -8,29 +9,34 @@ import DashboardPage from './pages/Dashboard';
|
||||
import ServersPage from './pages/ServersPage';
|
||||
import GroupsPage from './pages/GroupsPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import MarketPage from './pages/MarketPage';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
{/* 公共路由 */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* 受保护的路由,使用 MainLayout 作为布局容器 */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/servers" element={<ServersPage />} />
|
||||
<Route path="/groups" element={<GroupsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<ToastProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
{/* 公共路由 */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* 受保护的路由,使用 MainLayout 作为布局容器 */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/servers" element={<ServersPage />} />
|
||||
<Route path="/groups" element={<GroupsPage />} />
|
||||
<Route path="/market" element={<MarketPage />} />
|
||||
<Route path="/market/:serverName" element={<MarketPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* 未匹配的路由重定向到首页 */}
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
|
||||
{/* 未匹配的路由重定向到首页 */}
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { Group, Server } from '@/types'
|
||||
import { Edit, Trash, Copy, Check } from '@/components/icons/LucideIcons'
|
||||
import DeleteDialog from '@/components/ui/DeleteDialog'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
|
||||
interface GroupCardProps {
|
||||
group: Group
|
||||
@@ -18,6 +19,7 @@ const GroupCard = ({
|
||||
onDelete
|
||||
}: GroupCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { showToast } = useToast()
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
@@ -55,7 +57,7 @@ const GroupCard = ({
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (err) {
|
||||
alert(t('common.copyFailed') || 'Copy failed')
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error')
|
||||
console.error('Copy to clipboard failed:', err)
|
||||
}
|
||||
document.body.removeChild(textArea)
|
||||
|
||||
153
frontend/src/components/MarketServerCard.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MarketServer } from '@/types';
|
||||
|
||||
interface MarketServerCardProps {
|
||||
server: MarketServer;
|
||||
onClick: (server: MarketServer) => void;
|
||||
}
|
||||
|
||||
const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Intelligently calculate how many tags to display to ensure they fit in a single line
|
||||
const getTagsToDisplay = () => {
|
||||
if (!server.tags || server.tags.length === 0) {
|
||||
return { tagsToShow: [], hasMore: false, moreCount: 0 };
|
||||
}
|
||||
|
||||
// Estimate available width in the card (in characters)
|
||||
const estimatedAvailableWidth = 28; // Estimated number of characters that can fit in one line
|
||||
|
||||
// Calculate the character space needed for tags and plus sign (including # and spacing)
|
||||
const calculateTagWidth = (tag: string) => tag.length + 3; // +3 for # and spacing
|
||||
|
||||
// Loop to determine the maximum number of tags that can be displayed
|
||||
let totalWidth = 0;
|
||||
let i = 0;
|
||||
|
||||
// First, sort tags by length to prioritize displaying shorter tags
|
||||
const sortedTags = [...server.tags].sort((a, b) => a.length - b.length);
|
||||
|
||||
// Calculate how many tags can fit
|
||||
for (i = 0; i < sortedTags.length; i++) {
|
||||
const tagWidth = calculateTagWidth(sortedTags[i]);
|
||||
|
||||
// If this tag would make the total width exceed available width, stop adding
|
||||
if (totalWidth + tagWidth > estimatedAvailableWidth) {
|
||||
break;
|
||||
}
|
||||
|
||||
totalWidth += tagWidth;
|
||||
|
||||
// If this is the last tag but there's still space, no need to show "more"
|
||||
if (i === sortedTags.length - 1) {
|
||||
return {
|
||||
tagsToShow: sortedTags,
|
||||
hasMore: false,
|
||||
moreCount: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// If there's not enough space to display any tags, show at least one
|
||||
if (i === 0 && sortedTags.length > 0) {
|
||||
i = 1;
|
||||
}
|
||||
|
||||
// Calculate space needed for the "more" tag
|
||||
const moreCount = sortedTags.length - i;
|
||||
const moreTagWidth = 3 + String(moreCount).length + t('market.moreTags').length;
|
||||
|
||||
// If there's enough remaining space to display the "more" tag
|
||||
if (totalWidth + moreTagWidth <= estimatedAvailableWidth || i < 1) {
|
||||
return {
|
||||
tagsToShow: sortedTags.slice(0, i),
|
||||
hasMore: true,
|
||||
moreCount
|
||||
};
|
||||
}
|
||||
|
||||
// If there's not enough space for even the "more" tag, reduce one tag to make room
|
||||
return {
|
||||
tagsToShow: sortedTags.slice(0, Math.max(1, i - 1)),
|
||||
hasMore: true,
|
||||
moreCount: moreCount + 1
|
||||
};
|
||||
};
|
||||
|
||||
const { tagsToShow, hasMore, moreCount } = getTagsToDisplay();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-md p-5 hover:shadow-lg transition-shadow cursor-pointer flex flex-col h-full"
|
||||
onClick={() => onClick(server)}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900 line-clamp-1 mr-2">{server.display_name}</h3>
|
||||
{server.is_official && (
|
||||
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded flex-shrink-0">
|
||||
{t('market.official')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm mb-4 line-clamp-2 min-h-[40px]">{server.description}</p>
|
||||
|
||||
{/* Categories */}
|
||||
<div className="flex flex-wrap gap-1 mb-2 min-h-[28px]">
|
||||
{server.categories?.length > 0 ? (
|
||||
server.categories.map((category, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="bg-gray-100 text-gray-800 text-xs px-2 py-1 rounded whitespace-nowrap"
|
||||
>
|
||||
{category}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 py-1">-</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="relative mb-3 min-h-[28px] overflow-x-auto">
|
||||
{server.tags?.length > 0 ? (
|
||||
<div className="flex gap-1 items-center whitespace-nowrap">
|
||||
{tagsToShow.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="bg-green-50 text-green-700 text-xs px-2 py-1 rounded flex-shrink-0"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
{hasMore && (
|
||||
<span className="bg-gray-100 text-gray-600 text-xs px-1.5 py-1 rounded flex-shrink-0">
|
||||
+{moreCount} {t('market.moreTags')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 py-1">-</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mt-auto pt-2 text-xs text-gray-500 border-t border-gray-100">
|
||||
<div className="overflow-hidden">
|
||||
<span className="whitespace-nowrap">{t('market.by')} </span>
|
||||
<span className="font-medium whitespace-nowrap overflow-hidden text-ellipsis max-w-[120px] inline-block align-bottom">
|
||||
{server.author?.name || t('market.unknown')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center flex-shrink-0">
|
||||
<svg className="h-4 w-4 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>{server.tools?.length || 0} {t('market.tools')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketServerCard;
|
||||
297
frontend/src/components/MarketServerDetail.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MarketServer, MarketServerInstallation } from '@/types';
|
||||
import ServerForm from './ServerForm';
|
||||
|
||||
interface MarketServerDetailProps {
|
||||
server: MarketServer;
|
||||
onBack: () => void;
|
||||
onInstall: (server: MarketServer) => void;
|
||||
installing?: boolean;
|
||||
isInstalled?: boolean;
|
||||
}
|
||||
|
||||
const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
server,
|
||||
onBack,
|
||||
onInstall,
|
||||
installing = false,
|
||||
isInstalled = false
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Helper function to determine button state
|
||||
const getButtonProps = () => {
|
||||
if (isInstalled) {
|
||||
return {
|
||||
className: "bg-green-600 cursor-default px-4 py-2 rounded text-sm font-medium text-white",
|
||||
disabled: true,
|
||||
text: t('market.installed')
|
||||
};
|
||||
} else if (installing) {
|
||||
return {
|
||||
className: "bg-gray-400 cursor-not-allowed px-4 py-2 rounded text-sm font-medium text-white",
|
||||
disabled: true,
|
||||
text: t('market.installing')
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
className: "bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white",
|
||||
disabled: false,
|
||||
text: t('market.install')
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const toggleModal = () => {
|
||||
setModalVisible(!modalVisible);
|
||||
setError(null); // Clear any previous errors when toggling modal
|
||||
};
|
||||
|
||||
const handleInstall = () => {
|
||||
if (!isInstalled) {
|
||||
toggleModal();
|
||||
}
|
||||
};
|
||||
|
||||
// Get the preferred installation configuration based on priority:
|
||||
// npm > uvx > default
|
||||
const getPreferredInstallation = (): MarketServerInstallation | undefined => {
|
||||
if (!server.installations) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (server.installations.npm) {
|
||||
return server.installations.npm;
|
||||
} else if (server.installations.uvx) {
|
||||
return server.installations.uvx;
|
||||
} else if (server.installations.default) {
|
||||
return server.installations.default;
|
||||
}
|
||||
|
||||
// If none of the preferred types are available, get the first available installation type
|
||||
const installTypes = Object.keys(server.installations);
|
||||
if (installTypes.length > 0) {
|
||||
return server.installations[installTypes[0]];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const handleSubmit = async (payload: any) => {
|
||||
try {
|
||||
setError(null);
|
||||
// Pass the server object to the parent component for installation
|
||||
onInstall(server);
|
||||
setModalVisible(false);
|
||||
} catch (err) {
|
||||
console.error('Error installing server:', err);
|
||||
setError(t('errors.serverInstall'));
|
||||
}
|
||||
};
|
||||
|
||||
const buttonProps = getButtonProps();
|
||||
const preferredInstallation = getPreferredInstallation();
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-gray-600 hover:text-gray-900 flex items-center"
|
||||
>
|
||||
<svg className="h-5 w-5 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{t('market.backToList')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 flex items-center flex-wrap">
|
||||
{server.display_name}
|
||||
<span className="text-sm font-normal text-gray-500 ml-2">({server.name})</span>
|
||||
<span className="text-sm font-normal text-gray-600 ml-4">
|
||||
{t('market.author')}: {server.author.name} • {t('market.license')}: {server.license} •
|
||||
<a
|
||||
href={server.repository.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline ml-1"
|
||||
>
|
||||
{t('market.repository')}
|
||||
</a>
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
{server.is_official && (
|
||||
<span className="bg-blue-100 text-blue-800 text-sm font-medium px-4 py-2 rounded mr-2 flex items-center">
|
||||
{t('market.official')}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
disabled={buttonProps.disabled}
|
||||
className={buttonProps.className}
|
||||
>
|
||||
{buttonProps.text}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-700 mb-6">{server.description}</p>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold mb-3">{t('market.categories')} & {t('market.tags')}</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{server.categories?.map((category, index) => (
|
||||
<span key={`cat-${index}`} className="bg-gray-100 text-gray-800 px-3 py-1 rounded">
|
||||
{category}
|
||||
</span>
|
||||
))}
|
||||
{server.tags && server.tags.map((tag, index) => (
|
||||
<span key={`tag-${index}`} className="bg-gray-100 text-green-700 px-2 py-1 rounded text-sm">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{server.arguments && Object.keys(server.arguments).length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold mb-3">{t('market.arguments')}</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
|
||||
{t('market.argumentName')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
|
||||
{t('market.description')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
|
||||
{t('market.required')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
|
||||
{t('market.example')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{Object.entries(server.arguments).map(([name, arg], index) => (
|
||||
<tr key={index}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{name}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
{arg.description}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{arg.required ? (
|
||||
<span className="text-green-600">✓</span>
|
||||
) : (
|
||||
<span className="text-red-600">✗</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<code className="bg-gray-100 px-2 py-1 rounded">{arg.example}</code>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold mb-3">{t('market.tools')}</h3>
|
||||
<div className="space-y-4">
|
||||
{server.tools?.map((tool, index) => (
|
||||
<div key={index} className="border border-gray-200 rounded p-4">
|
||||
<h4 className="font-medium mb-2">
|
||||
{tool.name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Toggle visibility of schema (simplified for this implementation)
|
||||
const element = document.getElementById(`schema-${index}`);
|
||||
if (element) {
|
||||
element.classList.toggle('hidden');
|
||||
}
|
||||
}}
|
||||
className="text-sm text-blue-600 hover:underline focus:outline-none ml-2"
|
||||
>
|
||||
{t('market.viewSchema')}
|
||||
</button>
|
||||
</h4>
|
||||
<p className="text-gray-600 mb-2">{tool.description}</p>
|
||||
<div className="mt-2">
|
||||
<pre id={`schema-${index}`} className="hidden bg-gray-50 p-3 rounded text-sm overflow-auto mt-2">
|
||||
{JSON.stringify(tool.inputSchema, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{server.examples && server.examples.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold mb-3">{t('market.examples')}</h3>
|
||||
<div className="space-y-4">
|
||||
{server.examples.map((example, index) => (
|
||||
<div key={index} className="border border-gray-200 rounded p-4">
|
||||
<h4 className="font-medium mb-2">{example.title}</h4>
|
||||
<p className="text-gray-600 mb-2">{example.description}</p>
|
||||
<pre className="bg-gray-50 p-3 rounded text-sm overflow-auto">
|
||||
{example.prompt}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
disabled={buttonProps.disabled}
|
||||
className={buttonProps.className}
|
||||
>
|
||||
{buttonProps.text}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{modalVisible && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<ServerForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={toggleModal}
|
||||
modalTitle={t('market.installServer', { name: server.display_name })}
|
||||
formError={error}
|
||||
initialData={{
|
||||
name: server.name,
|
||||
status: 'disconnected',
|
||||
config: preferredInstallation
|
||||
? {
|
||||
command: preferredInstallation.command || '',
|
||||
args: preferredInstallation.args || [],
|
||||
env: preferredInstallation.env || {}
|
||||
}
|
||||
: undefined
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketServerDetail;
|
||||
@@ -16,7 +16,7 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
// 菜单项配置
|
||||
// Menu item configuration
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
path: '/',
|
||||
@@ -46,6 +46,15 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/market',
|
||||
label: t('nav.market'),
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M3 1a1 1 0 000 2h1.22l.305 1.222a.997.997 0 00.01.042l1.358 5.43-.893.892C3.74 11.846 4.632 14 6.414 14H15a1 1 0 000-2H6.414l1-1H14a1 1 0 00.894-.553l3-6A1 1 0 0017 3H6.28l-.31-1.243A1 1 0 005 1H3zM16 16.5a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0zM6.5 18a1.5 1.5 0 100-3 1.5 1.5 0 000 3z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
label: t('nav.settings'),
|
||||
|
||||
128
frontend/src/components/ui/Pagination.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React from 'react';
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
const Pagination: React.FC<PaginationProps> = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange
|
||||
}) => {
|
||||
// Generate page buttons
|
||||
const getPageButtons = () => {
|
||||
const buttons = [];
|
||||
const maxDisplayedPages = 5; // Maximum number of page buttons to display
|
||||
|
||||
// Always display first page
|
||||
buttons.push(
|
||||
<button
|
||||
key="first"
|
||||
onClick={() => onPageChange(1)}
|
||||
className={`px-3 py-1 mx-1 rounded ${
|
||||
currentPage === 1
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
1
|
||||
</button>
|
||||
);
|
||||
|
||||
// Start range
|
||||
let startPage = Math.max(2, currentPage - Math.floor(maxDisplayedPages / 2));
|
||||
|
||||
// If we're showing ellipsis after first page
|
||||
if (startPage > 2) {
|
||||
buttons.push(
|
||||
<span key="ellipsis1" className="px-3 py-1">
|
||||
...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Middle pages
|
||||
for (let i = startPage; i <= Math.min(totalPages - 1, startPage + maxDisplayedPages - 3); i++) {
|
||||
buttons.push(
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onPageChange(i)}
|
||||
className={`px-3 py-1 mx-1 rounded ${
|
||||
currentPage === i
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{i}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// If we're showing ellipsis before last page
|
||||
if (startPage + maxDisplayedPages - 3 < totalPages - 1) {
|
||||
buttons.push(
|
||||
<span key="ellipsis2" className="px-3 py-1">
|
||||
...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Always display last page if there's more than one page
|
||||
if (totalPages > 1) {
|
||||
buttons.push(
|
||||
<button
|
||||
key="last"
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
className={`px-3 py-1 mx-1 rounded ${
|
||||
currentPage === totalPages
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{totalPages}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return buttons;
|
||||
};
|
||||
|
||||
// If there's only one page, don't render pagination
|
||||
if (totalPages <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center my-6">
|
||||
<button
|
||||
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className={`px-3 py-1 rounded mr-2 ${
|
||||
currentPage === 1
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
« Prev
|
||||
</button>
|
||||
|
||||
<div className="flex">{getPageButtons()}</div>
|
||||
|
||||
<button
|
||||
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className={`px-3 py-1 rounded ml-2 ${
|
||||
currentPage === totalPages
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Next »
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pagination;
|
||||
96
frontend/src/components/ui/Toast.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info' | 'warning';
|
||||
|
||||
export interface ToastProps {
|
||||
message: string;
|
||||
type?: ToastType;
|
||||
duration?: number;
|
||||
onClose: () => void;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
const Toast: React.FC<ToastProps> = ({
|
||||
message,
|
||||
type = 'info',
|
||||
duration = 3000,
|
||||
onClose,
|
||||
visible
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
const timer = setTimeout(() => {
|
||||
onClose();
|
||||
}, duration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [visible, duration, onClose]);
|
||||
|
||||
const icons = {
|
||||
success: <Check className="w-5 h-5 text-green-500" />,
|
||||
error: <X className="w-5 h-5 text-red-500" />,
|
||||
info: (
|
||||
<svg className="w-5 h-5 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
warning: (
|
||||
<svg className="w-5 h-5 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
)
|
||||
};
|
||||
|
||||
const bgColors = {
|
||||
success: 'bg-green-50 border-green-200',
|
||||
error: 'bg-red-50 border-red-200',
|
||||
info: 'bg-blue-50 border-blue-200',
|
||||
warning: 'bg-yellow-50 border-yellow-200'
|
||||
};
|
||||
|
||||
const textColors = {
|
||||
success: 'text-green-800',
|
||||
error: 'text-red-800',
|
||||
info: 'text-blue-800',
|
||||
warning: 'text-yellow-800'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"fixed top-4 right-4 z-50 max-w-sm p-4 rounded-md shadow-lg border",
|
||||
bgColors[type],
|
||||
"transform transition-all duration-300 ease-in-out",
|
||||
visible ? "translate-x-0 opacity-100" : "translate-x-full opacity-0"
|
||||
)}>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
{icons[type]}
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className={cn("text-sm font-medium", textColors[type])}>
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto pl-3">
|
||||
<div className="-mx-1.5 -my-1.5">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"inline-flex rounded-md p-1.5",
|
||||
`hover:bg-${type}-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-${type}-500`
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">Dismiss</span>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toast;
|
||||
@@ -97,4 +97,38 @@ export const ToggleGroup: React.FC<ToggleGroupProps> = ({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SwitchProps {
|
||||
checked: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const Switch: React.FC<SwitchProps> = ({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500",
|
||||
checked ? "bg-blue-600" : "bg-gray-200",
|
||||
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
|
||||
)}
|
||||
onClick={() => !disabled && onCheckedChange(!checked)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform",
|
||||
checked ? "translate-x-6" : "translate-x-1"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
60
frontend/src/contexts/ToastContext.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
import Toast, { ToastType } from '@/components/ui/Toast';
|
||||
|
||||
interface ToastContextProps {
|
||||
showToast: (message: string, type?: ToastType, duration?: number) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextProps | undefined>(undefined);
|
||||
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface ToastProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
|
||||
const [toast, setToast] = useState<{
|
||||
message: string;
|
||||
type: ToastType;
|
||||
visible: boolean;
|
||||
duration: number;
|
||||
}>({
|
||||
message: '',
|
||||
type: 'info',
|
||||
visible: false,
|
||||
duration: 3000,
|
||||
});
|
||||
|
||||
const showToast = (message: string, type: ToastType = 'info', duration: number = 3000) => {
|
||||
setToast({
|
||||
message,
|
||||
type,
|
||||
visible: true,
|
||||
duration,
|
||||
});
|
||||
};
|
||||
|
||||
const hideToast = () => {
|
||||
setToast((prev) => ({ ...prev, visible: false }));
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
<Toast
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
duration={toast.duration}
|
||||
onClose={hideToast}
|
||||
visible={toast.visible}
|
||||
/>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
410
frontend/src/hooks/useMarketData.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MarketServer, ApiResponse } from '@/types';
|
||||
|
||||
export const useMarketData = () => {
|
||||
const { t } = useTranslation();
|
||||
const [servers, setServers] = useState<MarketServer[]>([]);
|
||||
const [allServers, setAllServers] = useState<MarketServer[]>([]);
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('');
|
||||
const [selectedTag, setSelectedTag] = useState<string>('');
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentServer, setCurrentServer] = useState<MarketServer | null>(null);
|
||||
const [installedServers, setInstalledServers] = useState<string[]>([]);
|
||||
|
||||
// Pagination states
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [serversPerPage, setServersPerPage] = useState(9);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
// Fetch all market servers
|
||||
const fetchMarketServers = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/market/servers', {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<MarketServer[]> = await response.json();
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setAllServers(data.data);
|
||||
// Apply pagination to the fetched data
|
||||
applyPagination(data.data, currentPage);
|
||||
} else {
|
||||
console.error('Invalid market servers data format:', data);
|
||||
setError(t('market.fetchError'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching market servers:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t, currentPage]);
|
||||
|
||||
// Apply pagination to data
|
||||
const applyPagination = useCallback((data: MarketServer[], page: number, itemsPerPage = serversPerPage) => {
|
||||
const totalItems = data.length;
|
||||
const calculatedTotalPages = Math.ceil(totalItems / itemsPerPage);
|
||||
setTotalPages(calculatedTotalPages);
|
||||
|
||||
// Ensure current page is valid
|
||||
const validPage = Math.max(1, Math.min(page, calculatedTotalPages));
|
||||
if (validPage !== page) {
|
||||
setCurrentPage(validPage);
|
||||
}
|
||||
|
||||
const startIndex = (validPage - 1) * itemsPerPage;
|
||||
const paginatedServers = data.slice(startIndex, startIndex + itemsPerPage);
|
||||
setServers(paginatedServers);
|
||||
}, [serversPerPage]);
|
||||
|
||||
// Change page
|
||||
const changePage = useCallback((page: number) => {
|
||||
setCurrentPage(page);
|
||||
applyPagination(allServers, page, serversPerPage);
|
||||
}, [allServers, applyPagination, serversPerPage]);
|
||||
|
||||
// Fetch all categories
|
||||
const fetchCategories = useCallback(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/market/categories', {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<string[]> = await response.json();
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setCategories(data.data);
|
||||
} else {
|
||||
console.error('Invalid categories data format:', data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching categories:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch all tags
|
||||
const fetchTags = useCallback(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/market/tags', {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<string[]> = await response.json();
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setTags(data.data);
|
||||
} else {
|
||||
console.error('Invalid tags data format:', data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching tags:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch server by name
|
||||
const fetchServerByName = useCallback(async (name: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(`/api/market/servers/${name}`, {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<MarketServer> = await response.json();
|
||||
|
||||
if (data && data.success && data.data) {
|
||||
setCurrentServer(data.data);
|
||||
return data.data;
|
||||
} else {
|
||||
console.error('Invalid server data format:', data);
|
||||
setError(t('market.serverNotFound'));
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error fetching server ${name}:`, err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
// Search servers by query
|
||||
const searchServers = useCallback(async (query: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setSearchQuery(query);
|
||||
|
||||
if (!query.trim()) {
|
||||
// Fetch fresh data from server instead of just applying pagination
|
||||
fetchMarketServers();
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(`/api/market/servers/search?query=${encodeURIComponent(query)}`, {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<MarketServer[]> = await response.json();
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setAllServers(data.data);
|
||||
setCurrentPage(1);
|
||||
applyPagination(data.data, 1);
|
||||
} else {
|
||||
console.error('Invalid search results format:', data);
|
||||
setError(t('market.searchError'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error searching servers:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t, allServers, applyPagination, fetchMarketServers]);
|
||||
|
||||
// Filter servers by category
|
||||
const filterByCategory = useCallback(async (category: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setSelectedCategory(category);
|
||||
setSelectedTag(''); // Reset tag filter when filtering by category
|
||||
|
||||
if (!category) {
|
||||
fetchMarketServers();
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(`/api/market/categories/${encodeURIComponent(category)}`, {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<MarketServer[]> = await response.json();
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setAllServers(data.data);
|
||||
setCurrentPage(1);
|
||||
applyPagination(data.data, 1);
|
||||
} else {
|
||||
console.error('Invalid category filter results format:', data);
|
||||
setError(t('market.filterError'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error filtering servers by category:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t, fetchMarketServers, applyPagination]);
|
||||
|
||||
// Filter servers by tag
|
||||
const filterByTag = useCallback(async (tag: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setSelectedTag(tag);
|
||||
setSelectedCategory(''); // Reset category filter when filtering by tag
|
||||
|
||||
if (!tag) {
|
||||
fetchMarketServers();
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(`/api/market/tags/${encodeURIComponent(tag)}`, {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<MarketServer[]> = await response.json();
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setAllServers(data.data);
|
||||
setCurrentPage(1);
|
||||
applyPagination(data.data, 1);
|
||||
} else {
|
||||
console.error('Invalid tag filter results format:', data);
|
||||
setError(t('market.tagFilterError'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error filtering servers by tag:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t, fetchMarketServers, applyPagination]);
|
||||
|
||||
// Fetch installed servers
|
||||
const fetchInstalledServers = useCallback(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/servers', {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
// Extract server names
|
||||
const installedServerNames = data.data.map((server: any) => server.name);
|
||||
setInstalledServers(installedServerNames);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching installed servers:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Check if a server is already installed
|
||||
const isServerInstalled = useCallback((serverName: string) => {
|
||||
return installedServers.includes(serverName);
|
||||
}, [installedServers]);
|
||||
|
||||
// Install server to the local environment
|
||||
const installServer = useCallback(async (server: MarketServer) => {
|
||||
try {
|
||||
const installType = server.installations?.npm ? 'npm' : Object.keys(server.installations || {}).length > 0 ? Object.keys(server.installations)[0] : null;
|
||||
|
||||
if (!installType || !server.installations?.[installType]) {
|
||||
setError(t('market.noInstallationMethod'));
|
||||
return false;
|
||||
}
|
||||
|
||||
const installation = server.installations[installType];
|
||||
|
||||
// Prepare server configuration
|
||||
const serverConfig = {
|
||||
name: server.name,
|
||||
config: {
|
||||
command: installation.command,
|
||||
args: installation.args,
|
||||
env: installation.env || {}
|
||||
}
|
||||
};
|
||||
|
||||
// Call the createServer API
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/servers', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || ''
|
||||
},
|
||||
body: JSON.stringify(serverConfig),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || `Status: ${response.status}`);
|
||||
}
|
||||
|
||||
// Update installed servers list after successful installation
|
||||
await fetchInstalledServers();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error installing server:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
}
|
||||
}, [t, fetchInstalledServers]);
|
||||
|
||||
// Change servers per page
|
||||
const changeServersPerPage = useCallback((perPage: number) => {
|
||||
setServersPerPage(perPage);
|
||||
setCurrentPage(1);
|
||||
applyPagination(allServers, 1, perPage);
|
||||
}, [allServers, applyPagination]);
|
||||
|
||||
// Load initial data
|
||||
useEffect(() => {
|
||||
fetchMarketServers();
|
||||
fetchCategories();
|
||||
fetchTags();
|
||||
fetchInstalledServers();
|
||||
}, [fetchMarketServers, fetchCategories, fetchTags, fetchInstalledServers]);
|
||||
|
||||
return {
|
||||
servers,
|
||||
allServers,
|
||||
categories,
|
||||
tags,
|
||||
selectedCategory,
|
||||
selectedTag,
|
||||
searchQuery,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
currentServer,
|
||||
fetchMarketServers,
|
||||
fetchServerByName,
|
||||
searchServers,
|
||||
filterByCategory,
|
||||
filterByTag,
|
||||
installServer,
|
||||
// Pagination properties and methods
|
||||
currentPage,
|
||||
totalPages,
|
||||
serversPerPage,
|
||||
changePage,
|
||||
changeServersPerPage,
|
||||
// Installed servers methods
|
||||
isServerInstalled
|
||||
};
|
||||
};
|
||||
@@ -2,16 +2,16 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server, ApiResponse } from '@/types';
|
||||
|
||||
// 配置选项
|
||||
// Configuration options
|
||||
const CONFIG = {
|
||||
// 初始化启动阶段的配置
|
||||
// Initialization phase configuration
|
||||
startup: {
|
||||
maxAttempts: 60, // 初始化阶段最大尝试次数
|
||||
pollingInterval: 3000 // 初始阶段轮询间隔 (3秒)
|
||||
maxAttempts: 60, // Maximum number of attempts during initialization
|
||||
pollingInterval: 3000 // Polling interval during initialization (3 seconds)
|
||||
},
|
||||
// 正常运行阶段的配置
|
||||
// Normal operation phase configuration
|
||||
normal: {
|
||||
pollingInterval: 10000 // 正常运行时的轮询间隔 (10秒)
|
||||
pollingInterval: 10000 // Polling interval during normal operation (10 seconds)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -23,12 +23,12 @@ export const useServerData = () => {
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const [fetchAttempts, setFetchAttempts] = useState(0);
|
||||
|
||||
// 轮询定时器引用
|
||||
// Timer reference for polling
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
// 保存当前尝试次数,避免依赖循环
|
||||
// Track current attempt count to avoid dependency cycles
|
||||
const attemptsRef = useRef<number>(0);
|
||||
|
||||
// 清理定时器
|
||||
// Clear the timer
|
||||
const clearTimer = () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
@@ -36,9 +36,9 @@ export const useServerData = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 开始正常轮询
|
||||
// Start normal polling
|
||||
const startNormalPolling = useCallback(() => {
|
||||
// 确保没有其他定时器在运行
|
||||
// Ensure no other timers are running
|
||||
clearTimer();
|
||||
|
||||
const fetchServers = async () => {
|
||||
@@ -60,12 +60,12 @@ export const useServerData = () => {
|
||||
setServers([]);
|
||||
}
|
||||
|
||||
// 重置错误状态
|
||||
// Reset error state
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching servers during normal polling:', err);
|
||||
|
||||
// 使用友好的错误消息
|
||||
// Use friendly error message
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else if (err instanceof TypeError && (
|
||||
@@ -79,21 +79,21 @@ export const useServerData = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 立即执行一次
|
||||
// Execute immediately
|
||||
fetchServers();
|
||||
|
||||
// 设置定期轮询
|
||||
// Set up regular polling
|
||||
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
// 重置尝试计数
|
||||
// Reset attempt count
|
||||
if (refreshKey > 0) {
|
||||
attemptsRef.current = 0;
|
||||
setFetchAttempts(0);
|
||||
}
|
||||
|
||||
// 初始化加载阶段的请求函数
|
||||
// Initialization phase request function
|
||||
const fetchInitialData = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
@@ -104,51 +104,51 @@ export const useServerData = () => {
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
// 处理API响应中的包装对象,提取data字段
|
||||
// Handle API response wrapper object, extract data field
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
setIsInitialLoading(false);
|
||||
// 初始化成功,开始正常轮询
|
||||
// Initialization successful, start normal polling
|
||||
startNormalPolling();
|
||||
return true;
|
||||
} else if (data && Array.isArray(data)) {
|
||||
// 兼容性处理,如果API直接返回数组
|
||||
// Compatibility handling, if API directly returns array
|
||||
setServers(data);
|
||||
setIsInitialLoading(false);
|
||||
// 初始化成功,开始正常轮询
|
||||
// Initialization successful, start normal polling
|
||||
startNormalPolling();
|
||||
return true;
|
||||
} else {
|
||||
// 如果数据格式不符合预期,设置为空数组
|
||||
// If data format is not as expected, set to empty array
|
||||
console.error('Invalid server data format:', data);
|
||||
setServers([]);
|
||||
setIsInitialLoading(false);
|
||||
// 初始化成功但数据为空,开始正常轮询
|
||||
// Initialization successful but data is empty, start normal polling
|
||||
startNormalPolling();
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
// 增加尝试次数计数,使用 ref 避免触发 effect 重新运行
|
||||
// Increment attempt count, use ref to avoid triggering effect rerun
|
||||
attemptsRef.current += 1;
|
||||
console.error(`Initial loading attempt ${attemptsRef.current} failed:`, err);
|
||||
|
||||
// 更新状态用于显示
|
||||
// Update state for display
|
||||
setFetchAttempts(attemptsRef.current);
|
||||
|
||||
// 设置适当的错误消息
|
||||
// Set appropriate error message
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else {
|
||||
setError(t('errors.initialStartup'));
|
||||
}
|
||||
|
||||
// 如果已超过最大尝试次数,放弃初始化并切换到正常轮询
|
||||
// If maximum attempt count is exceeded, give up initialization and switch to normal polling
|
||||
if (attemptsRef.current >= CONFIG.startup.maxAttempts) {
|
||||
console.log('Maximum startup attempts reached, switching to normal polling');
|
||||
setIsInitialLoading(false);
|
||||
// 清除初始化的轮询
|
||||
// Clear initialization polling
|
||||
clearTimer();
|
||||
// 切换到正常轮询模式
|
||||
// Switch to normal polling mode
|
||||
startNormalPolling();
|
||||
}
|
||||
|
||||
@@ -156,45 +156,45 @@ export const useServerData = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 组件挂载时,根据当前状态设置适当的轮询
|
||||
// On component mount, set appropriate polling based on current state
|
||||
if (isInitialLoading) {
|
||||
// 确保没有其他定时器在运行
|
||||
// Ensure no other timers are running
|
||||
clearTimer();
|
||||
|
||||
// 立即执行一次初始请求
|
||||
// Execute initial request immediately
|
||||
fetchInitialData();
|
||||
|
||||
// 设置初始阶段的轮询间隔
|
||||
// Set polling interval for initialization phase
|
||||
intervalRef.current = setInterval(fetchInitialData, CONFIG.startup.pollingInterval);
|
||||
console.log(`Started initial polling with interval: ${CONFIG.startup.pollingInterval}ms`);
|
||||
} else {
|
||||
// 已经初始化完成,开始正常轮询
|
||||
// Initialization completed, start normal polling
|
||||
startNormalPolling();
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
// Cleanup function
|
||||
return () => {
|
||||
clearTimer();
|
||||
};
|
||||
}, [refreshKey, t, isInitialLoading, startNormalPolling]);
|
||||
|
||||
// 手动触发刷新
|
||||
// Manually trigger refresh
|
||||
const triggerRefresh = () => {
|
||||
// 清除当前的定时器
|
||||
// Clear current timer
|
||||
clearTimer();
|
||||
|
||||
// 如果在初始化阶段,重置初始化状态
|
||||
// If in initialization phase, reset initialization state
|
||||
if (isInitialLoading) {
|
||||
setIsInitialLoading(true);
|
||||
attemptsRef.current = 0;
|
||||
setFetchAttempts(0);
|
||||
}
|
||||
|
||||
// refreshKey 的改变会触发 useEffect 再次运行
|
||||
// Change in refreshKey will trigger useEffect to run again
|
||||
setRefreshKey(prevKey => prevKey + 1);
|
||||
};
|
||||
|
||||
// 服务器相关操作
|
||||
// Server related operations
|
||||
const handleServerAdd = () => {
|
||||
setRefreshKey(prevKey => prevKey + 1);
|
||||
};
|
||||
|
||||
131
frontend/src/hooks/useSettingsData.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ApiResponse } from '@/types';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
|
||||
// Define types for the settings data
|
||||
interface RoutingConfig {
|
||||
enableGlobalRoute: boolean;
|
||||
enableGroupNameRoute: boolean;
|
||||
}
|
||||
|
||||
interface SystemSettings {
|
||||
systemConfig?: {
|
||||
routing?: RoutingConfig;
|
||||
};
|
||||
}
|
||||
|
||||
export const useSettingsData = () => {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [routingConfig, setRoutingConfig] = useState<RoutingConfig>({
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
// Trigger a refresh of the settings data
|
||||
const triggerRefresh = useCallback(() => {
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
// Fetch current settings
|
||||
const fetchSettings = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/settings', {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<SystemSettings> = await response.json();
|
||||
|
||||
if (data.success && data.data?.systemConfig?.routing) {
|
||||
setRoutingConfig({
|
||||
enableGlobalRoute: data.data.systemConfig.routing.enableGlobalRoute ?? true,
|
||||
enableGroupNameRoute: data.data.systemConfig.routing.enableGroupNameRoute ?? true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to fetch settings');
|
||||
showToast(t('errors.failedToFetchSettings'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t, showToast]);
|
||||
|
||||
// Update routing configuration
|
||||
const updateRoutingConfig = async (key: keyof RoutingConfig, value: boolean) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/system-config', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
routing: {
|
||||
[key]: value,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setRoutingConfig({
|
||||
...routingConfig,
|
||||
[key]: value,
|
||||
});
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
showToast(t('errors.failedToUpdateSystemConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update system config:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to update system config');
|
||||
showToast(t('errors.failedToUpdateSystemConfig'));
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch settings when the component mounts or refreshKey changes
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, [fetchSettings, refreshKey]);
|
||||
|
||||
return {
|
||||
routingConfig,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
triggerRefresh,
|
||||
fetchSettings,
|
||||
updateRoutingConfig,
|
||||
};
|
||||
};
|
||||
@@ -32,8 +32,8 @@ i18n
|
||||
},
|
||||
|
||||
detection: {
|
||||
// Order of detection; we put 'navigator' first to use browser language
|
||||
order: ['navigator', 'localStorage', 'cookie', 'htmlTag'],
|
||||
// Order of detection; prioritize localStorage to respect user language choice
|
||||
order: ['localStorage', 'cookie', 'htmlTag', 'navigator'],
|
||||
// Cache the language in localStorage
|
||||
caches: ['localStorage', 'cookie'],
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"profile": "Profile",
|
||||
"changePassword": "Change Password",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"welcomeUser": "Welcome, {{username}}"
|
||||
"welcomeUser": "Welcome, {{username}}",
|
||||
"name": "MCP Hub"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
@@ -26,7 +27,9 @@
|
||||
"passwordsNotMatch": "New password and confirmation do not match",
|
||||
"changePasswordSuccess": "Password changed successfully",
|
||||
"changePasswordError": "Failed to change password",
|
||||
"changePassword": "Change Password"
|
||||
"changePassword": "Change Password",
|
||||
"passwordChanged": "Password changed successfully",
|
||||
"passwordChangeError": "Failed to change password"
|
||||
},
|
||||
"server": {
|
||||
"addServer": "Add Server",
|
||||
@@ -40,7 +43,7 @@
|
||||
"name": "Server Name",
|
||||
"url": "Server URL",
|
||||
"apiKey": "API Key",
|
||||
"save": "Save Changes",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"invalidConfig": "Could not find configuration data for {{serverName}}",
|
||||
"addError": "Failed to add server",
|
||||
@@ -61,7 +64,11 @@
|
||||
"toggleError": "Failed to toggle server {{serverName}}",
|
||||
"alreadyExists": "Server {{serverName}} already exists",
|
||||
"invalidData": "Invalid server data provided",
|
||||
"notFound": "Server {{serverName}} not found"
|
||||
"notFound": "Server {{serverName}} not found",
|
||||
"namePlaceholder": "Enter server name",
|
||||
"urlPlaceholder": "Enter server URL",
|
||||
"commandPlaceholder": "Enter command",
|
||||
"argumentsPlaceholder": "Enter arguments"
|
||||
},
|
||||
"status": {
|
||||
"online": "Online",
|
||||
@@ -75,7 +82,10 @@
|
||||
"serverAdd": "Failed to add server. Please check the server status",
|
||||
"serverUpdate": "Failed to edit server {{serverName}}. Please check the server status",
|
||||
"serverFetch": "Failed to retrieve server data. Please try again later",
|
||||
"initialStartup": "The server might be starting up. Please wait a moment as this process can take some time on first launch..."
|
||||
"initialStartup": "The server might be starting up. Please wait a moment as this process can take some time on first launch...",
|
||||
"serverInstall": "Failed to install server",
|
||||
"failedToFetchSettings": "Failed to fetch settings",
|
||||
"failedToUpdateRouteConfig": "Failed to update route configuration"
|
||||
},
|
||||
"common": {
|
||||
"processing": "Processing...",
|
||||
@@ -84,14 +94,16 @@
|
||||
"refresh": "Refresh",
|
||||
"create": "Create",
|
||||
"submitting": "Submitting...",
|
||||
"delete": "Delete"
|
||||
"delete": "Delete",
|
||||
"copy": "Copy"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"servers": "Servers",
|
||||
"groups": "Groups",
|
||||
"settings": "Settings",
|
||||
"changePassword": "Change Password"
|
||||
"changePassword": "Change Password",
|
||||
"market": "Market"
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
@@ -110,7 +122,14 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"language": "Language"
|
||||
"language": "Language",
|
||||
"account": "Account Settings",
|
||||
"password": "Change Password",
|
||||
"appearance": "Appearance",
|
||||
"routeConfig": "Route Configuration"
|
||||
},
|
||||
"market": {
|
||||
"title": "Server Market - (Data from mcpm.sh)"
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
@@ -138,5 +157,54 @@
|
||||
"noServers": "No servers in this group.",
|
||||
"noServerOptions": "No servers available",
|
||||
"serverCount": "{{count}} Servers"
|
||||
},
|
||||
"market": {
|
||||
"title": "Server Market",
|
||||
"official": "Official",
|
||||
"by": "By",
|
||||
"unknown": "Unknown",
|
||||
"tools": "tools",
|
||||
"search": "Search",
|
||||
"searchPlaceholder": "Search for servers by name, category, or tags",
|
||||
"clearFilters": "Clear",
|
||||
"clearCategoryFilter": "",
|
||||
"clearTagFilter": "",
|
||||
"categories": "Categories",
|
||||
"tags": "Tags",
|
||||
"showTags": "Show tags",
|
||||
"hideTags": "Hide tags",
|
||||
"moreTags": "",
|
||||
"noServers": "No servers found matching your search",
|
||||
"backToList": "Back to list",
|
||||
"install": "Install",
|
||||
"installing": "Installing...",
|
||||
"installed": "Installed",
|
||||
"installServer": "Install Server: {{name}}",
|
||||
"installSuccess": "Server {{serverName}} installed successfully",
|
||||
"author": "Author",
|
||||
"license": "License",
|
||||
"repository": "Repository",
|
||||
"examples": "Examples",
|
||||
"arguments": "Arguments",
|
||||
"argumentName": "Name",
|
||||
"description": "Description",
|
||||
"required": "Required",
|
||||
"example": "Example",
|
||||
"viewSchema": "View schema",
|
||||
"fetchError": "Error fetching market servers",
|
||||
"serverNotFound": "Server not found",
|
||||
"searchError": "Error searching servers",
|
||||
"filterError": "Error filtering servers by category",
|
||||
"tagFilterError": "Error filtering servers by tag",
|
||||
"noInstallationMethod": "No installation method available for this server",
|
||||
"showing": "Showing {{from}}-{{to}} of {{total}} servers",
|
||||
"perPage": "Per page"
|
||||
},
|
||||
"settings": {
|
||||
"enableGlobalRoute": "Enable Global Route",
|
||||
"enableGlobalRouteDescription": "Allow connections to /sse endpoint without specifying a group ID",
|
||||
"enableGroupNameRoute": "Enable Group Name Route",
|
||||
"enableGroupNameRouteDescription": "Allow connections to /sse endpoint using group names instead of just group IDs",
|
||||
"systemConfigUpdated": "System configuration updated successfully"
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,8 @@
|
||||
"profile": "个人资料",
|
||||
"changePassword": "修改密码",
|
||||
"toggleSidebar": "切换侧边栏",
|
||||
"welcomeUser": "欢迎, {{username}}"
|
||||
"welcomeUser": "欢迎, {{username}}",
|
||||
"name": "MCP Hub"
|
||||
},
|
||||
"auth": {
|
||||
"login": "登录",
|
||||
@@ -26,7 +27,9 @@
|
||||
"passwordsNotMatch": "新密码与确认密码不一致",
|
||||
"changePasswordSuccess": "密码修改成功",
|
||||
"changePasswordError": "修改密码失败",
|
||||
"changePassword": "修改密码"
|
||||
"changePassword": "修改密码",
|
||||
"passwordChanged": "密码修改成功",
|
||||
"passwordChangeError": "修改密码失败"
|
||||
},
|
||||
"server": {
|
||||
"addServer": "添加服务器",
|
||||
@@ -40,7 +43,7 @@
|
||||
"name": "服务器名称",
|
||||
"url": "服务器 URL",
|
||||
"apiKey": "API 密钥",
|
||||
"save": "保存更改",
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"addError": "添加服务器失败",
|
||||
"editError": "编辑服务器 {{serverName}} 失败",
|
||||
@@ -61,7 +64,11 @@
|
||||
"toggleError": "切换服务器 {{serverName}} 状态失败",
|
||||
"alreadyExists": "服务器 {{serverName}} 已经存在",
|
||||
"invalidData": "提供的服务器数据无效",
|
||||
"notFound": "找不到服务器 {{serverName}}"
|
||||
"notFound": "找不到服务器 {{serverName}}",
|
||||
"namePlaceholder": "请输入服务器名称",
|
||||
"urlPlaceholder": "请输入服务器URL",
|
||||
"commandPlaceholder": "请输入命令",
|
||||
"argumentsPlaceholder": "请输入参数"
|
||||
},
|
||||
"status": {
|
||||
"online": "在线",
|
||||
@@ -75,7 +82,10 @@
|
||||
"serverAdd": "添加服务器失败,请检查服务器状态",
|
||||
"serverUpdate": "编辑服务器 {{serverName}} 失败,请检查服务器状态",
|
||||
"serverFetch": "获取服务器数据失败,请稍后重试",
|
||||
"initialStartup": "服务器可能正在启动中。首次启动可能需要一些时间,请耐心等候..."
|
||||
"initialStartup": "服务器可能正在启动中。首次启动可能需要一些时间,请耐心等候...",
|
||||
"serverInstall": "安装服务器失败",
|
||||
"failedToFetchSettings": "获取设置失败",
|
||||
"failedToUpdateSystemConfig": "更新系统配置失败"
|
||||
},
|
||||
"common": {
|
||||
"processing": "处理中...",
|
||||
@@ -84,14 +94,16 @@
|
||||
"refresh": "刷新",
|
||||
"create": "创建",
|
||||
"submitting": "提交中...",
|
||||
"delete": "删除"
|
||||
"delete": "删除",
|
||||
"copy": "复制"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "仪表盘",
|
||||
"servers": "服务器",
|
||||
"settings": "设置",
|
||||
"changePassword": "修改密码",
|
||||
"groups": "分组"
|
||||
"groups": "分组",
|
||||
"market": "市场"
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
@@ -107,10 +119,17 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
"language": "语言"
|
||||
"language": "语言",
|
||||
"account": "账户设置",
|
||||
"password": "修改密码",
|
||||
"appearance": "外观",
|
||||
"routeConfig": "路由配置"
|
||||
},
|
||||
"groups": {
|
||||
"title": "分组管理"
|
||||
},
|
||||
"market": {
|
||||
"title": "服务器市场 - (数据来源于 mcpm.sh)"
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
@@ -138,5 +157,54 @@
|
||||
"noServers": "此分组中没有服务器。",
|
||||
"noServerOptions": "没有可用的服务器",
|
||||
"serverCount": "{{count}} 台服务器"
|
||||
},
|
||||
"market": {
|
||||
"title": "服务器市场",
|
||||
"official": "官方",
|
||||
"by": "作者",
|
||||
"unknown": "未知",
|
||||
"tools": "工具",
|
||||
"search": "搜索",
|
||||
"searchPlaceholder": "搜索服务器名称、分类或标签",
|
||||
"clearFilters": "清除",
|
||||
"clearCategoryFilter": "",
|
||||
"clearTagFilter": "",
|
||||
"categories": "分类",
|
||||
"tags": "标签",
|
||||
"showTags": "显示标签",
|
||||
"hideTags": "隐藏标签",
|
||||
"moreTags": "",
|
||||
"noServers": "未找到匹配的服务器",
|
||||
"backToList": "返回列表",
|
||||
"install": "安装",
|
||||
"installing": "安装中...",
|
||||
"installed": "已安装",
|
||||
"installServer": "安装服务器: {{name}}",
|
||||
"installSuccess": "服务器 {{serverName}} 安装成功",
|
||||
"author": "作者",
|
||||
"license": "许可证",
|
||||
"repository": "代码仓库",
|
||||
"examples": "示例",
|
||||
"arguments": "参数",
|
||||
"argumentName": "名称",
|
||||
"description": "描述",
|
||||
"required": "必填",
|
||||
"example": "示例",
|
||||
"viewSchema": "查看结构",
|
||||
"fetchError": "获取服务器市场数据失败",
|
||||
"serverNotFound": "未找到服务器",
|
||||
"searchError": "搜索服务器失败",
|
||||
"filterError": "按分类筛选服务器失败",
|
||||
"tagFilterError": "按标签筛选服务器失败",
|
||||
"noInstallationMethod": "该服务器没有可用的安装方法",
|
||||
"showing": "显示 {{from}}-{{to}}/{{total}} 个服务器",
|
||||
"perPage": "每页显示"
|
||||
},
|
||||
"settings": {
|
||||
"enableGlobalRoute": "启用全局路由",
|
||||
"enableGlobalRouteDescription": "允许不指定分组 ID 就连接到 /sse 端点",
|
||||
"enableGroupNameRoute": "启用分组名称路由",
|
||||
"enableGroupNameRouteDescription": "允许使用分组名称而非分组 ID 连接到 /sse 端点",
|
||||
"systemConfigUpdated": "系统配置更新成功"
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ const DashboardPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { servers, error, setError, isLoading } = useServerData();
|
||||
|
||||
// 计算服务器统计信息
|
||||
// Calculate server statistics
|
||||
const serverStats = {
|
||||
total: servers.length,
|
||||
online: servers.filter(server => server.status === 'connected').length,
|
||||
@@ -22,7 +22,7 @@ const DashboardPage: React.FC = () => {
|
||||
connecting: 'status.connecting'
|
||||
}
|
||||
|
||||
// 计算各状态百分比(用于仪表板展示)
|
||||
// Calculate percentage for each status (for dashboard display)
|
||||
const getStatusPercentage = (status: ServerStatus) => {
|
||||
if (servers.length === 0) return 0;
|
||||
return Math.round((servers.filter(server => server.status === status).length / servers.length) * 100);
|
||||
@@ -64,7 +64,7 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{/* 服务器总数 */}
|
||||
{/* Total servers */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-blue-100 text-blue-800">
|
||||
@@ -79,7 +79,7 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 在线服务器 */}
|
||||
{/* Online servers */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-green-100 text-green-800">
|
||||
@@ -100,7 +100,7 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 离线服务器 */}
|
||||
{/* Offline servers */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-red-100 text-red-800">
|
||||
@@ -121,7 +121,7 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 连接中服务器 */}
|
||||
{/* Connecting servers */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-yellow-100 text-yellow-800">
|
||||
@@ -144,7 +144,7 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 最近活动列表 */}
|
||||
{/* Recent activity list */}
|
||||
{servers.length > 0 && !isLoading && (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">{t('pages.dashboard.recentServers')}</h2>
|
||||
|
||||
356
frontend/src/pages/MarketPage.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
||||
import { MarketServer } from '@/types';
|
||||
import { useMarketData } from '@/hooks/useMarketData';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import MarketServerCard from '@/components/MarketServerCard';
|
||||
import MarketServerDetail from '@/components/MarketServerDetail';
|
||||
import Pagination from '@/components/ui/Pagination';
|
||||
|
||||
const MarketPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { serverName } = useParams<{ serverName?: string }>();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const {
|
||||
servers,
|
||||
allServers,
|
||||
categories,
|
||||
tags,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
searchServers,
|
||||
filterByCategory,
|
||||
filterByTag,
|
||||
selectedCategory,
|
||||
selectedTag,
|
||||
installServer,
|
||||
fetchServerByName,
|
||||
isServerInstalled,
|
||||
// Pagination
|
||||
currentPage,
|
||||
totalPages,
|
||||
changePage,
|
||||
serversPerPage,
|
||||
changeServersPerPage
|
||||
} = useMarketData();
|
||||
|
||||
const [selectedServer, setSelectedServer] = useState<MarketServer | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [showTags, setShowTags] = useState(false);
|
||||
|
||||
// Load server details if a server name is in the URL
|
||||
useEffect(() => {
|
||||
const loadServerDetails = async () => {
|
||||
if (serverName) {
|
||||
const server = await fetchServerByName(serverName);
|
||||
if (server) {
|
||||
setSelectedServer(server);
|
||||
} else {
|
||||
// If server not found, navigate back to market page
|
||||
navigate('/market');
|
||||
}
|
||||
} else {
|
||||
setSelectedServer(null);
|
||||
}
|
||||
};
|
||||
|
||||
loadServerDetails();
|
||||
}, [serverName, fetchServerByName, navigate]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
searchServers(searchQuery);
|
||||
};
|
||||
|
||||
const handleCategoryClick = (category: string) => {
|
||||
filterByCategory(category);
|
||||
};
|
||||
|
||||
const handleTagClick = (tag: string) => {
|
||||
filterByTag(tag);
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSearchQuery('');
|
||||
filterByCategory('');
|
||||
filterByTag('');
|
||||
};
|
||||
|
||||
const handleServerClick = (server: MarketServer) => {
|
||||
navigate(`/market/${server.name}`);
|
||||
};
|
||||
|
||||
const handleBackToList = () => {
|
||||
navigate('/market');
|
||||
};
|
||||
|
||||
const handleInstall = async (server: MarketServer) => {
|
||||
try {
|
||||
setInstalling(true);
|
||||
const success = await installServer(server);
|
||||
if (success) {
|
||||
// Show success message using toast instead of alert
|
||||
showToast(t('market.installSuccess', { serverName: server.display_name }), 'success');
|
||||
}
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
changePage(page);
|
||||
// Scroll to top of page when changing pages
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleChangeItemsPerPage = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newValue = parseInt(e.target.value, 10);
|
||||
changeServersPerPage(newValue);
|
||||
};
|
||||
|
||||
const toggleTagsVisibility = () => {
|
||||
setShowTags(!showTags);
|
||||
};
|
||||
|
||||
// Render detailed view if a server is selected
|
||||
if (selectedServer) {
|
||||
return (
|
||||
<MarketServerDetail
|
||||
server={selectedServer}
|
||||
onBack={handleBackToList}
|
||||
onInstall={handleInstall}
|
||||
installing={installing}
|
||||
isInstalled={isServerInstalled(selectedServer.name)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center">
|
||||
{t('market.title')}
|
||||
<span className="text-sm text-gray-500 font-normal ml-2">{t('pages.market.title').split(' - ')[1]}</span>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p>{error}</p>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="text-red-700 hover:text-red-900"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 01.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search bar at the top */}
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<form onSubmit={handleSearch} className="flex space-x-4 mb-0">
|
||||
<div className="flex-grow">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('market.searchPlaceholder')}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded"
|
||||
>
|
||||
{t('market.search')}
|
||||
</button>
|
||||
{(searchQuery || selectedCategory || selectedTag) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearFilters}
|
||||
className="border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded hover:bg-gray-50"
|
||||
>
|
||||
{t('market.clearFilters')}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
{/* Left sidebar for filters (without search) */}
|
||||
<div className="md:w-48 flex-shrink-0">
|
||||
<div className="bg-white shadow rounded-lg p-4 mb-6 sticky top-4">
|
||||
{/* Categories */}
|
||||
{categories.length > 0 ? (
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
|
||||
{selectedCategory && (
|
||||
<span className="text-xs text-blue-600 cursor-pointer hover:underline" onClick={() => filterByCategory('')}>
|
||||
{t('market.clearCategoryFilter')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => handleCategoryClick(category)}
|
||||
className={`px-3 py-2 rounded text-sm text-left ${selectedCategory === category
|
||||
? 'bg-blue-100 text-blue-800 font-medium'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="mb-6">
|
||||
<div className="mb-3">
|
||||
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 items-center py-4">
|
||||
<svg className="animate-spin h-6 w-6 text-blue-500 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p className="text-sm text-gray-600">{t('app.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-6">
|
||||
<div className="mb-3">
|
||||
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 py-2">{t('market.noCategories')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{/* {tags.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<div className="flex items-center">
|
||||
<h3 className="font-medium text-gray-900">{t('market.tags')}</h3>
|
||||
<button
|
||||
onClick={toggleTagsVisibility}
|
||||
className="ml-2 p-1 text-gray-600 hover:text-blue-600 hover:bg-gray-100 rounded-full"
|
||||
aria-label={showTags ? t('market.hideTags') : t('market.showTags')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className={`h-5 w-5 transition-transform ${showTags ? 'rotate-180' : ''}`} viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M5.293 7.293a1 1 011.414 0L10 10.586l3.293-3.293a1 1 011.414 1.414l-4 4a1 1 01-1.414 0l-4-4a1 1 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{selectedTag && (
|
||||
<span className="text-xs text-blue-600 cursor-pointer hover:underline" onClick={() => filterByTag('')}>
|
||||
{t('market.clearTagFilter')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showTags && (
|
||||
<div className="flex flex-wrap gap-2 max-h-48 overflow-y-auto pr-2">
|
||||
{tags.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => handleTagClick(tag)}
|
||||
className={`px-2 py-1 rounded text-xs ${selectedTag === tag
|
||||
? 'bg-green-100 text-green-800 font-medium'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
#{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex-grow">
|
||||
{loading ? (
|
||||
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p className="text-gray-600">{t('app.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<p className="text-gray-600">{t('market.noServers')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{servers.map((server, index) => (
|
||||
<MarketServerCard
|
||||
key={index}
|
||||
server={server}
|
||||
onClick={handleServerClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="text-sm text-gray-500">
|
||||
{t('market.showing', {
|
||||
from: (currentPage - 1) * serversPerPage + 1,
|
||||
to: Math.min(currentPage * serversPerPage, allServers.length),
|
||||
total: allServers.length
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<label htmlFor="perPage" className="text-sm text-gray-600">
|
||||
{t('market.perPage')}:
|
||||
</label>
|
||||
<select
|
||||
id="perPage"
|
||||
value={serversPerPage}
|
||||
onChange={handleChangeItemsPerPage}
|
||||
className="border rounded p-1 text-sm"
|
||||
>
|
||||
<option value="6">6</option>
|
||||
<option value="9">9</option>
|
||||
<option value="12">12</option>
|
||||
<option value="24">24</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketPage;
|
||||
@@ -16,9 +16,11 @@ const ServersPage: React.FC = () => {
|
||||
handleServerAdd,
|
||||
handleServerEdit,
|
||||
handleServerRemove,
|
||||
handleServerToggle
|
||||
handleServerToggle,
|
||||
triggerRefresh
|
||||
} = useServerData();
|
||||
const [editingServer, setEditingServer] = useState<Server | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const handleEditClick = async (server: Server) => {
|
||||
const fullServerData = await handleServerEdit(server);
|
||||
@@ -31,6 +33,17 @@ const ServersPage: React.FC = () => {
|
||||
setEditingServer(null);
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
triggerRefresh();
|
||||
// Add a slight delay to make the spinner visible
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
@@ -38,12 +51,20 @@ const ServersPage: React.FC = () => {
|
||||
<div className="flex space-x-4">
|
||||
<AddServerForm onAdd={handleServerAdd} />
|
||||
<button
|
||||
onClick={() => handleServerAdd()}
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className={`px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center ${isRefreshing ? 'opacity-70 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<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>
|
||||
{isRefreshing ? (
|
||||
<svg className="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
{t('common.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,52 +1,146 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ChangePasswordForm from '@/components/ChangePasswordForm';
|
||||
import { Switch } from '@/components/ui/ToggleGroup';
|
||||
import { useSettingsData } from '@/hooks/useSettingsData';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { showToast } = useToast();
|
||||
const [currentLanguage, setCurrentLanguage] = useState(i18n.language);
|
||||
|
||||
// Update current language when it changes
|
||||
useEffect(() => {
|
||||
setCurrentLanguage(i18n.language);
|
||||
}, [i18n.language]);
|
||||
|
||||
const {
|
||||
routingConfig,
|
||||
loading,
|
||||
updateRoutingConfig
|
||||
} = useSettingsData();
|
||||
|
||||
const [sectionsVisible, setSectionsVisible] = useState({
|
||||
routingConfig: false,
|
||||
password: false
|
||||
});
|
||||
|
||||
const toggleSection = (section: 'routingConfig' | 'password') => {
|
||||
setSectionsVisible(prev => ({
|
||||
...prev,
|
||||
[section]: !prev[section]
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRoutingConfigChange = async (key: 'enableGlobalRoute' | 'enableGroupNameRoute', value: boolean) => {
|
||||
await updateRoutingConfig(key, value);
|
||||
};
|
||||
|
||||
const handlePasswordChangeSuccess = () => {
|
||||
setTimeout(() => {
|
||||
navigate('/');
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleLanguageChange = (lang: string) => {
|
||||
localStorage.setItem('i18nextLng', lang);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-8">{t('pages.settings.title')}</h1>
|
||||
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('auth.changePassword')}</h2>
|
||||
<div className="max-w-lg">
|
||||
<ChangePasswordForm onSuccess={handlePasswordChangeSuccess} />
|
||||
|
||||
{/* Language Settings */}
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-gray-800">{t('pages.settings.language')}</h2>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
className={`px-3 py-1.5 rounded-md transition-colors text-sm ${
|
||||
currentLanguage.startsWith('en')
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-blue-100 text-blue-800 hover:bg-blue-200'
|
||||
}`}
|
||||
onClick={() => handleLanguageChange('en')}
|
||||
>
|
||||
English
|
||||
</button>
|
||||
<button
|
||||
className={`px-3 py-1.5 rounded-md transition-colors text-sm ${
|
||||
currentLanguage.startsWith('zh')
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-blue-100 text-blue-800 hover:bg-blue-200'
|
||||
}`}
|
||||
onClick={() => handleLanguageChange('zh')}
|
||||
>
|
||||
中文
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 其他设置可以在这里添加 */}
|
||||
<div className="bg-white shadow rounded-lg p-6 mt-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('pages.settings.language')}</h2>
|
||||
<div className="flex space-x-4">
|
||||
<button
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200"
|
||||
onClick={() => {
|
||||
localStorage.setItem('i18nextLng', 'en');
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
English
|
||||
</button>
|
||||
<button
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200"
|
||||
onClick={() => {
|
||||
localStorage.setItem('i18nextLng', 'zh');
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
中文
|
||||
</button>
|
||||
|
||||
{/* Route Configuration Settings */}
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('routingConfig')}
|
||||
>
|
||||
<h2 className="text-xl font-semibold text-gray-800">{t('pages.settings.routeConfig')}</h2>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.routingConfig ? '▼' : '►'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.routingConfig && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableGlobalRoute')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.enableGlobalRouteDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableGlobalRoute}
|
||||
onCheckedChange={(checked) => handleRoutingConfigChange('enableGlobalRoute', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableGroupNameRoute')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.enableGroupNameRouteDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableGroupNameRoute}
|
||||
onCheckedChange={(checked) => handleRoutingConfigChange('enableGroupNameRoute', checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Change Password */}
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('password')}
|
||||
>
|
||||
<h2 className="text-xl font-semibold text-gray-800">{t('auth.changePassword')}</h2>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.password ? '▼' : '►'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.password && (
|
||||
<div className="max-w-lg mt-4">
|
||||
<ChangePasswordForm onSuccess={handlePasswordChangeSuccess} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,60 @@
|
||||
// Server status types
|
||||
export type ServerStatus = 'connecting' | 'connected' | 'disconnected';
|
||||
|
||||
// Market server types
|
||||
export interface MarketServerRepository {
|
||||
type: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface MarketServerAuthor {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface MarketServerInstallation {
|
||||
type: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface MarketServerArgument {
|
||||
description: string;
|
||||
required: boolean;
|
||||
example: string;
|
||||
}
|
||||
|
||||
export interface MarketServerExample {
|
||||
title: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export interface MarketServerTool {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface MarketServer {
|
||||
name: string;
|
||||
display_name: string;
|
||||
description: string;
|
||||
repository: MarketServerRepository;
|
||||
homepage: string;
|
||||
author: MarketServerAuthor;
|
||||
license: string;
|
||||
categories: string[];
|
||||
tags: string[];
|
||||
examples: MarketServerExample[];
|
||||
installations: {
|
||||
[key: string]: MarketServerInstallation;
|
||||
};
|
||||
arguments: Record<string, MarketServerArgument>;
|
||||
tools: MarketServerTool[];
|
||||
is_official?: boolean;
|
||||
}
|
||||
|
||||
// Tool input schema types
|
||||
export interface ToolInputSchema {
|
||||
type: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"amap-maps": {
|
||||
"amap": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
|
||||
74722
servers.json
Normal file
@@ -2,7 +2,7 @@ import { Request, Response } from 'express';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
import {
|
||||
getAllGroups,
|
||||
getGroupById,
|
||||
getGroupByIdOrName,
|
||||
createGroup,
|
||||
updateGroup,
|
||||
updateGroupServers,
|
||||
@@ -41,7 +41,7 @@ export const getGroup = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const group = getGroupById(id);
|
||||
const group = getGroupByIdOrName(id);
|
||||
if (!group) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -318,7 +318,7 @@ export const getGroupServers = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const group = getGroupById(id);
|
||||
const group = getGroupByIdOrName(id);
|
||||
if (!group) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
|
||||
154
src/controllers/marketController.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
import {
|
||||
getMarketServers,
|
||||
getMarketServerByName,
|
||||
getMarketCategories,
|
||||
getMarketTags,
|
||||
searchMarketServers,
|
||||
filterMarketServersByCategory,
|
||||
filterMarketServersByTag
|
||||
} from '../services/marketService.js';
|
||||
|
||||
// Get all market servers
|
||||
export const getAllMarketServers = (_: Request, res: Response): void => {
|
||||
try {
|
||||
const marketServers = Object.values(getMarketServers());
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: marketServers,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get market servers information',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get a specific market server by name
|
||||
export const getMarketServer = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
if (!name) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server name is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const server = getMarketServerByName(name);
|
||||
if (!server) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Market server not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: server,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get market server information',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get all market categories
|
||||
export const getAllMarketCategories = (_: Request, res: Response): void => {
|
||||
try {
|
||||
const categories = getMarketCategories();
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: categories,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get market categories',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get all market tags
|
||||
export const getAllMarketTags = (_: Request, res: Response): void => {
|
||||
try {
|
||||
const tags = getMarketTags();
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: tags,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get market tags',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Search market servers
|
||||
export const searchMarketServersByQuery = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { query } = req.query;
|
||||
const searchQuery = typeof query === 'string' ? query : '';
|
||||
|
||||
const servers = searchMarketServers(searchQuery);
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: servers,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to search market servers',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Filter market servers by category
|
||||
export const getMarketServersByCategory = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { category } = req.params;
|
||||
|
||||
const servers = filterMarketServersByCategory(category);
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: servers,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to filter market servers by category',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Filter market servers by tag
|
||||
export const getMarketServersByTag = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { tag } = req.params;
|
||||
|
||||
const servers = filterMarketServersByTag(tag);
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: servers,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to filter market servers by tag',
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
notifyToolChanged,
|
||||
toggleServerStatus,
|
||||
} from '../services/mcpService.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
|
||||
export const getAllServers = (_: Request, res: Response): void => {
|
||||
try {
|
||||
@@ -244,3 +244,60 @@ export const toggleServer = async (req: Request, res: Response): Promise<void> =
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { routing } = req.body;
|
||||
|
||||
if (!routing || (typeof routing.enableGlobalRoute !== 'boolean' && typeof routing.enableGroupNameRoute !== 'boolean')) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Invalid system configuration provided',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = loadSettings();
|
||||
if (!settings.systemConfig) {
|
||||
settings.systemConfig = {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!settings.systemConfig.routing) {
|
||||
settings.systemConfig.routing = {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof routing.enableGlobalRoute === 'boolean') {
|
||||
settings.systemConfig.routing.enableGlobalRoute = routing.enableGlobalRoute;
|
||||
}
|
||||
|
||||
if (typeof routing.enableGroupNameRoute === 'boolean') {
|
||||
settings.systemConfig.routing.enableGroupNameRoute = routing.enableGroupNameRoute;
|
||||
}
|
||||
|
||||
if (saveSettings(settings)) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: settings.systemConfig,
|
||||
message: 'System configuration updated successfully',
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to save system configuration',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal server error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import express from 'express';
|
||||
import { check } from 'express-validator';
|
||||
import path from 'path';
|
||||
import {
|
||||
getAllServers,
|
||||
getAllSettings,
|
||||
@@ -8,6 +7,7 @@ import {
|
||||
updateServer,
|
||||
deleteServer,
|
||||
toggleServer,
|
||||
updateSystemConfig
|
||||
} from '../controllers/serverController.js';
|
||||
import {
|
||||
getGroups,
|
||||
@@ -20,6 +20,15 @@ import {
|
||||
getGroupServers,
|
||||
updateGroupServersBatch
|
||||
} from '../controllers/groupController.js';
|
||||
import {
|
||||
getAllMarketServers,
|
||||
getMarketServer,
|
||||
getAllMarketCategories,
|
||||
getAllMarketTags,
|
||||
searchMarketServersByQuery,
|
||||
getMarketServersByCategory,
|
||||
getMarketServersByTag
|
||||
} from '../controllers/marketController.js';
|
||||
import {
|
||||
login,
|
||||
register,
|
||||
@@ -38,6 +47,7 @@ export const initRoutes = (app: express.Application): void => {
|
||||
router.put('/servers/:name', updateServer);
|
||||
router.delete('/servers/:name', deleteServer);
|
||||
router.post('/servers/:name/toggle', toggleServer);
|
||||
router.put('/system-config', updateSystemConfig);
|
||||
|
||||
// Group management routes
|
||||
router.get('/groups', getGroups);
|
||||
@@ -51,6 +61,15 @@ export const initRoutes = (app: express.Application): void => {
|
||||
// New route for batch updating servers in a group
|
||||
router.put('/groups/:id/servers/batch', updateGroupServersBatch);
|
||||
|
||||
// Market routes
|
||||
router.get('/market/servers', getAllMarketServers);
|
||||
router.get('/market/servers/search', searchMarketServersByQuery);
|
||||
router.get('/market/servers/:name', getMarketServer);
|
||||
router.get('/market/categories', getAllMarketCategories);
|
||||
router.get('/market/categories/:category', getMarketServersByCategory);
|
||||
router.get('/market/tags', getAllMarketTags);
|
||||
router.get('/market/tags/:tag', getMarketServersByTag);
|
||||
|
||||
// Auth routes (these will NOT be protected by auth middleware)
|
||||
app.post('/auth/login', [
|
||||
check('username', 'Username is required').not().isEmpty(),
|
||||
@@ -72,10 +91,6 @@ 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,5 +1,6 @@
|
||||
import express from 'express';
|
||||
import config from './config/index.js';
|
||||
import path from 'path';
|
||||
import { initMcpServer } from './services/mcpService.js';
|
||||
import { initMiddlewares } from './middlewares/index.js';
|
||||
import { initRoutes } from './routes/index.js';
|
||||
@@ -31,12 +32,17 @@ export class AppServer {
|
||||
initMcpServer(config.mcpHubName, config.mcpHubVersion)
|
||||
.then(() => {
|
||||
console.log('MCP server initialized successfully');
|
||||
this.app.get('/sse/:groupId?', (req, res) => handleSseConnection(req, res));
|
||||
this.app.get('/sse/:group?', (req, res) => handleSseConnection(req, res));
|
||||
this.app.post('/messages', handleSseMessage);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error initializing MCP server:', error);
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
this.app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(process.cwd(), 'frontend', 'dist', 'index.html'));
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error initializing server:', error);
|
||||
|
||||
@@ -9,10 +9,19 @@ export const getAllGroups = (): IGroup[] => {
|
||||
return settings.groups || [];
|
||||
};
|
||||
|
||||
// Get group by ID
|
||||
export const getGroupById = (id: string): IGroup | undefined => {
|
||||
// Get group by ID or name
|
||||
export const getGroupByIdOrName = (key: string): IGroup | undefined => {
|
||||
const settings = loadSettings();
|
||||
const routingConfig = settings.systemConfig?.routing || {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
};
|
||||
const groups = getAllGroups();
|
||||
return groups.find((group) => group.id === id);
|
||||
return (
|
||||
groups.find(
|
||||
(group) => group.id === key || (group.name === key && routingConfig.enableGroupNameRoute),
|
||||
) || undefined
|
||||
);
|
||||
};
|
||||
|
||||
// Create a new group
|
||||
@@ -218,6 +227,6 @@ export const removeServerFromGroup = (groupId: string, serverName: string): IGro
|
||||
|
||||
// Get all servers in a group
|
||||
export const getServersInGroup = (groupId: string): string[] => {
|
||||
const group = getGroupById(groupId);
|
||||
const group = getGroupByIdOrName(groupId);
|
||||
return group ? group.servers : [];
|
||||
};
|
||||
|
||||
103
src/services/marketService.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { MarketServer } from '../types/index.js';
|
||||
|
||||
// Get path to the servers.json file
|
||||
export const getServersJsonPath = (): string => {
|
||||
return path.resolve(process.cwd(), 'servers.json');
|
||||
};
|
||||
|
||||
// Load all market servers from servers.json
|
||||
export const getMarketServers = (): Record<string, MarketServer> => {
|
||||
try {
|
||||
const serversJsonPath = getServersJsonPath();
|
||||
const data = fs.readFileSync(serversJsonPath, 'utf8');
|
||||
return JSON.parse(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load servers from servers.json:', error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
// Get a specific market server by name
|
||||
export const getMarketServerByName = (name: string): MarketServer | null => {
|
||||
const servers = getMarketServers();
|
||||
return servers[name] || null;
|
||||
};
|
||||
|
||||
// Get all categories from market servers
|
||||
export const getMarketCategories = (): string[] => {
|
||||
const servers = getMarketServers();
|
||||
const categories = new Set<string>();
|
||||
|
||||
Object.values(servers).forEach((server) => {
|
||||
server.categories?.forEach((category) => {
|
||||
categories.add(category);
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(categories).sort();
|
||||
};
|
||||
|
||||
// Get all tags from market servers
|
||||
export const getMarketTags = (): string[] => {
|
||||
const servers = getMarketServers();
|
||||
const tags = new Set<string>();
|
||||
|
||||
Object.values(servers).forEach((server) => {
|
||||
server.tags?.forEach((tag) => {
|
||||
tags.add(tag);
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(tags).sort();
|
||||
};
|
||||
|
||||
// Search market servers by query
|
||||
export const searchMarketServers = (query: string): MarketServer[] => {
|
||||
const servers = getMarketServers();
|
||||
const searchTerms = query.toLowerCase().split(' ').filter(term => term.length > 0);
|
||||
|
||||
if (searchTerms.length === 0) {
|
||||
return Object.values(servers);
|
||||
}
|
||||
|
||||
return Object.values(servers).filter((server) => {
|
||||
// Search in name, display_name, description, categories, and tags
|
||||
const searchableText = [
|
||||
server.name,
|
||||
server.display_name,
|
||||
server.description,
|
||||
...(server.categories || []),
|
||||
...(server.tags || [])
|
||||
].join(' ').toLowerCase();
|
||||
|
||||
return searchTerms.some(term => searchableText.includes(term));
|
||||
});
|
||||
};
|
||||
|
||||
// Filter market servers by category
|
||||
export const filterMarketServersByCategory = (category: string): MarketServer[] => {
|
||||
const servers = getMarketServers();
|
||||
|
||||
if (!category) {
|
||||
return Object.values(servers);
|
||||
}
|
||||
|
||||
return Object.values(servers).filter((server) => {
|
||||
return server.categories?.includes(category);
|
||||
});
|
||||
};
|
||||
|
||||
// Filter market servers by tag
|
||||
export const filterMarketServersByTag = (tag: string): MarketServer[] => {
|
||||
const servers = getMarketServers();
|
||||
|
||||
if (!tag) {
|
||||
return Object.values(servers);
|
||||
}
|
||||
|
||||
return Object.values(servers).filter((server) => {
|
||||
return server.tags?.includes(tag);
|
||||
});
|
||||
};
|
||||
@@ -7,7 +7,7 @@ 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 { getGroup } from './sseService.js';
|
||||
import { getServersInGroup } from './groupService.js';
|
||||
|
||||
let currentServer: Server;
|
||||
@@ -316,12 +316,12 @@ 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 group = getGroup(sessionId);
|
||||
console.log(`Handling ListToolsRequest for group: ${group}`);
|
||||
const allServerInfos = serverInfos.filter((serverInfo) => {
|
||||
if (serverInfo.enabled === false) return false;
|
||||
if (!groupId) return true;
|
||||
const serversInGroup = getServersInGroup(groupId);
|
||||
if (!group) return true;
|
||||
const serversInGroup = getServersInGroup(group);
|
||||
return serversInGroup.includes(serverInfo.name);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,33 +1,48 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { getMcpServer } from './mcpService.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
|
||||
const transports: { [sessionId: string]: { transport: SSEServerTransport; groupId: string } } = {};
|
||||
const transports: { [sessionId: string]: { transport: SSEServerTransport; group: string } } = {};
|
||||
|
||||
export const getGroupId = (sessionId: string): string => {
|
||||
return transports[sessionId]?.groupId || '';
|
||||
export const getGroup = (sessionId: string): string => {
|
||||
return transports[sessionId]?.group || '';
|
||||
};
|
||||
|
||||
export const handleSseConnection = async (req: Request, res: Response): Promise<void> => {
|
||||
const settings = loadSettings();
|
||||
const routingConfig = settings.systemConfig?.routing || {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
};
|
||||
const group = req.params.group;
|
||||
|
||||
// Check if this is a global route (no group) and if it's allowed
|
||||
if (!group && !routingConfig.enableGlobalRoute) {
|
||||
res.status(403).send('Global routes are disabled. Please specify a group ID.');
|
||||
return;
|
||||
}
|
||||
|
||||
const transport = new SSEServerTransport('/messages', res);
|
||||
const groupId = req.params.groupId;
|
||||
transports[transport.sessionId] = { transport, groupId };
|
||||
transports[transport.sessionId] = { transport, group: group };
|
||||
|
||||
res.on('close', () => {
|
||||
delete transports[transport.sessionId];
|
||||
console.log(`SSE connection closed: ${transport.sessionId}`);
|
||||
});
|
||||
|
||||
console.log(`New SSE connection established: ${transport.sessionId}`);
|
||||
console.log(
|
||||
`New SSE connection established: ${transport.sessionId} with group: ${group || 'global'}`,
|
||||
);
|
||||
await getMcpServer().connect(transport);
|
||||
};
|
||||
|
||||
export const handleSseMessage = async (req: Request, res: Response): Promise<void> => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const { transport, groupId } = transports[sessionId];
|
||||
req.params.groupId = groupId;
|
||||
req.query.groupId = groupId;
|
||||
console.log(`Received message for sessionId: ${sessionId} in groupId: ${groupId}`);
|
||||
const { transport, group } = transports[sessionId];
|
||||
req.params.group = group;
|
||||
req.query.group = group;
|
||||
console.log(`Received message for sessionId: ${sessionId} in group: ${group}`);
|
||||
if (transport) {
|
||||
await transport.handlePostMessage(req, res);
|
||||
} else {
|
||||
|
||||
@@ -17,6 +17,60 @@ export interface IGroup {
|
||||
servers: string[]; // Array of server names that belong to this group
|
||||
}
|
||||
|
||||
// Market server types
|
||||
export interface MarketServerRepository {
|
||||
type: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface MarketServerAuthor {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface MarketServerInstallation {
|
||||
type: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface MarketServerArgument {
|
||||
description: string;
|
||||
required: boolean;
|
||||
example: string;
|
||||
}
|
||||
|
||||
export interface MarketServerExample {
|
||||
title: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export interface MarketServerTool {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface MarketServer {
|
||||
name: string;
|
||||
display_name: string;
|
||||
description: string;
|
||||
repository: MarketServerRepository;
|
||||
homepage: string;
|
||||
author: MarketServerAuthor;
|
||||
license: string;
|
||||
categories: string[];
|
||||
tags: string[];
|
||||
examples: MarketServerExample[];
|
||||
installations: {
|
||||
[key: string]: MarketServerInstallation;
|
||||
};
|
||||
arguments: Record<string, MarketServerArgument>;
|
||||
tools: MarketServerTool[];
|
||||
is_official?: boolean;
|
||||
}
|
||||
|
||||
// Represents the settings for MCP servers
|
||||
export interface McpSettings {
|
||||
users?: IUser[]; // Array of user credentials and permissions
|
||||
@@ -24,6 +78,13 @@ export interface McpSettings {
|
||||
[key: string]: ServerConfig; // Key-value pairs of server names and their configurations
|
||||
};
|
||||
groups?: IGroup[]; // Array of server groups
|
||||
systemConfig?: {
|
||||
routing?: {
|
||||
enableGlobalRoute?: boolean; // Controls whether the /sse endpoint without group is enabled
|
||||
enableGroupNameRoute?: boolean; // Controls whether group routing by name is allowed
|
||||
};
|
||||
// Add other system configuration sections here in the future
|
||||
};
|
||||
}
|
||||
|
||||
// Configuration details for an individual server
|
||||
|
||||