mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9aa4a9a08 | ||
|
|
48bcf9f5f0 | ||
|
|
f63f06d879 | ||
|
|
63b356b8d7 | ||
|
|
a6cea2ad3f | ||
|
|
5bb2715094 | ||
|
|
9b40f7e101 | ||
|
|
df872823c1 | ||
|
|
9304653c34 | ||
|
|
b5685b7010 | ||
|
|
89c37b2f02 | ||
|
|
c316cb896e | ||
|
|
bc3c8facfa | ||
|
|
69afb865c0 | ||
|
|
ba30d88840 | ||
|
|
6d0d622bd8 | ||
|
|
ab50c7e9eb | ||
|
|
e507bea2e3 | ||
|
|
0f00ad7200 | ||
|
|
b0b0c93337 | ||
|
|
20fd355b87 | ||
|
|
4388084704 | ||
|
|
fe2535461d | ||
|
|
985598e529 | ||
|
|
b2b6d0588b | ||
|
|
64628ee3ed | ||
|
|
66d4142039 | ||
|
|
cf72295f99 | ||
|
|
89f85c73ff | ||
|
|
adabf1d92b | ||
|
|
c3a6dfadb4 | ||
|
|
d119be0f82 | ||
|
|
1e308ec4c5 |
16
.coveragerc
Normal file
16
.coveragerc
Normal file
@@ -0,0 +1,16 @@
|
||||
# Test coverage configuration
|
||||
# This file tells Jest what to include/exclude from coverage reports
|
||||
|
||||
# Coverage patterns
|
||||
- "src/**/*.{ts,tsx}"
|
||||
|
||||
# Exclusions
|
||||
- "!src/**/*.d.ts"
|
||||
- "!src/index.ts"
|
||||
- "!src/**/__tests__/**"
|
||||
- "!src/**/*.test.{ts,tsx}"
|
||||
- "!src/**/*.spec.{ts,tsx}"
|
||||
- "!**/node_modules/**"
|
||||
- "!coverage/**"
|
||||
- "!dist/**"
|
||||
- "!build/**"
|
||||
50
.github/copilot-instructions.md
vendored
Normal file
50
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
# MCPHub Coding Instructions
|
||||
|
||||
## Project Overview
|
||||
|
||||
MCPHub is a TypeScript/Node.js MCP server management hub that provides unified access through HTTP endpoints.
|
||||
|
||||
**Core Components:**
|
||||
|
||||
- **Backend**: Express.js + TypeScript + ESM (`src/server.ts`)
|
||||
- **Frontend**: React/Vite + Tailwind CSS (`frontend/`)
|
||||
- **MCP Integration**: Connects multiple MCP servers (`src/services/mcpService.ts`)
|
||||
|
||||
## Development Environment
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev # Start both backend and frontend
|
||||
pnpm backend:dev # Backend only
|
||||
pnpm frontend:dev # Frontend only
|
||||
```
|
||||
|
||||
## Project Conventions
|
||||
|
||||
### File Structure
|
||||
|
||||
- `src/services/` - Core business logic
|
||||
- `src/controllers/` - HTTP request handlers
|
||||
- `src/types/index.ts` - TypeScript type definitions
|
||||
- `src/config/index.ts` - Configuration management
|
||||
|
||||
### Key Notes
|
||||
|
||||
- Use ESM modules: Import with `.js` extensions, not `.ts`
|
||||
- Configuration file: `mcp_settings.json`
|
||||
- Endpoint formats: `/mcp/{group|server}` and `/mcp/$smart`
|
||||
- All code comments must be written in English
|
||||
- Frontend uses i18n with resource files in `locales/` folder
|
||||
- Server-side code should use appropriate abstraction layers for extensibility and replaceability
|
||||
|
||||
## Development Process
|
||||
|
||||
- For complex features, implement step by step and wait for confirmation before proceeding to the next step
|
||||
- After implementing features, no separate summary documentation is needed - update README.md and README.zh.md as appropriate
|
||||
|
||||
### Development Entry Points
|
||||
|
||||
- **MCP Servers**: Modify `src/services/mcpService.ts`
|
||||
- **API Endpoints**: Add routes in `src/routes/`, controllers in `src/controllers/`
|
||||
- **Frontend Features**: Start from `frontend/src/pages/`
|
||||
- **Testing**: Follow existing patterns in `tests/`
|
||||
17
.github/workflows/build.yml
vendored
17
.github/workflows/build.yml
vendored
@@ -8,6 +8,9 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
matrix:
|
||||
variant: ${{ startsWith(github.ref, 'refs/tags/') && fromJSON('["base", "full"]') || fromJSON('["base"]') }}
|
||||
@@ -30,16 +33,27 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: endsWith(github.repository, 'mcphub')
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: endsWith(github.repository, 'mcphubx')
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: samanhappy/mcphub
|
||||
images: |
|
||||
${{ endsWith(github.repository, 'mcphub') && github.repository || '' }}
|
||||
${{ endsWith(github.repository, 'mcphubx') && format('ghcr.io/{0}', github.repository) || '' }}
|
||||
tags: |
|
||||
type=raw,value=edge${{ matrix.variant == 'full' && '-full' || '' }},enable=${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
|
||||
type=semver,pattern={{version}}${{ matrix.variant == 'full' && '-full' || '' }},enable=${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
@@ -48,6 +62,7 @@ jobs:
|
||||
latest=false
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
if: endsWith(github.repository, 'mcphub') || endsWith(github.repository, 'mcphubx')
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
|
||||
112
.github/workflows/ci.yml
vendored
Normal file
112
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,112 @@
|
||||
name: CI/CD Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run linter
|
||||
run: pnpm lint
|
||||
|
||||
- name: Run type checking
|
||||
run: pnpm backend:build
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test:ci
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
file: ./coverage/lcov.info
|
||||
flags: unittests
|
||||
name: codecov-umbrella
|
||||
|
||||
# build:
|
||||
# runs-on: ubuntu-latest
|
||||
# needs: test
|
||||
|
||||
# steps:
|
||||
# - name: Checkout code
|
||||
# uses: actions/checkout@v4
|
||||
|
||||
# - name: Setup Node.js
|
||||
# uses: actions/setup-node@v4
|
||||
# with:
|
||||
# node-version: '20.x'
|
||||
|
||||
# - name: Enable Corepack
|
||||
# run: corepack enable
|
||||
|
||||
# - name: Install dependencies
|
||||
# run: pnpm install --frozen-lockfile
|
||||
|
||||
# - name: Build application
|
||||
# run: pnpm build
|
||||
|
||||
# - name: Verify build artifacts
|
||||
# run: node scripts/verify-dist.js
|
||||
|
||||
# integration-test:
|
||||
# runs-on: ubuntu-latest
|
||||
# needs: test
|
||||
|
||||
# services:
|
||||
# postgres:
|
||||
# image: postgres:15
|
||||
# env:
|
||||
# POSTGRES_PASSWORD: postgres
|
||||
# POSTGRES_DB: mcphub_test
|
||||
# options: >-
|
||||
# --health-cmd pg_isready
|
||||
# --health-interval 10s
|
||||
# --health-timeout 5s
|
||||
# --health-retries 5
|
||||
|
||||
# steps:
|
||||
# - name: Checkout code
|
||||
# uses: actions/checkout@v4
|
||||
|
||||
# - name: Setup Node.js
|
||||
# uses: actions/setup-node@v4
|
||||
# with:
|
||||
# node-version: '20.x'
|
||||
|
||||
# - name: Enable Corepack
|
||||
# run: corepack enable
|
||||
|
||||
# - name: Install dependencies
|
||||
# run: pnpm install --frozen-lockfile
|
||||
|
||||
# - name: Build application
|
||||
# run: pnpm build
|
||||
|
||||
# - name: Run integration tests
|
||||
# run: |
|
||||
# export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/mcphub_test"
|
||||
# node test-integration.ts
|
||||
# env:
|
||||
# NODE_ENV: test
|
||||
1
.github/workflows/npm-publish.yml
vendored
1
.github/workflows/npm-publish.yml
vendored
@@ -7,6 +7,7 @@ on:
|
||||
jobs:
|
||||
publish-npm:
|
||||
runs-on: ubuntu-latest
|
||||
if: endsWith(github.repository, 'mcphub')
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -24,3 +24,5 @@ yarn-error.log*
|
||||
.vscode/
|
||||
*.log
|
||||
coverage/
|
||||
|
||||
data/
|
||||
@@ -21,6 +21,9 @@ ENV REQUEST_TIMEOUT=$REQUEST_TIMEOUT
|
||||
ARG BASE_PATH=""
|
||||
ENV BASE_PATH=$BASE_PATH
|
||||
|
||||
ARG READONLY=false
|
||||
ENV READONLY=$READONLY
|
||||
|
||||
ENV PNPM_HOME=/usr/local/share/pnpm
|
||||
ENV PATH=$PNPM_HOME:$PATH
|
||||
RUN mkdir -p $PNPM_HOME && \
|
||||
|
||||
@@ -57,7 +57,7 @@ Create a `mcp_settings.json` file to customize your server settings:
|
||||
**Recommended**: Mount your custom config:
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 -v $(pwd)/mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
|
||||
docker run -p 3000:3000 -v ./mcp_settings.json:/app/mcp_settings.json -v ./data:/app/data samanhappy/mcphub
|
||||
```
|
||||
|
||||
or run with default settings:
|
||||
|
||||
@@ -57,7 +57,7 @@ MCPHub 通过将多个 MCP(Model Context Protocol)服务器组织为灵活
|
||||
**推荐**:挂载自定义配置:
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 -v $(pwd)/mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
|
||||
docker run -p 3000:3000 -v ./mcp_settings.json:/app/mcp_settings.json -v ./data:/app/data samanhappy/mcphub
|
||||
```
|
||||
|
||||
或使用默认配置运行:
|
||||
|
||||
172
docs/testing-framework.md
Normal file
172
docs/testing-framework.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# 测试框架和自动化测试实现报告
|
||||
|
||||
## 概述
|
||||
|
||||
本项目已成功引入现代化的测试框架和自动化测试流程。实现了基于Jest的测试环境,支持TypeScript、ES模块,并包含完整的CI/CD配置。
|
||||
|
||||
## 已实现的功能
|
||||
|
||||
### 1. 测试框架配置
|
||||
|
||||
- **Jest配置**: 使用`jest.config.cjs`配置文件,支持ES模块和TypeScript
|
||||
- **覆盖率报告**: 配置了代码覆盖率收集和报告
|
||||
- **测试环境**: 支持Node.js环境的单元测试和集成测试
|
||||
- **模块映射**: 配置了路径别名支持
|
||||
|
||||
### 2. 测试工具和辅助函数
|
||||
|
||||
创建了完善的测试工具库 (`tests/utils/testHelpers.ts`):
|
||||
|
||||
- **认证工具**: JWT token生成和管理
|
||||
- **HTTP测试**: Supertest集成用于API测试
|
||||
- **数据生成**: 测试数据工厂函数
|
||||
- **响应断言**: 自定义API响应验证器
|
||||
- **环境管理**: 测试环境变量配置
|
||||
|
||||
### 3. 测试用例实现
|
||||
|
||||
已实现的测试场景:
|
||||
|
||||
#### 基础配置测试 (`tests/basic.test.ts`)
|
||||
- Jest配置验证
|
||||
- 异步操作支持测试
|
||||
- 自定义匹配器验证
|
||||
|
||||
#### 认证逻辑测试 (`tests/auth.logic.test.ts`)
|
||||
- 用户登录逻辑
|
||||
- 密码验证
|
||||
- JWT生成和验证
|
||||
- 错误处理场景
|
||||
- 用户数据验证
|
||||
|
||||
#### 路径工具测试 (`tests/utils/pathLogic.test.ts`)
|
||||
- 配置文件路径解析
|
||||
- 环境变量处理
|
||||
- 文件系统操作
|
||||
- 错误处理和边界条件
|
||||
- 跨平台路径处理
|
||||
|
||||
### 4. CI/CD配置
|
||||
|
||||
GitHub Actions配置 (`.github/workflows/ci.yml`):
|
||||
|
||||
- **多Node.js版本支持**: 18.x和20.x
|
||||
- **自动化测试流程**:
|
||||
- 代码检查 (ESLint)
|
||||
- 类型检查 (TypeScript)
|
||||
- 单元测试执行
|
||||
- 覆盖率报告
|
||||
- **构建验证**: 应用构建和产物验证
|
||||
- **集成测试**: 包含数据库环境的集成测试
|
||||
|
||||
### 5. 测试脚本
|
||||
|
||||
在`package.json`中添加的测试命令:
|
||||
|
||||
```json
|
||||
{
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"test:verbose": "jest --verbose",
|
||||
"test:ci": "jest --ci --coverage --watchAll=false"
|
||||
}
|
||||
```
|
||||
|
||||
## 测试结果
|
||||
|
||||
当前测试统计:
|
||||
- **测试套件**: 3个
|
||||
- **测试用例**: 19个
|
||||
- **通过率**: 100%
|
||||
- **执行时间**: ~15秒
|
||||
|
||||
### 测试覆盖的功能模块
|
||||
|
||||
1. **认证系统**: 用户登录、JWT处理、密码验证
|
||||
2. **配置管理**: 文件路径解析、环境变量处理
|
||||
3. **基础设施**: Jest配置、测试工具验证
|
||||
|
||||
## 技术特点
|
||||
|
||||
### 现代化特性
|
||||
|
||||
- **ES模块支持**: 完全支持ES2022模块语法
|
||||
- **TypeScript集成**: 类型安全的测试编写
|
||||
- **异步测试**: Promise和async/await支持
|
||||
- **模拟系统**: Jest mock功能的深度使用
|
||||
- **参数化测试**: 数据驱动的测试用例
|
||||
|
||||
### 最佳实践
|
||||
|
||||
- **测试隔离**: 每个测试用例独立运行
|
||||
- **Mock管理**: 统一的mock清理和重置
|
||||
- **错误处理**: 完整的错误场景测试
|
||||
- **边界测试**: 输入验证和边界条件覆盖
|
||||
- **文档化**: 清晰的测试用例命名和描述
|
||||
|
||||
## 后续扩展计划
|
||||
|
||||
### 短期目标
|
||||
|
||||
1. **API测试**: 为REST API端点添加集成测试
|
||||
2. **数据库测试**: 添加数据模型和存储层测试
|
||||
3. **中间件测试**: 认证和权限中间件测试
|
||||
4. **服务层测试**: 核心业务逻辑测试
|
||||
|
||||
### 中期目标
|
||||
|
||||
1. **端到端测试**: 使用Playwright或Cypress
|
||||
2. **性能测试**: API响应时间和负载测试
|
||||
3. **安全测试**: 输入验证和安全漏洞测试
|
||||
4. **契约测试**: API契约验证
|
||||
|
||||
### 长期目标
|
||||
|
||||
1. **测试数据管理**: 测试数据库和fixture管理
|
||||
2. **视觉回归测试**: UI组件的视觉测试
|
||||
3. **监控集成**: 生产环境测试监控
|
||||
4. **自动化测试报告**: 详细的测试报告和趋势分析
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 添加新测试用例
|
||||
|
||||
1. 在`tests/`目录下创建对应的测试文件
|
||||
2. 使用`testHelpers.ts`中的工具函数
|
||||
3. 遵循命名约定: `*.test.ts`或`*.spec.ts`
|
||||
4. 确保测试用例具有清晰的描述和断言
|
||||
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
# 运行所有测试
|
||||
pnpm test
|
||||
|
||||
# 监听模式
|
||||
pnpm test:watch
|
||||
|
||||
# 生成覆盖率报告
|
||||
pnpm test:coverage
|
||||
|
||||
# CI模式运行
|
||||
pnpm test:ci
|
||||
```
|
||||
|
||||
### Mock最佳实践
|
||||
|
||||
- 在`beforeEach`中清理所有mock
|
||||
- 使用具体的mock实现而不是空函数
|
||||
- 验证mock被正确调用
|
||||
- 保持mock的一致性和可维护性
|
||||
|
||||
## 结论
|
||||
|
||||
本项目已成功建立了完整的现代化测试框架,具备以下优势:
|
||||
|
||||
1. **高度可扩展**: 易于添加新的测试用例和测试类型
|
||||
2. **开发友好**: 丰富的工具函数和清晰的结构
|
||||
3. **CI/CD就绪**: 完整的自动化流水线配置
|
||||
4. **质量保证**: 代码覆盖率和持续测试验证
|
||||
|
||||
这个测试框架为项目的持续发展和质量保证提供了坚实的基础,支持敏捷开发和持续集成的最佳实践。
|
||||
@@ -1,13 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MCP Hub Dashboard</title>
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-100">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -9,6 +9,7 @@ import LoginPage from './pages/LoginPage';
|
||||
import DashboardPage from './pages/Dashboard';
|
||||
import ServersPage from './pages/ServersPage';
|
||||
import GroupsPage from './pages/GroupsPage';
|
||||
import UsersPage from './pages/UsersPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import MarketPage from './pages/MarketPage';
|
||||
import LogsPage from './pages/LogsPage';
|
||||
@@ -31,6 +32,7 @@ function App() {
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/servers" element={<ServersPage />} />
|
||||
<Route path="/groups" element={<GroupsPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/market" element={<MarketPage />} />
|
||||
<Route path="/market/:serverName" element={<MarketPage />} />
|
||||
<Route path="/logs" element={<LogsPage />} />
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useGroupData } from '@/hooks/useGroupData'
|
||||
import { useServerData } from '@/hooks/useServerData'
|
||||
import { GroupFormData, Server } from '@/types'
|
||||
import { ToggleGroup } from './ui/ToggleGroup'
|
||||
import { GroupFormData, Server, IGroupServerConfig } from '@/types'
|
||||
import { ServerToolConfig } from './ServerToolConfig'
|
||||
|
||||
interface AddGroupFormProps {
|
||||
onAdd: () => void
|
||||
@@ -21,7 +21,7 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
|
||||
const [formData, setFormData] = useState<GroupFormData>({
|
||||
name: '',
|
||||
description: '',
|
||||
servers: []
|
||||
servers: [] as IGroupServerConfig[]
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,9 +50,8 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
|
||||
}
|
||||
|
||||
const result = await createGroup(formData.name, formData.description, formData.servers)
|
||||
|
||||
if (!result) {
|
||||
setError(t('groups.createError'))
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('groups.createError'))
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
@@ -66,64 +65,68 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
|
||||
<div className="p-6">
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-2xl w-full max-h-[90vh] flex flex-col">
|
||||
<div className="p-6 flex-shrink-0">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('groups.addNew')}</h2>
|
||||
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded-md border border-gray-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="name">
|
||||
{t('groups.name')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
placeholder={t('groups.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ToggleGroup
|
||||
className="mb-6"
|
||||
label={t('groups.servers')}
|
||||
noOptionsText={t('groups.noServerOptions')}
|
||||
values={formData.servers}
|
||||
options={availableServers.map(server => ({
|
||||
value: server.name,
|
||||
label: server.name
|
||||
}))}
|
||||
onChange={(servers) => setFormData(prev => ({ ...prev, servers }))}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? t('common.submitting') : t('common.create')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex-1 overflow-y-auto px-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="name">
|
||||
{t('groups.name')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder={t('groups.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2">
|
||||
{t('groups.configureTools')}
|
||||
</label>
|
||||
<ServerToolConfig
|
||||
servers={availableServers}
|
||||
value={formData.servers as IGroupServerConfig[]}
|
||||
onChange={(servers) => setFormData(prev => ({ ...prev, servers }))}
|
||||
className="border border-gray-200 rounded-lg p-4 bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 p-6 pt-4 border-t border-gray-200 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:opacity-50 transition-colors"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? t('common.submitting') : t('common.create')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ServerForm from './ServerForm'
|
||||
import { getApiUrl } from '../utils/runtime';
|
||||
import { apiPost } from '../utils/fetchInterceptor'
|
||||
import { detectVariables } from '../utils/variableDetection'
|
||||
|
||||
interface AddServerFormProps {
|
||||
onAdd: () => void
|
||||
@@ -11,35 +12,34 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [confirmationVisible, setConfirmationVisible] = useState(false)
|
||||
const [pendingPayload, setPendingPayload] = useState<any>(null)
|
||||
const [detectedVariables, setDetectedVariables] = useState<string[]>([])
|
||||
|
||||
const toggleModal = () => {
|
||||
setModalVisible(!modalVisible)
|
||||
setError(null) // Clear any previous errors when toggling modal
|
||||
setConfirmationVisible(false) // Close confirmation dialog
|
||||
setPendingPayload(null) // Clear pending payload
|
||||
}
|
||||
|
||||
const handleSubmit = async (payload: any) => {
|
||||
const handleConfirmSubmit = async () => {
|
||||
if (pendingPayload) {
|
||||
await submitServer(pendingPayload)
|
||||
setConfirmationVisible(false)
|
||||
setPendingPayload(null)
|
||||
}
|
||||
}
|
||||
|
||||
const submitServer = async (payload: any) => {
|
||||
try {
|
||||
setError(null)
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/servers'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || ''
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const result = await apiPost('/servers', payload)
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
if (!result.success) {
|
||||
// Use specific error message from the response if available
|
||||
if (result && result.message) {
|
||||
setError(result.message)
|
||||
} else if (response.status === 400) {
|
||||
setError(t('server.invalidData'))
|
||||
} else if (response.status === 409) {
|
||||
setError(t('server.alreadyExists', { serverName: payload.name }))
|
||||
} else {
|
||||
setError(t('server.addError'))
|
||||
}
|
||||
@@ -65,11 +65,31 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (payload: any) => {
|
||||
try {
|
||||
// Check for variables in the payload
|
||||
const variables = detectVariables(payload)
|
||||
|
||||
if (variables.length > 0) {
|
||||
// Show confirmation dialog
|
||||
setDetectedVariables(variables)
|
||||
setPendingPayload(payload)
|
||||
setConfirmationVisible(true)
|
||||
} else {
|
||||
// Submit directly if no variables found
|
||||
await submitServer(payload)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error processing server submission:', err)
|
||||
setError(t('errors.serverAdd'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={toggleModal}
|
||||
className="w-full bg-blue-100 text-blue-800 rounded hover:bg-blue-200 py-2 px-4 flex items-center justify-center"
|
||||
className="w-full bg-blue-100 text-blue-800 rounded hover:bg-blue-200 py-2 px-4 flex items-center justify-center btn-primary"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
|
||||
@@ -87,6 +107,60 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmationVisible && (
|
||||
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
{t('server.confirmVariables')}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{t('server.variablesDetected')}
|
||||
</p>
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 mb-4">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h4 className="text-sm font-medium text-yellow-800">
|
||||
{t('server.detectedVariables')}:
|
||||
</h4>
|
||||
<ul className="mt-1 text-sm text-yellow-700">
|
||||
{detectedVariables.map((variable, index) => (
|
||||
<li key={index} className="font-mono">
|
||||
${`{${variable}}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm mb-6">
|
||||
{t('server.confirmVariablesMessage')}
|
||||
</p>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setConfirmationVisible(false)
|
||||
setPendingPayload(null)
|
||||
}}
|
||||
className="px-4 py-2 text-gray-600 border border-gray-300 rounded hover:bg-gray-50 btn-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmSubmit}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 btn-primary"
|
||||
>
|
||||
{t('server.confirmAndAdd')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,17 +31,17 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
|
||||
// Validate passwords match
|
||||
if (formData.newPassword !== confirmPassword) {
|
||||
setError(t('auth.passwordsNotMatch'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await changePassword(formData);
|
||||
|
||||
|
||||
if (response.success) {
|
||||
setSuccess(true);
|
||||
if (onSuccess) {
|
||||
@@ -60,7 +60,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
return (
|
||||
<div className="p-6 bg-white rounded-lg shadow-md">
|
||||
<h2 className="text-xl font-bold mb-4">{t('auth.changePassword')}</h2>
|
||||
|
||||
|
||||
{success ? (
|
||||
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
|
||||
{t('auth.changePasswordSuccess')}
|
||||
@@ -72,7 +72,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="currentPassword">
|
||||
{t('auth.currentPassword')}
|
||||
@@ -81,13 +81,13 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
type="password"
|
||||
id="currentPassword"
|
||||
name="currentPassword"
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
|
||||
value={formData.currentPassword}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="newPassword">
|
||||
{t('auth.newPassword')}
|
||||
@@ -96,14 +96,14 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
type="password"
|
||||
id="newPassword"
|
||||
name="newPassword"
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
|
||||
value={formData.newPassword}
|
||||
onChange={handleChange}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="confirmPassword">
|
||||
{t('auth.confirmPassword')}
|
||||
@@ -112,14 +112,14 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
|
||||
value={confirmPassword}
|
||||
onChange={handleChange}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
{onCancel && (
|
||||
<button
|
||||
@@ -134,7 +134,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
className="py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 btn-primary"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center">
|
||||
|
||||
394
frontend/src/components/DxtUploadForm.tsx
Normal file
394
frontend/src/components/DxtUploadForm.tsx
Normal file
@@ -0,0 +1,394 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { apiPost, apiGet, apiPut, fetchWithInterceptors } from '@/utils/fetchInterceptor';
|
||||
import { getApiUrl } from '@/utils/runtime';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
|
||||
interface DxtUploadFormProps {
|
||||
onSuccess: (serverConfig: any) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface DxtUploadResponse {
|
||||
success: boolean;
|
||||
data?: {
|
||||
manifest: any;
|
||||
extractDir: string;
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const DxtUploadForm: React.FC<DxtUploadFormProps> = ({ onSuccess, onCancel }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [showServerForm, setShowServerForm] = useState(false);
|
||||
const [manifestData, setManifestData] = useState<any>(null);
|
||||
const [extractDir, setExtractDir] = useState<string>('');
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [pendingServerName, setPendingServerName] = useState<string>('');
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
const file = files[0];
|
||||
if (file.name.endsWith('.dxt')) {
|
||||
setSelectedFile(file);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(t('dxt.invalidFileType'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
if (file.name.endsWith('.dxt')) {
|
||||
setSelectedFile(file);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(t('dxt.invalidFileType'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile) {
|
||||
setError(t('dxt.noFileSelected'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('dxtFile', selectedFile);
|
||||
|
||||
const response = await fetchWithInterceptors(getApiUrl('/dxt/upload'), {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const result: DxtUploadResponse = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || `HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
if (result.success && result.data) {
|
||||
setManifestData(result.data.manifest);
|
||||
setExtractDir(result.data.extractDir);
|
||||
setShowServerForm(true);
|
||||
} else {
|
||||
throw new Error(result.message || t('dxt.uploadFailed'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('DXT upload error:', err);
|
||||
setError(err instanceof Error ? err.message : t('dxt.uploadFailed'));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstallServer = async (serverName: string, forceOverride: boolean = false) => {
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Convert DXT manifest to MCPHub stdio server configuration
|
||||
const serverConfig = convertDxtToMcpConfig(manifestData, extractDir, serverName);
|
||||
|
||||
// First, check if server exists
|
||||
if (!forceOverride) {
|
||||
const checkResult = await apiGet('/servers');
|
||||
|
||||
if (checkResult.success) {
|
||||
const existingServer = checkResult.data?.find((server: any) => server.name === serverName);
|
||||
|
||||
if (existingServer) {
|
||||
// Server exists, show confirmation dialog
|
||||
setPendingServerName(serverName);
|
||||
setShowConfirmDialog(true);
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Install or override the server
|
||||
let result;
|
||||
if (forceOverride) {
|
||||
result = await apiPut(`/servers/${encodeURIComponent(serverName)}`, {
|
||||
name: serverName,
|
||||
config: serverConfig,
|
||||
});
|
||||
} else {
|
||||
result = await apiPost('/servers', {
|
||||
name: serverName,
|
||||
config: serverConfig,
|
||||
});
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
onSuccess(serverConfig);
|
||||
} else {
|
||||
throw new Error(result.message || t('dxt.installFailed'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('DXT install error:', err);
|
||||
setError(err instanceof Error ? err.message : t('dxt.installFailed'));
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmOverride = () => {
|
||||
setShowConfirmDialog(false);
|
||||
if (pendingServerName) {
|
||||
handleInstallServer(pendingServerName, true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelOverride = () => {
|
||||
setShowConfirmDialog(false);
|
||||
setPendingServerName('');
|
||||
setIsUploading(false);
|
||||
};
|
||||
|
||||
const convertDxtToMcpConfig = (manifest: any, extractPath: string, _serverName: string) => {
|
||||
const mcpConfig = manifest.server?.mcp_config || {};
|
||||
|
||||
// Convert DXT manifest to MCPHub stdio configuration
|
||||
const config: any = {
|
||||
type: 'stdio',
|
||||
command: mcpConfig.command || 'node',
|
||||
args: (mcpConfig.args || []).map((arg: string) =>
|
||||
arg.replace('${__dirname}', extractPath)
|
||||
),
|
||||
};
|
||||
|
||||
// Add environment variables if they exist
|
||||
if (mcpConfig.env && Object.keys(mcpConfig.env).length > 0) {
|
||||
config.env = { ...mcpConfig.env };
|
||||
|
||||
// Replace ${__dirname} in environment variables
|
||||
Object.keys(config.env).forEach(key => {
|
||||
if (typeof config.env[key] === 'string') {
|
||||
config.env[key] = config.env[key].replace('${__dirname}', extractPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
if (showServerForm && manifestData) {
|
||||
return (
|
||||
<>
|
||||
<ConfirmDialog
|
||||
isOpen={showConfirmDialog}
|
||||
onClose={handleCancelOverride}
|
||||
onConfirm={handleConfirmOverride}
|
||||
title={t('dxt.serverExistsTitle')}
|
||||
message={t('dxt.serverExistsConfirm', { serverName: pendingServerName })}
|
||||
confirmText={t('dxt.override')}
|
||||
cancelText={t('common.cancel')}
|
||||
variant="warning"
|
||||
/>
|
||||
|
||||
<div className={`fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 ${showConfirmDialog ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
<div className="bg-white shadow rounded-lg p-6 w-full max-w-2xl max-h-screen overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900">{t('dxt.installServer')}</h2>
|
||||
<button onClick={onCancel} className="text-gray-500 hover:text-gray-700">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4 rounded">
|
||||
<p className="text-red-700">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Extension Info */}
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<h3 className="font-medium text-gray-900 mb-2">{t('dxt.extensionInfo')}</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div><strong>{t('dxt.name')}:</strong> {manifestData.display_name || manifestData.name}</div>
|
||||
<div><strong>{t('dxt.version')}:</strong> {manifestData.version}</div>
|
||||
<div><strong>{t('dxt.description')}:</strong> {manifestData.description}</div>
|
||||
{manifestData.author && (
|
||||
<div><strong>{t('dxt.author')}:</strong> {manifestData.author.name}</div>
|
||||
)}
|
||||
{manifestData.tools && manifestData.tools.length > 0 && (
|
||||
<div>
|
||||
<strong>{t('dxt.tools')}:</strong>
|
||||
<ul className="list-disc list-inside ml-4">
|
||||
{manifestData.tools.map((tool: any, index: number) => (
|
||||
<li key={index}>{tool.name} - {tool.description}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server Configuration */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{t('dxt.serverName')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="serverName"
|
||||
defaultValue={manifestData.name}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
|
||||
placeholder={t('dxt.serverNamePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end space-x-4">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={isUploading}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50 btn-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const nameInput = document.getElementById('serverName') as HTMLInputElement;
|
||||
const serverName = nameInput?.value.trim() || manifestData.name;
|
||||
handleInstallServer(serverName);
|
||||
}}
|
||||
disabled={isUploading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center btn-primary"
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{t('dxt.installing')}
|
||||
</>
|
||||
) : (
|
||||
t('dxt.install')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white shadow rounded-lg p-6 w-full max-w-lg">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900">{t('dxt.uploadTitle')}</h2>
|
||||
<button onClick={onCancel} className="text-gray-500 hover:text-gray-700">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4 rounded">
|
||||
<p className="text-red-700">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File Drop Zone */}
|
||||
<div
|
||||
className={`relative border-2 border-dashed rounded-lg p-8 text-center transition-colors ${isDragging
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: selectedFile
|
||||
? 'border-gray-500 '
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{selectedFile ? (
|
||||
<div className="space-y-2">
|
||||
<svg className="mx-auto h-12 w-12 text-green-200" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="text-sm text-gray-900 font-medium">{selectedFile.name}</p>
|
||||
<p className="text-xs text-gray-500">{(selectedFile.size / 1024 / 1024).toFixed(2)} MB</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-sm text-gray-900">{t('dxt.dropFileHere')}</p>
|
||||
<p className="text-xs text-gray-500">{t('dxt.orClickToSelect')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept=".dxt"
|
||||
onChange={handleFileSelect}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end space-x-4">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={isUploading}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50 btn-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedFile || isUploading}
|
||||
className="px-4 py-2 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center btn-primary"
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{t('dxt.uploading')}
|
||||
</>
|
||||
) : (
|
||||
t('dxt.upload')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DxtUploadForm;
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Group, GroupFormData, Server } from '@/types'
|
||||
import { Group, GroupFormData, Server, IGroupServerConfig } from '@/types'
|
||||
import { useGroupData } from '@/hooks/useGroupData'
|
||||
import { useServerData } from '@/hooks/useServerData'
|
||||
import { ToggleGroup } from './ui/ToggleGroup'
|
||||
import { ServerToolConfig } from './ServerToolConfig'
|
||||
|
||||
interface EditGroupFormProps {
|
||||
group: Group
|
||||
@@ -38,18 +38,6 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
|
||||
}))
|
||||
}
|
||||
|
||||
const handleServerToggle = (serverName: string) => {
|
||||
setFormData(prev => {
|
||||
const isSelected = prev.servers.includes(serverName)
|
||||
return {
|
||||
...prev,
|
||||
servers: isSelected
|
||||
? prev.servers.filter(name => name !== serverName)
|
||||
: [...prev.servers, serverName]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
@@ -67,9 +55,9 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
|
||||
description: formData.description,
|
||||
servers: formData.servers
|
||||
})
|
||||
|
||||
if (!result) {
|
||||
setError(t('groups.updateError'))
|
||||
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('groups.updateError'))
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
@@ -83,64 +71,68 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
|
||||
<div className="p-6">
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-2xl w-full max-h-[90vh] flex flex-col">
|
||||
<div className="p-6 flex-shrink-0">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('groups.edit')}</h2>
|
||||
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded-md border border-gray-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="name">
|
||||
{t('groups.name')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
placeholder={t('groups.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ToggleGroup
|
||||
className="mb-6"
|
||||
label={t('groups.servers')}
|
||||
noOptionsText={t('groups.noServerOptions')}
|
||||
values={formData.servers}
|
||||
options={availableServers.map(server => ({
|
||||
value: server.name,
|
||||
label: server.name
|
||||
}))}
|
||||
onChange={(servers) => setFormData(prev => ({ ...prev, servers }))}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? t('common.submitting') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex-1 overflow-y-auto px-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="name">
|
||||
{t('groups.name')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder={t('groups.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2">
|
||||
{t('groups.configureTools')}
|
||||
</label>
|
||||
<ServerToolConfig
|
||||
servers={availableServers}
|
||||
value={formData.servers as IGroupServerConfig[]}
|
||||
onChange={(servers) => setFormData(prev => ({ ...prev, servers }))}
|
||||
className="border border-gray-200 rounded-lg p-4 bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 p-6 pt-4 border-t border-gray-200 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:opacity-50 transition-colors"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? t('common.submitting') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Server } from '@/types'
|
||||
import { getApiUrl } from '../utils/runtime'
|
||||
import { apiPut } from '../utils/fetchInterceptor'
|
||||
import ServerForm from './ServerForm'
|
||||
|
||||
interface EditServerFormProps {
|
||||
@@ -17,26 +17,12 @@ const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => {
|
||||
const handleSubmit = async (payload: any) => {
|
||||
try {
|
||||
setError(null)
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl(`/servers/${server.name}`), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || ''
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const result = await apiPut(`/servers/${server.name}`, payload)
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
if (!result.success) {
|
||||
// Use specific error message from the response if available
|
||||
if (result && result.message) {
|
||||
setError(result.message)
|
||||
} else if (response.status === 404) {
|
||||
setError(t('server.notFound', { serverName: server.name }))
|
||||
} else if (response.status === 400) {
|
||||
setError(t('server.invalidData'))
|
||||
} else {
|
||||
setError(t('server.updateError', { serverName: server.name }))
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Group, Server } from '@/types'
|
||||
import { Edit, Trash, Copy, Check } from '@/components/icons/LucideIcons'
|
||||
import { Group, Server, IGroupServerConfig } from '@/types'
|
||||
import { Edit, Trash, Copy, Check, Link, FileCode, DropdownIcon, Wrench } from '@/components/icons/LucideIcons'
|
||||
import DeleteDialog from '@/components/ui/DeleteDialog'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
import { useSettingsData } from '@/hooks/useSettingsData'
|
||||
|
||||
interface GroupCardProps {
|
||||
group: Group
|
||||
@@ -20,8 +21,26 @@ const GroupCard = ({
|
||||
}: GroupCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { showToast } = useToast()
|
||||
const { installConfig } = useSettingsData()
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [showCopyDropdown, setShowCopyDropdown] = useState(false)
|
||||
const [expandedServer, setExpandedServer] = useState<string | null>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setShowCopyDropdown(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleEdit = () => {
|
||||
onEdit(group)
|
||||
@@ -36,16 +55,18 @@ const GroupCard = ({
|
||||
setShowDeleteDialog(false)
|
||||
}
|
||||
|
||||
const copyToClipboard = () => {
|
||||
const copyToClipboard = (text: string) => {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(group.id).then(() => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true)
|
||||
setShowCopyDropdown(false)
|
||||
showToast(t('common.copySuccess'), 'success')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
} else {
|
||||
// Fallback for HTTP or unsupported clipboard API
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = group.id
|
||||
textArea.value = text
|
||||
// Avoid scrolling to bottom
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-9999px'
|
||||
@@ -55,6 +76,8 @@ const GroupCard = ({
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
setCopied(true)
|
||||
setShowCopyDropdown(false)
|
||||
showToast(t('common.copySuccess'), 'success')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (err) {
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error')
|
||||
@@ -64,24 +87,92 @@ const GroupCard = ({
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyId = () => {
|
||||
copyToClipboard(group.id)
|
||||
}
|
||||
|
||||
const handleCopyUrl = () => {
|
||||
copyToClipboard(`${installConfig.baseUrl}/mcp/${group.id}`)
|
||||
}
|
||||
|
||||
const handleCopyJson = () => {
|
||||
const jsonConfig = {
|
||||
mcpServers: {
|
||||
mcphub: {
|
||||
url: `${installConfig.baseUrl}/mcp/${group.id}`,
|
||||
headers: {
|
||||
Authorization: "Bearer <your-access-token>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
copyToClipboard(JSON.stringify(jsonConfig, null, 2))
|
||||
}
|
||||
|
||||
// Helper function to normalize group servers to get server names
|
||||
const getServerNames = (servers: string[] | IGroupServerConfig[]): string[] => {
|
||||
return servers.map(server => typeof server === 'string' ? server : server.name);
|
||||
};
|
||||
|
||||
// Helper function to get server configuration
|
||||
const getServerConfig = (serverName: string): IGroupServerConfig | undefined => {
|
||||
const server = group.servers.find(s =>
|
||||
typeof s === 'string' ? s === serverName : s.name === serverName
|
||||
);
|
||||
if (typeof server === 'string') {
|
||||
return { name: server, tools: 'all' };
|
||||
}
|
||||
return server;
|
||||
};
|
||||
|
||||
// Get servers that belong to this group
|
||||
const groupServers = servers.filter(server => group.servers.includes(server.name))
|
||||
const serverNames = getServerNames(group.servers);
|
||||
const groupServers = servers.filter(server => serverNames.includes(server.name));
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<div className="bg-white shadow rounded-lg p-6 ">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<h2 className="text-xl font-semibold text-gray-800">{group.name}</h2>
|
||||
<div className="flex items-center ml-3">
|
||||
<span className="text-xs text-gray-500 mr-1">{group.id}</span>
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
title={t('common.copy')}
|
||||
>
|
||||
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
||||
</button>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowCopyDropdown(!showCopyDropdown)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors flex items-center"
|
||||
title={t('common.copy')}
|
||||
>
|
||||
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
||||
<DropdownIcon size={12} className="ml-1" />
|
||||
</button>
|
||||
|
||||
{showCopyDropdown && (
|
||||
<div className="absolute top-full left-0 mt-1 bg-white shadow-lg rounded-md border border-gray-200 py-1 z-10 min-w-[140px]">
|
||||
<button
|
||||
onClick={handleCopyId}
|
||||
className="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center"
|
||||
>
|
||||
<Copy size={12} className="mr-2" />
|
||||
{t('common.copyId')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopyUrl}
|
||||
className="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center"
|
||||
>
|
||||
<Link size={12} className="mr-2" />
|
||||
{t('common.copyUrl')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopyJson}
|
||||
className="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center"
|
||||
>
|
||||
<FileCode size={12} className="mr-2" />
|
||||
{t('common.copyJson')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{group.description && (
|
||||
@@ -89,7 +180,7 @@ const GroupCard = ({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-blue-50 text-blue-700 px-3 py-1 rounded-full text-sm">
|
||||
<div className="bg-blue-50 text-blue-700 px-3 py-1 rounded-full text-sm btn-secondary">
|
||||
{t('groups.serverCount', { count: group.servers.length })}
|
||||
</div>
|
||||
<button
|
||||
@@ -113,18 +204,68 @@ const GroupCard = ({
|
||||
{groupServers.length === 0 ? (
|
||||
<p className="text-gray-500 italic">{t('groups.noServers')}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{groupServers.map(server => (
|
||||
<div
|
||||
key={server.name}
|
||||
className="inline-flex items-center px-3 py-1 bg-gray-50 rounded"
|
||||
>
|
||||
<span className="font-medium text-gray-700 text-sm">{server.name}</span>
|
||||
<span className={`ml-2 inline-block h-2 w-2 rounded-full ${server.status === 'connected' ? 'bg-green-500' :
|
||||
server.status === 'connecting' ? 'bg-yellow-500' : 'bg-red-500'
|
||||
}`}></span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{groupServers.map(server => {
|
||||
const serverConfig = getServerConfig(server.name);
|
||||
const hasToolRestrictions = serverConfig && serverConfig.tools !== 'all' && Array.isArray(serverConfig.tools);
|
||||
const toolCount = hasToolRestrictions && Array.isArray(serverConfig?.tools)
|
||||
? serverConfig.tools.length
|
||||
: (server.tools?.length || 0); // Show total tool count when all tools are selected
|
||||
|
||||
const isExpanded = expandedServer === server.name;
|
||||
|
||||
// Get tools list for display
|
||||
const getToolsList = () => {
|
||||
if (hasToolRestrictions && Array.isArray(serverConfig?.tools)) {
|
||||
return serverConfig.tools;
|
||||
} else if (server.tools && server.tools.length > 0) {
|
||||
return server.tools.map(tool => tool.name);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const handleServerClick = () => {
|
||||
setExpandedServer(isExpanded ? null : server.name);
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={server.name} className="relative">
|
||||
<div
|
||||
className="flex items-center space-x-2 bg-gray-50 rounded-lg px-3 py-2 cursor-pointer hover:bg-gray-100 transition-colors"
|
||||
onClick={handleServerClick}
|
||||
>
|
||||
<span className="font-medium text-gray-700 text-sm">{server.name}</span>
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${server.status === 'connected' ? 'bg-green-500' :
|
||||
server.status === 'connecting' ? 'bg-yellow-500' : 'bg-red-500'
|
||||
}`}></span>
|
||||
{toolCount > 0 && (
|
||||
<span className="text-xs text-blue-600 bg-blue-100 px-2 py-0.5 rounded flex items-center gap-1">
|
||||
<Wrench size={12} />
|
||||
{toolCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="absolute top-full left-0 mt-1 bg-white shadow-lg rounded-md border border-gray-200 p-3 z-10 min-w-[300px] max-w-[400px]">
|
||||
<div className="text-gray-600 text-xs mb-2">
|
||||
{hasToolRestrictions ? t('groups.selectedTools') : t('groups.allTools')}:
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{getToolsList().map((toolName, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-block bg-gray-100 text-gray-700 px-2 py-1 rounded text-xs"
|
||||
>
|
||||
{toolName}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -48,25 +48,26 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
|
||||
// Get badge color based on log type
|
||||
const getLogTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'error': return 'bg-red-400';
|
||||
case 'warn': return 'bg-yellow-400';
|
||||
case 'debug': return 'bg-purple-400';
|
||||
default: return 'bg-blue-400';
|
||||
case 'error': return 'bg-red-400/80 text-white';
|
||||
case 'warn': return 'bg-yellow-400/80 text-gray-900';
|
||||
case 'debug': return 'bg-purple-400/80 text-white';
|
||||
case 'info': return 'bg-blue-400/80 text-white';
|
||||
default: return 'bg-blue-400/80 text-white';
|
||||
}
|
||||
};
|
||||
|
||||
// Get badge color based on log source
|
||||
const getSourceColor = (source: string) => {
|
||||
switch (source) {
|
||||
case 'main': return 'bg-green-400';
|
||||
case 'child': return 'bg-orange-400';
|
||||
default: return 'bg-gray-400';
|
||||
case 'main': return 'bg-green-400/80 text-white';
|
||||
case 'child': return 'bg-orange-400/80 text-white';
|
||||
default: return 'bg-gray-400/80 text-white';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="bg-card p-3 rounded-t-md border-b flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="bg-card p-3 rounded-t-md flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-sm">{t('logs.filters')}:</span>
|
||||
|
||||
@@ -74,14 +75,14 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('logs.search')}
|
||||
className="px-2 py-1 text-sm border rounded"
|
||||
className="shadow appearance-none border border-gray-200 rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Log type filters */}
|
||||
<div className="flex gap-1 items-center">
|
||||
{(['info', 'error', 'warn', 'debug'] as const).map(type => (
|
||||
{(['debug', 'info', 'error', 'warn'] as const).map(type => (
|
||||
<Badge
|
||||
key={type}
|
||||
variant={typeFilter.includes(type) ? 'default' : 'outline'}
|
||||
@@ -134,6 +135,7 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClear}
|
||||
className='btn-secondary'
|
||||
disabled={isLoading || logs.length === 0}
|
||||
>
|
||||
{t('logs.clearLogs')}
|
||||
@@ -164,7 +166,7 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
|
||||
filteredLogs.map((log, index) => (
|
||||
<div
|
||||
key={`${log.timestamp}-${index}`}
|
||||
className={`py-1 border-b border-gray-100 dark:border-gray-800 ${log.type === 'error' ? 'text-red-500' :
|
||||
className={`py-1 ${log.type === 'error' ? 'text-red-500' :
|
||||
log.type === 'warn' ? 'text-yellow-500' : ''
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -15,31 +15,31 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
|
||||
if (!server.tags || server.tags.length === 0) {
|
||||
return { tagsToShow: [], hasMore: false, moreCount: 0 };
|
||||
}
|
||||
|
||||
|
||||
// Estimate available width in the card (in characters)
|
||||
const estimatedAvailableWidth = 28; // Estimated number of characters that can fit in one line
|
||||
|
||||
|
||||
// Calculate the character space needed for tags and plus sign (including # and spacing)
|
||||
const calculateTagWidth = (tag: string) => tag.length + 3; // +3 for # and spacing
|
||||
|
||||
|
||||
// Loop to determine the maximum number of tags that can be displayed
|
||||
let totalWidth = 0;
|
||||
let i = 0;
|
||||
|
||||
|
||||
// First, sort tags by length to prioritize displaying shorter tags
|
||||
const sortedTags = [...server.tags].sort((a, b) => a.length - b.length);
|
||||
|
||||
|
||||
// Calculate how many tags can fit
|
||||
for (i = 0; i < sortedTags.length; i++) {
|
||||
const tagWidth = calculateTagWidth(sortedTags[i]);
|
||||
|
||||
|
||||
// If this tag would make the total width exceed available width, stop adding
|
||||
if (totalWidth + tagWidth > estimatedAvailableWidth) {
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
totalWidth += tagWidth;
|
||||
|
||||
|
||||
// If this is the last tag but there's still space, no need to show "more"
|
||||
if (i === sortedTags.length - 1) {
|
||||
return {
|
||||
@@ -49,16 +49,16 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If there's not enough space to display any tags, show at least one
|
||||
if (i === 0 && sortedTags.length > 0) {
|
||||
i = 1;
|
||||
}
|
||||
|
||||
|
||||
// Calculate space needed for the "more" tag
|
||||
const moreCount = sortedTags.length - i;
|
||||
const moreTagWidth = 3 + String(moreCount).length + t('market.moreTags').length;
|
||||
|
||||
|
||||
// If there's enough remaining space to display the "more" tag
|
||||
if (totalWidth + moreTagWidth <= estimatedAvailableWidth || i < 1) {
|
||||
return {
|
||||
@@ -67,7 +67,7 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
|
||||
moreCount
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// If there's not enough space for even the "more" tag, reduce one tag to make room
|
||||
return {
|
||||
tagsToShow: sortedTags.slice(0, Math.max(1, i - 1)),
|
||||
@@ -79,27 +79,27 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
|
||||
const { tagsToShow, hasMore, moreCount } = getTagsToDisplay();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-md p-5 hover:shadow-lg transition-shadow cursor-pointer flex flex-col h-full"
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-md p-5 hover:shadow-lg transition-all duration-200 cursor-pointer flex flex-col h-full page-card"
|
||||
onClick={() => onClick(server)}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900 line-clamp-1 mr-2">{server.display_name}</h3>
|
||||
{server.is_official && (
|
||||
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded flex-shrink-0">
|
||||
<span className="text-xs font-medium px-2.5 py-0.5 rounded flex-shrink-0 label-primary">
|
||||
{t('market.official')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm mb-4 line-clamp-2 min-h-[40px]">{server.description}</p>
|
||||
|
||||
|
||||
{/* Categories */}
|
||||
<div className="flex flex-wrap gap-1 mb-2 min-h-[28px]">
|
||||
{server.categories?.length > 0 ? (
|
||||
server.categories.map((category, index) => (
|
||||
<span
|
||||
<span
|
||||
key={index}
|
||||
className="bg-gray-100 text-gray-800 text-xs px-2 py-1 rounded whitespace-nowrap"
|
||||
className="bg-gray-100 text-gray-800 text-xs px-2 py-1.5 rounded whitespace-nowrap"
|
||||
>
|
||||
{category}
|
||||
</span>
|
||||
@@ -108,15 +108,15 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
|
||||
<span className="text-xs text-gray-400 py-1">-</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Tags */}
|
||||
<div className="relative mb-3 min-h-[28px] overflow-x-auto">
|
||||
{server.tags?.length > 0 ? (
|
||||
<div className="flex gap-1 items-center whitespace-nowrap">
|
||||
{tagsToShow.map((tag, index) => (
|
||||
<span
|
||||
<span
|
||||
key={index}
|
||||
className="bg-green-50 text-green-700 text-xs px-2 py-1 rounded flex-shrink-0"
|
||||
className="bg-green-50 text-green-700 text-xs px-2 py-1 rounded flex-shrink-0 label-secondary"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
@@ -131,8 +131,8 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
|
||||
<span className="text-xs text-gray-400 py-1">-</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mt-auto pt-2 text-xs text-gray-500 border-t border-gray-100">
|
||||
|
||||
<div className="flex justify-between items-center mt-auto pt-2 text-xs text-gray-500">
|
||||
<div className="overflow-hidden">
|
||||
<span className="whitespace-nowrap">{t('market.by')} </span>
|
||||
<span className="font-medium whitespace-nowrap overflow-hidden text-ellipsis max-w-[120px] inline-block align-bottom">
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MarketServer, MarketServerInstallation } from '@/types';
|
||||
import ServerForm from './ServerForm';
|
||||
import { detectVariables } from '../utils/variableDetection';
|
||||
|
||||
import { ServerConfig } from '@/types';
|
||||
|
||||
@@ -23,6 +24,9 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmationVisible, setConfirmationVisible] = useState(false);
|
||||
const [pendingPayload, setPendingPayload] = useState<any>(null);
|
||||
const [detectedVariables, setDetectedVariables] = useState<string[]>([]);
|
||||
|
||||
// Helper function to determine button state
|
||||
const getButtonProps = () => {
|
||||
@@ -40,7 +44,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
className: "bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white",
|
||||
className: "bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white btn-primary",
|
||||
disabled: false,
|
||||
text: t('market.install')
|
||||
};
|
||||
@@ -50,6 +54,27 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
const toggleModal = () => {
|
||||
setModalVisible(!modalVisible);
|
||||
setError(null); // Clear any previous errors when toggling modal
|
||||
setConfirmationVisible(false);
|
||||
setPendingPayload(null);
|
||||
};
|
||||
|
||||
const handleConfirmInstall = async () => {
|
||||
if (pendingPayload) {
|
||||
await proceedWithInstall(pendingPayload);
|
||||
setConfirmationVisible(false);
|
||||
setPendingPayload(null);
|
||||
}
|
||||
};
|
||||
|
||||
const proceedWithInstall = async (payload: any) => {
|
||||
try {
|
||||
setError(null);
|
||||
onInstall(server, payload.config);
|
||||
setModalVisible(false);
|
||||
} catch (err) {
|
||||
console.error('Error installing server:', err);
|
||||
setError(t('errors.serverInstall'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstall = () => {
|
||||
@@ -72,24 +97,32 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
} else if (server.installations.default) {
|
||||
return server.installations.default;
|
||||
}
|
||||
|
||||
|
||||
// If none of the preferred types are available, get the first available installation type
|
||||
const installTypes = Object.keys(server.installations);
|
||||
if (installTypes.length > 0) {
|
||||
return server.installations[installTypes[0]];
|
||||
}
|
||||
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const handleSubmit = async (payload: any) => {
|
||||
try {
|
||||
setError(null);
|
||||
// Pass the server object and the payload (includes env changes) for installation
|
||||
onInstall(server, payload.config);
|
||||
setModalVisible(false);
|
||||
// Check for variables in the payload
|
||||
const variables = detectVariables(payload);
|
||||
|
||||
if (variables.length > 0) {
|
||||
// Show confirmation dialog
|
||||
setDetectedVariables(variables);
|
||||
setPendingPayload(payload);
|
||||
setConfirmationVisible(true);
|
||||
} else {
|
||||
// Install directly if no variables found
|
||||
await proceedWithInstall(payload);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error installing server:', err);
|
||||
console.error('Error processing server installation:', err);
|
||||
setError(t('errors.serverInstall'));
|
||||
}
|
||||
};
|
||||
@@ -114,15 +147,15 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 flex items-center flex-wrap">
|
||||
{server.display_name}
|
||||
{server.display_name}
|
||||
<span className="text-sm font-normal text-gray-500 ml-2">({server.name})</span>
|
||||
<span className="text-sm font-normal text-gray-600 ml-4">
|
||||
{t('market.author')}: {server.author.name} • {t('market.license')}: {server.license} •
|
||||
{t('market.author')}: {server.author.name} • {t('market.license')}: {server.license} •
|
||||
<a
|
||||
href={server.repository.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline ml-1"
|
||||
className="text-blue-500 hover:underline ml-1"
|
||||
>
|
||||
{t('market.repository')}
|
||||
</a>
|
||||
@@ -132,7 +165,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
|
||||
<div className="flex items-center">
|
||||
{server.is_official && (
|
||||
<span className="bg-blue-100 text-blue-800 text-sm font-medium px-4 py-2 rounded mr-2 flex items-center">
|
||||
<span className="bg-blue-100 text-blue-800 text-sm font-normal px-4 py-2 rounded mr-2 flex items-center label-primary">
|
||||
{t('market.official')}
|
||||
</span>
|
||||
)}
|
||||
@@ -169,7 +202,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
<h3 className="text-lg font-semibold mb-3">{t('market.arguments')}</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
|
||||
{t('market.argumentName')}
|
||||
@@ -198,7 +231,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
{arg.required ? (
|
||||
<span className="text-green-600">✓</span>
|
||||
) : (
|
||||
<span className="text-red-600">✗</span>
|
||||
<span className="text-gray-600">✗</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
@@ -228,7 +261,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
element.classList.toggle('hidden');
|
||||
}
|
||||
}}
|
||||
className="text-sm text-blue-600 hover:underline focus:outline-none ml-2"
|
||||
className="text-sm text-blue-500 font-normal hover:underline focus:outline-none ml-2"
|
||||
>
|
||||
{t('market.viewSchema')}
|
||||
</button>
|
||||
@@ -281,17 +314,71 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
initialData={{
|
||||
name: server.name,
|
||||
status: 'disconnected',
|
||||
config: preferredInstallation
|
||||
config: preferredInstallation
|
||||
? {
|
||||
command: preferredInstallation.command || '',
|
||||
args: preferredInstallation.args || [],
|
||||
env: preferredInstallation.env || {}
|
||||
}
|
||||
command: preferredInstallation.command || '',
|
||||
args: preferredInstallation.args || [],
|
||||
env: preferredInstallation.env || {}
|
||||
}
|
||||
: undefined
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmationVisible && (
|
||||
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
{t('server.confirmVariables')}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{t('server.variablesDetected')}
|
||||
</p>
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 mb-4">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h4 className="text-sm font-medium text-yellow-800">
|
||||
{t('server.detectedVariables')}:
|
||||
</h4>
|
||||
<ul className="mt-1 text-sm text-yellow-700">
|
||||
{detectedVariables.map((variable, index) => (
|
||||
<li key={index} className="font-mono">
|
||||
${`{${variable}}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm mb-6">
|
||||
{t('market.confirmVariablesMessage')}
|
||||
</p>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setConfirmationVisible(false)
|
||||
setPendingPayload(null)
|
||||
}}
|
||||
className="px-4 py-2 text-gray-600 border border-gray-300 rounded hover:bg-gray-50 btn-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmInstall}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 btn-primary"
|
||||
>
|
||||
{t('market.confirmAndInstall')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
95
frontend/src/components/PermissionChecker.tsx
Normal file
95
frontend/src/components/PermissionChecker.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
interface PermissionCheckerProps {
|
||||
permissions: string | string[];
|
||||
fallback?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission checker component for conditional rendering
|
||||
* @param permissions Required permissions, supports single permission string or permission array
|
||||
* @param fallback Content to show when permission is denied, defaults to null
|
||||
* @param children Content to show when permission is granted
|
||||
*/
|
||||
export const PermissionChecker: React.FC<PermissionCheckerProps> = ({
|
||||
permissions,
|
||||
fallback = null,
|
||||
children,
|
||||
}) => {
|
||||
const hasPermission = usePermissionCheck(permissions);
|
||||
|
||||
return hasPermission ? <>{children}</> : <>{fallback}</>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Permission check hook
|
||||
* @param requiredPermissions Permissions to check
|
||||
* @returns Whether user has permission
|
||||
*/
|
||||
export const usePermissionCheck = (requiredPermissions: string | string[]): boolean => {
|
||||
const { auth } = useAuth();
|
||||
|
||||
if (!auth.isAuthenticated || !auth.user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const userPermissions = auth.user.permissions || [];
|
||||
|
||||
if (requiredPermissions === 'x' && !userPermissions.includes('x')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If user has '*' permission, they have all permissions
|
||||
if (userPermissions.includes('*')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If user is admin, they have all permissions by default
|
||||
if (auth.user.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Normalize required permissions to array
|
||||
const permissionsToCheck = Array.isArray(requiredPermissions)
|
||||
? requiredPermissions
|
||||
: [requiredPermissions];
|
||||
|
||||
// Check if user has any of the required permissions
|
||||
return permissionsToCheck.some(permission =>
|
||||
userPermissions.includes(permission)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Permission check hook - requires all permissions
|
||||
* @param requiredPermissions Array of permissions to check
|
||||
* @returns Whether user has all permissions
|
||||
*/
|
||||
export const usePermissionCheckAll = (requiredPermissions: string[]): boolean => {
|
||||
const { auth } = useAuth();
|
||||
|
||||
if (!auth.isAuthenticated || !auth.user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const userPermissions = auth.user.permissions || [];
|
||||
|
||||
// If user has '*' permission, they have all permissions
|
||||
if (userPermissions.includes('*')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If user is admin, they have all permissions by default
|
||||
if (auth.user.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if user has all required permissions
|
||||
return requiredPermissions.every(permission =>
|
||||
userPermissions.includes(permission)
|
||||
);
|
||||
};
|
||||
|
||||
export default PermissionChecker;
|
||||
@@ -7,20 +7,20 @@ interface ProtectedRouteProps {
|
||||
redirectPath?: string;
|
||||
}
|
||||
|
||||
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||
redirectPath = '/login'
|
||||
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||
redirectPath = '/login'
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { auth } = useAuth();
|
||||
|
||||
|
||||
if (auth.loading) {
|
||||
return <div className="flex items-center justify-center h-screen">{t('app.loading')}</div>;
|
||||
}
|
||||
|
||||
|
||||
if (!auth.isAuthenticated) {
|
||||
return <Navigate to={redirectPath} replace />;
|
||||
}
|
||||
|
||||
|
||||
return <Outlet />;
|
||||
};
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`bg-white shadow rounded-lg p-6 mb-6 ${server.enabled === false ? 'opacity-60' : ''}`}>
|
||||
<div className={`bg-white shadow rounded-lg p-6 mb-6 page-card transition-all duration-200 ${server.enabled === false ? 'opacity-60' : ''}`}>
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
@@ -138,7 +138,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
<StatusBadge status={server.status} />
|
||||
|
||||
{/* Tool count display */}
|
||||
<div className="flex items-center px-2 py-1 bg-blue-50 text-blue-700 rounded-full text-sm">
|
||||
<div className="flex items-center px-2 py-1 bg-blue-50 text-blue-700 rounded-full text-sm btn-primary">
|
||||
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
|
||||
</svg>
|
||||
@@ -174,7 +174,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
<h4 className="text-sm font-medium text-red-600">{t('server.errorDetails')}</h4>
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors btn-secondary"
|
||||
title={t('common.copy')}
|
||||
>
|
||||
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
||||
@@ -201,7 +201,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className="px-3 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm"
|
||||
className="px-3 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary"
|
||||
>
|
||||
{t('server.edit')}
|
||||
</button>
|
||||
@@ -211,8 +211,8 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
className={`px-3 py-1 text-sm rounded transition-colors ${isToggling
|
||||
? 'bg-gray-200 text-gray-500'
|
||||
: server.enabled !== false
|
||||
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
||||
? 'bg-green-100 text-green-800 hover:bg-green-200 btn-secondary'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-primary'
|
||||
}`}
|
||||
disabled={isToggling}
|
||||
>
|
||||
@@ -226,11 +226,11 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
className="px-3 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm"
|
||||
className="px-3 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm btn-danger"
|
||||
>
|
||||
{t('server.delete')}
|
||||
</button>
|
||||
<button className="text-gray-400 hover:text-gray-600">
|
||||
<button className="text-gray-400 hover:text-gray-600 btn-secondary">
|
||||
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -286,7 +286,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={handleInputChange}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
placeholder="e.g.: time-mcp"
|
||||
required
|
||||
disabled={isEdit}
|
||||
@@ -403,7 +403,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
...prev,
|
||||
openapi: { ...prev.openapi!, url: e.target.value }
|
||||
}))}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
placeholder="e.g.: https://api.example.com/openapi.json"
|
||||
required={serverType === 'openapi' && formData.openapi?.inputMode === 'url'}
|
||||
/>
|
||||
@@ -462,7 +462,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
url: prev.openapi?.url || ''
|
||||
}
|
||||
}))}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
>
|
||||
<option value="none">{t('server.openapi.securityNone')}</option>
|
||||
<option value="apiKey">{t('server.openapi.securityApiKey')}</option>
|
||||
@@ -474,7 +474,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
|
||||
{/* API Key Configuration */}
|
||||
{formData.openapi?.securityType === 'apiKey' && (
|
||||
<div className="mb-4 p-4 border rounded bg-gray-50">
|
||||
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.apiKeyConfig')}</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
@@ -486,7 +486,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
...prev,
|
||||
openapi: { ...prev.openapi, apiKeyName: e.target.value, url: prev.openapi?.url || '' }
|
||||
}))}
|
||||
className="w-full border rounded px-2 py-1 text-sm"
|
||||
className="w-full border rounded px-2 py-1 text-sm form-input focus:outline-none"
|
||||
placeholder="Authorization"
|
||||
/>
|
||||
</div>
|
||||
@@ -498,7 +498,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
...prev,
|
||||
openapi: { ...prev.openapi, apiKeyIn: e.target.value as any, url: prev.openapi?.url || '' }
|
||||
}))}
|
||||
className="w-full border rounded px-2 py-1 text-sm"
|
||||
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
|
||||
>
|
||||
<option value="header">Header</option>
|
||||
<option value="query">Query</option>
|
||||
@@ -514,7 +514,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
...prev,
|
||||
openapi: { ...prev.openapi, apiKeyValue: e.target.value, url: prev.openapi?.url || '' }
|
||||
}))}
|
||||
className="w-full border rounded px-2 py-1 text-sm"
|
||||
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
|
||||
placeholder="your-api-key"
|
||||
/>
|
||||
</div>
|
||||
@@ -524,7 +524,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
|
||||
{/* HTTP Authentication Configuration */}
|
||||
{formData.openapi?.securityType === 'http' && (
|
||||
<div className="mb-4 p-4 border rounded bg-gray-50">
|
||||
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.httpAuthConfig')}</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
@@ -535,7 +535,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
...prev,
|
||||
openapi: { ...prev.openapi, httpScheme: e.target.value as any, url: prev.openapi?.url || '' }
|
||||
}))}
|
||||
className="w-full border rounded px-2 py-1 text-sm"
|
||||
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
|
||||
>
|
||||
<option value="basic">Basic</option>
|
||||
<option value="bearer">Bearer</option>
|
||||
@@ -551,7 +551,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
...prev,
|
||||
openapi: { ...prev.openapi, httpCredentials: e.target.value, url: prev.openapi?.url || '' }
|
||||
}))}
|
||||
className="w-full border rounded px-2 py-1 text-sm"
|
||||
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
|
||||
placeholder={formData.openapi?.httpScheme === 'basic' ? 'base64-encoded-credentials' : 'bearer-token'}
|
||||
/>
|
||||
</div>
|
||||
@@ -561,7 +561,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
|
||||
{/* OAuth2 Configuration */}
|
||||
{formData.openapi?.securityType === 'oauth2' && (
|
||||
<div className="mb-4 p-4 border rounded bg-gray-50">
|
||||
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.oauth2Config')}</h4>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
@@ -573,7 +573,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
...prev,
|
||||
openapi: { ...prev.openapi, oauth2Token: e.target.value, url: prev.openapi?.url || '' }
|
||||
}))}
|
||||
className="w-full border rounded px-2 py-1 text-sm"
|
||||
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
|
||||
placeholder="access-token"
|
||||
/>
|
||||
</div>
|
||||
@@ -583,7 +583,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
|
||||
{/* OpenID Connect Configuration */}
|
||||
{formData.openapi?.securityType === 'openIdConnect' && (
|
||||
<div className="mb-4 p-4 border rounded bg-gray-50">
|
||||
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.openIdConnectConfig')}</h4>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
@@ -595,7 +595,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
...prev,
|
||||
openapi: { ...prev.openapi, openIdConnectUrl: e.target.value, url: prev.openapi?.url || '' }
|
||||
}))}
|
||||
className="w-full border rounded px-2 py-1 text-sm"
|
||||
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
|
||||
placeholder="https://example.com/.well-known/openid_configuration"
|
||||
/>
|
||||
</div>
|
||||
@@ -608,13 +608,56 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
...prev,
|
||||
openapi: { ...prev.openapi, openIdConnectToken: e.target.value, url: prev.openapi?.url || '' }
|
||||
}))}
|
||||
className="w-full border rounded px-2 py-1 text-sm"
|
||||
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
|
||||
placeholder="id-token"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<label className="block text-gray-700 text-sm font-bold">
|
||||
{t('server.headers')}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addHeaderVar}
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] btn-primary"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{headerVars.map((headerVar, index) => (
|
||||
<div key={index} className="flex items-center mb-2">
|
||||
<div className="flex items-center space-x-2 flex-grow">
|
||||
<input
|
||||
type="text"
|
||||
value={headerVar.key}
|
||||
onChange={(e) => handleHeaderVarChange(index, 'key', e.target.value)}
|
||||
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
|
||||
placeholder="Authorization"
|
||||
/>
|
||||
<span className="flex items-center">:</span>
|
||||
<input
|
||||
type="text"
|
||||
value={headerVar.value}
|
||||
onChange={(e) => handleHeaderVarChange(index, 'value', e.target.value)}
|
||||
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
|
||||
placeholder="Bearer token..."
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeHeaderVar(index)}
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] ml-2 btn-danger"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : serverType === 'sse' || serverType === 'streamable-http' ? (
|
||||
<>
|
||||
@@ -628,7 +671,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
id="url"
|
||||
value={formData.url}
|
||||
onChange={handleInputChange}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
placeholder={serverType === 'streamable-http' ? "e.g.: http://localhost:3000/mcp" : "e.g.: http://localhost:3000/sse"}
|
||||
required={serverType === 'sse' || serverType === 'streamable-http'}
|
||||
/>
|
||||
@@ -642,9 +685,9 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
<button
|
||||
type="button"
|
||||
onClick={addHeaderVar}
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center"
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] btn-primary"
|
||||
>
|
||||
+ {t('server.add')}
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{headerVars.map((headerVar, index) => (
|
||||
@@ -654,7 +697,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
type="text"
|
||||
value={headerVar.key}
|
||||
onChange={(e) => handleHeaderVarChange(index, 'key', e.target.value)}
|
||||
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2"
|
||||
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
|
||||
placeholder="Authorization"
|
||||
/>
|
||||
<span className="flex items-center">:</span>
|
||||
@@ -662,16 +705,16 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
type="text"
|
||||
value={headerVar.value}
|
||||
onChange={(e) => handleHeaderVarChange(index, 'value', e.target.value)}
|
||||
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2"
|
||||
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
|
||||
placeholder="Bearer token..."
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeHeaderVar(index)}
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[56px] ml-2"
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] ml-2 btn-danger"
|
||||
>
|
||||
- {t('server.remove')}
|
||||
-
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -689,7 +732,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
id="command"
|
||||
value={formData.command}
|
||||
onChange={handleInputChange}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
placeholder="e.g.: npx"
|
||||
required={serverType === 'stdio'}
|
||||
/>
|
||||
@@ -704,7 +747,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
id="arguments"
|
||||
value={formData.arguments}
|
||||
onChange={(e) => handleArgsChange(e.target.value)}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
placeholder="e.g.: -y time-mcp"
|
||||
required={serverType === 'stdio'}
|
||||
/>
|
||||
@@ -718,9 +761,9 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
<button
|
||||
type="button"
|
||||
onClick={addEnvVar}
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center"
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] btn-primary"
|
||||
>
|
||||
+ {t('server.add')}
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{envVars.map((envVar, index) => (
|
||||
@@ -730,7 +773,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
type="text"
|
||||
value={envVar.key}
|
||||
onChange={(e) => handleEnvVarChange(index, 'key', e.target.value)}
|
||||
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2"
|
||||
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
|
||||
placeholder={t('server.key')}
|
||||
/>
|
||||
<span className="flex items-center">:</span>
|
||||
@@ -738,16 +781,16 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
type="text"
|
||||
value={envVar.value}
|
||||
onChange={(e) => handleEnvVarChange(index, 'value', e.target.value)}
|
||||
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2"
|
||||
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
|
||||
placeholder={t('server.value')}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeEnvVar(index)}
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[56px] ml-2"
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] ml-2 btn-danger"
|
||||
>
|
||||
- {t('server.remove')}
|
||||
-
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -759,7 +802,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
{serverType !== 'openapi' && (
|
||||
<div className="mb-4">
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer bg-gray-50 hover:bg-gray-100 p-3 rounded border"
|
||||
className="flex items-center justify-between cursor-pointer bg-gray-50 hover:bg-gray-100 p-3 rounded border border-gray-200"
|
||||
onClick={() => setIsRequestOptionsExpanded(!isRequestOptionsExpanded)}
|
||||
>
|
||||
<label className="text-gray-700 text-sm font-bold">
|
||||
@@ -771,7 +814,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
</div>
|
||||
|
||||
{isRequestOptionsExpanded && (
|
||||
<div className="border rounded-b p-4 bg-gray-50 border-t-0">
|
||||
<div className="border border-gray-200 rounded-b p-4 bg-gray-50 border-t-0">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-gray-600 text-sm font-medium mb-1" htmlFor="timeout">
|
||||
@@ -782,7 +825,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
id="timeout"
|
||||
value={formData.options?.timeout || 60000}
|
||||
onChange={(e) => handleOptionsChange('timeout', parseInt(e.target.value) || 60000)}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
placeholder="30000"
|
||||
min="1000"
|
||||
max="300000"
|
||||
@@ -799,7 +842,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
id="maxTotalTimeout"
|
||||
value={formData.options?.maxTotalTimeout || ''}
|
||||
onChange={(e) => handleOptionsChange('maxTotalTimeout', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
placeholder="Optional"
|
||||
min="1000"
|
||||
/>
|
||||
@@ -830,13 +873,13 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="bg-gray-300 hover:bg-gray-400 text-gray-800 font-medium py-2 px-4 rounded mr-2"
|
||||
className="bg-gray-300 hover:bg-gray-400 text-gray-800 font-medium py-2 px-4 rounded mr-2 btn-secondary"
|
||||
>
|
||||
{t('server.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded"
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded btn-primary"
|
||||
>
|
||||
{isEdit ? t('server.save') : t('server.add')}
|
||||
</button>
|
||||
|
||||
317
frontend/src/components/ServerToolConfig.tsx
Normal file
317
frontend/src/components/ServerToolConfig.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IGroupServerConfig, Server, Tool } from '@/types';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
interface ServerToolConfigProps {
|
||||
servers: Server[];
|
||||
value: string[] | IGroupServerConfig[];
|
||||
onChange: (value: IGroupServerConfig[]) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ServerToolConfig: React.FC<ServerToolConfigProps> = ({
|
||||
servers,
|
||||
value,
|
||||
onChange,
|
||||
className
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
|
||||
|
||||
// Normalize current value to IGroupServerConfig[] format
|
||||
const normalizedValue: IGroupServerConfig[] = React.useMemo(() => {
|
||||
return value.map(item => {
|
||||
if (typeof item === 'string') {
|
||||
return { name: item, tools: 'all' as const };
|
||||
}
|
||||
return { ...item, tools: item.tools || 'all' as const };
|
||||
});
|
||||
}, [value]);
|
||||
|
||||
// Get available servers (enabled only)
|
||||
const availableServers = React.useMemo(() =>
|
||||
servers.filter(server => server.enabled !== false),
|
||||
[servers]
|
||||
);
|
||||
|
||||
// Clean up expanded servers when servers are removed from configuration
|
||||
// But keep servers that were explicitly expanded even if they have no configuration
|
||||
React.useEffect(() => {
|
||||
const configuredServerNames = new Set(normalizedValue.map(config => config.name));
|
||||
const availableServerNames = new Set(availableServers.map(server => server.name));
|
||||
|
||||
setExpandedServers(prev => {
|
||||
const newSet = new Set<string>();
|
||||
prev.forEach(serverName => {
|
||||
// Keep expanded if server is configured OR if server exists and user manually expanded it
|
||||
if (configuredServerNames.has(serverName) || availableServerNames.has(serverName)) {
|
||||
newSet.add(serverName);
|
||||
}
|
||||
});
|
||||
return newSet;
|
||||
});
|
||||
}, [normalizedValue, availableServers]);
|
||||
|
||||
const toggleServer = (serverName: string) => {
|
||||
const existingIndex = normalizedValue.findIndex(config => config.name === serverName);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Remove server - this will also remove all its tools
|
||||
const newValue = normalizedValue.filter(config => config.name !== serverName);
|
||||
onChange(newValue);
|
||||
// Don't auto-collapse the server when it's unchecked - let user control expansion manually
|
||||
} else {
|
||||
// Add server with all tools by default
|
||||
const newValue = [...normalizedValue, { name: serverName, tools: 'all' as const }];
|
||||
onChange(newValue);
|
||||
// Don't auto-expand the server when it's checked - let user control expansion manually
|
||||
}
|
||||
};
|
||||
|
||||
const toggleServerExpanded = (serverName: string) => {
|
||||
setExpandedServers(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(serverName)) {
|
||||
newSet.delete(serverName);
|
||||
} else {
|
||||
newSet.add(serverName);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const updateServerTools = (serverName: string, tools: string[] | 'all', keepExpanded = false) => {
|
||||
if (Array.isArray(tools) && tools.length === 0) {
|
||||
// If no tools are selected, remove the server entirely
|
||||
const newValue = normalizedValue.filter(config => config.name !== serverName);
|
||||
onChange(newValue);
|
||||
// Only collapse the server if not explicitly asked to keep it expanded
|
||||
if (!keepExpanded) {
|
||||
setExpandedServers(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(serverName);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Update server tools or add server if it doesn't exist
|
||||
const existingServerIndex = normalizedValue.findIndex(config => config.name === serverName);
|
||||
|
||||
if (existingServerIndex >= 0) {
|
||||
// Update existing server
|
||||
const newValue = normalizedValue.map(config =>
|
||||
config.name === serverName ? { ...config, tools } : config
|
||||
);
|
||||
onChange(newValue);
|
||||
} else {
|
||||
// Add new server with specified tools
|
||||
const newValue = [...normalizedValue, { name: serverName, tools }];
|
||||
onChange(newValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTool = (serverName: string, toolName: string) => {
|
||||
const server = availableServers.find(s => s.name === serverName);
|
||||
if (!server) return;
|
||||
|
||||
const allToolNames = server.tools?.map(tool => tool.name.replace(`${serverName}-`, '')) || [];
|
||||
const serverConfig = normalizedValue.find(config => config.name === serverName);
|
||||
|
||||
if (!serverConfig) {
|
||||
// Server not selected yet, add it with only this tool
|
||||
const newValue = [...normalizedValue, { name: serverName, tools: [toolName] }];
|
||||
onChange(newValue);
|
||||
// Don't auto-expand - let user control expansion manually
|
||||
return;
|
||||
}
|
||||
|
||||
if (serverConfig.tools === 'all') {
|
||||
// Switch from 'all' to specific tools, excluding the toggled tool
|
||||
const newTools = allToolNames.filter(name => name !== toolName);
|
||||
updateServerTools(serverName, newTools);
|
||||
// If all tools are deselected, the server will be removed and collapsed in updateServerTools
|
||||
} else if (Array.isArray(serverConfig.tools)) {
|
||||
const currentTools = serverConfig.tools;
|
||||
if (currentTools.includes(toolName)) {
|
||||
// Remove tool
|
||||
const newTools = currentTools.filter(name => name !== toolName);
|
||||
updateServerTools(serverName, newTools);
|
||||
// If all tools are deselected, the server will be removed and collapsed in updateServerTools
|
||||
} else {
|
||||
// Add tool
|
||||
const newTools = [...currentTools, toolName];
|
||||
|
||||
// If all tools are selected, switch to 'all'
|
||||
if (newTools.length === allToolNames.length) {
|
||||
updateServerTools(serverName, 'all');
|
||||
} else {
|
||||
updateServerTools(serverName, newTools);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isServerSelected = (serverName: string) => {
|
||||
const serverConfig = normalizedValue.find(config => config.name === serverName);
|
||||
if (!serverConfig) return false;
|
||||
|
||||
// Server is considered "fully selected" if tools is 'all'
|
||||
return serverConfig.tools === 'all';
|
||||
};
|
||||
|
||||
const isServerPartiallySelected = (serverName: string) => {
|
||||
const serverConfig = normalizedValue.find(config => config.name === serverName);
|
||||
if (!serverConfig) return false;
|
||||
|
||||
// Server is partially selected if it has specific tools selected (not 'all' and not empty)
|
||||
return Array.isArray(serverConfig.tools) && serverConfig.tools.length > 0;
|
||||
};
|
||||
|
||||
const isToolSelected = (serverName: string, toolName: string) => {
|
||||
const serverConfig = normalizedValue.find(config => config.name === serverName);
|
||||
if (!serverConfig) return false;
|
||||
|
||||
if (serverConfig.tools === 'all') return true;
|
||||
if (Array.isArray(serverConfig.tools)) {
|
||||
return serverConfig.tools.includes(toolName);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const getServerTools = (serverName: string): Tool[] => {
|
||||
const server = availableServers.find(s => s.name === serverName);
|
||||
return server?.tools || [];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
<div className="space-y-3">
|
||||
{availableServers.map(server => {
|
||||
const isSelected = isServerSelected(server.name);
|
||||
const isPartiallySelected = isServerPartiallySelected(server.name);
|
||||
const isExpanded = expandedServers.has(server.name);
|
||||
const serverTools = getServerTools(server.name);
|
||||
const serverConfig = normalizedValue.find(config => config.name === server.name);
|
||||
|
||||
return (
|
||||
<div key={server.name} className="border border-gray-200 rounded-lg hover:border-gray-300 hover:bg-gray-50 transition-colors">
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer rounded-lg transition-colors"
|
||||
onClick={() => toggleServerExpanded(server.name)}
|
||||
>
|
||||
<div
|
||||
className="flex items-center space-x-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleServer(server.name);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected || isPartiallySelected}
|
||||
onChange={() => toggleServer(server.name)}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="font-medium text-gray-900 cursor-pointer select-none">
|
||||
{server.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
{serverConfig && serverConfig.tools !== 'all' && Array.isArray(serverConfig.tools) && (
|
||||
<span className="text-sm text-green-600">
|
||||
({t('groups.toolsSelected')} {serverConfig.tools.length}/{serverTools.length})
|
||||
</span>
|
||||
)}
|
||||
{serverConfig && serverConfig.tools === 'all' && (
|
||||
<span className="text-sm text-green-600">
|
||||
({t('groups.allTools')} {serverTools.length}/{serverTools.length})
|
||||
</span>
|
||||
)}
|
||||
|
||||
{serverTools.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<svg
|
||||
className={cn("w-5 h-5 transition-transform", isExpanded && "rotate-180")}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && serverTools.length > 0 && (
|
||||
<div className="border-t border-gray-200 bg-gray-50 p-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{t('groups.toolSelection')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const isAllSelected = serverConfig?.tools === 'all';
|
||||
if (isAllSelected || (Array.isArray(serverConfig?.tools) && serverConfig.tools.length === serverTools.length)) {
|
||||
// If all tools are selected, deselect all (remove server) but keep expanded
|
||||
updateServerTools(server.name, [], true);
|
||||
} else {
|
||||
// Select all tools (add server if not present)
|
||||
updateServerTools(server.name, 'all');
|
||||
// Don't auto-expand - let user control expansion manually
|
||||
}
|
||||
}}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 transition-colors"
|
||||
>
|
||||
{(serverConfig?.tools === 'all' ||
|
||||
(Array.isArray(serverConfig?.tools) && serverConfig.tools.length === serverTools.length))
|
||||
? t('groups.selectNone')
|
||||
: t('groups.selectAll')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 max-h-32 overflow-y-auto">
|
||||
{serverTools.map(tool => {
|
||||
const toolName = tool.name.replace(`${server.name}-`, '');
|
||||
const isToolChecked = isToolSelected(server.name, toolName);
|
||||
|
||||
return (
|
||||
<label key={tool.name} className="flex items-center space-x-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isToolChecked}
|
||||
onChange={() => toggleTool(server.name, toolName)}
|
||||
className="w-3 h-3 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-gray-700">
|
||||
{toolName}
|
||||
</span>
|
||||
{tool.description && (
|
||||
<span className="text-gray-400 text-xs truncate">
|
||||
{tool.description}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{availableServers.length === 0 && (
|
||||
<p className="text-gray-500 text-sm">{t('groups.noServerOptions')}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
27
frontend/src/components/icons/LanguageIcon.tsx
Normal file
27
frontend/src/components/icons/LanguageIcon.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const LanguageIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
{...props}
|
||||
>
|
||||
<title>{t('common.language')}</title>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2 12h20" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageIcon;
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
Loader,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle
|
||||
AlertCircle,
|
||||
Link,
|
||||
FileCode,
|
||||
ChevronDown as DropdownIcon,
|
||||
Wrench
|
||||
} from 'lucide-react'
|
||||
|
||||
export {
|
||||
@@ -31,7 +35,11 @@ export {
|
||||
Loader,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle
|
||||
AlertCircle,
|
||||
Link,
|
||||
FileCode,
|
||||
DropdownIcon,
|
||||
Wrench
|
||||
}
|
||||
|
||||
const LucideIcons = {
|
||||
@@ -49,7 +57,10 @@ const LucideIcons = {
|
||||
Loader,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle
|
||||
AlertCircle,
|
||||
Link,
|
||||
FileCode,
|
||||
DropdownIcon
|
||||
}
|
||||
|
||||
export default LucideIcons
|
||||
42
frontend/src/components/index.ts
Normal file
42
frontend/src/components/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// Permission components unified export
|
||||
export { PermissionChecker, usePermissionCheck, usePermissionCheckAll } from './PermissionChecker';
|
||||
export { PERMISSIONS } from '../constants/permissions';
|
||||
|
||||
// Convenient permission check Hook
|
||||
export { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
// Permission utility functions
|
||||
export const hasPermission = (
|
||||
userPermissions: string[] = [],
|
||||
requiredPermissions: string | string[],
|
||||
): boolean => {
|
||||
if (requiredPermissions === 'x' && !userPermissions.includes('x')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If user has '*' permission, it means they have all permissions
|
||||
if (userPermissions.includes('*')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Normalize required permissions to array
|
||||
const permissionsToCheck = Array.isArray(requiredPermissions)
|
||||
? requiredPermissions
|
||||
: [requiredPermissions];
|
||||
|
||||
// Check if user has any of the required permissions
|
||||
return permissionsToCheck.some((permission) => userPermissions.includes(permission));
|
||||
};
|
||||
|
||||
export const hasAllPermissions = (
|
||||
userPermissions: string[] = [],
|
||||
requiredPermissions: string[],
|
||||
): boolean => {
|
||||
// If user has '*' permission, it means they have all permissions
|
||||
if (userPermissions.includes('*')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if user has all required permissions
|
||||
return requiredPermissions.every((permission) => userPermissions.includes(permission));
|
||||
};
|
||||
@@ -1,27 +1,19 @@
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import ThemeSwitch from '@/components/ui/ThemeSwitch';
|
||||
import LanguageSwitch from '@/components/ui/LanguageSwitch';
|
||||
import GitHubIcon from '@/components/icons/GitHubIcon';
|
||||
import SponsorIcon from '@/components/icons/SponsorIcon';
|
||||
import WeChatIcon from '@/components/icons/WeChatIcon';
|
||||
import DiscordIcon from '@/components/icons/DiscordIcon';
|
||||
import SponsorDialog from '@/components/ui/SponsorDialog';
|
||||
import WeChatDialog from '@/components/ui/WeChatDialog';
|
||||
|
||||
interface HeaderProps {
|
||||
onToggleSidebar: () => void;
|
||||
}
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { auth } = useAuth();
|
||||
const [sponsorDialogOpen, setSponsorDialogOpen] = useState(false);
|
||||
const [wechatDialogOpen, setWechatDialogOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<header className="bg-white dark:bg-gray-800 shadow-sm z-10">
|
||||
<div className="flex justify-between items-center px-4 py-3">
|
||||
<div className="flex justify-between items-center px-3 py-3">
|
||||
<div className="flex items-center">
|
||||
{/* 侧边栏切换按钮 */}
|
||||
<button
|
||||
@@ -38,53 +30,27 @@ const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
|
||||
<h1 className="ml-4 text-xl font-bold text-gray-900 dark:text-white">{t('app.title')}</h1>
|
||||
</div>
|
||||
|
||||
{/* Theme Switch and Version */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{/* Theme Switch and Language Switcher and Version */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 mr-2">
|
||||
{import.meta.env.PACKAGE_VERSION === 'dev'
|
||||
? import.meta.env.PACKAGE_VERSION
|
||||
: `v${import.meta.env.PACKAGE_VERSION}`}
|
||||
</span>
|
||||
|
||||
<a
|
||||
href="https://github.com/samanhappy/mcphub"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
className="p-2 rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
aria-label="GitHub Repository"
|
||||
>
|
||||
<GitHubIcon className="h-5 w-5" />
|
||||
</a>
|
||||
{i18n.language === 'zh' ? (
|
||||
<button
|
||||
onClick={() => setWechatDialogOpen(true)}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 focus:outline-none"
|
||||
aria-label={t('wechat.label')}
|
||||
>
|
||||
<WeChatIcon className="h-5 w-5" />
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href="https://discord.gg/qMKNsn5Q"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
aria-label={t('discord.label')}
|
||||
>
|
||||
<DiscordIcon className="h-5 w-5" />
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSponsorDialogOpen(true)}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 focus:outline-none"
|
||||
aria-label={t('sponsor.label')}
|
||||
>
|
||||
<SponsorIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<ThemeSwitch />
|
||||
<LanguageSwitch />
|
||||
</div>
|
||||
</div>
|
||||
<SponsorDialog open={sponsorDialogOpen} onOpenChange={setSponsorDialogOpen} />
|
||||
<WeChatDialog open={wechatDialogOpen} onOpenChange={setWechatDialogOpen} />
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { usePermissionCheck } from '../PermissionChecker';
|
||||
import UserProfileMenu from '@/components/ui/UserProfileMenu';
|
||||
|
||||
interface SidebarProps {
|
||||
@@ -15,11 +17,11 @@ interface MenuItem {
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
const { auth } = useAuth();
|
||||
|
||||
// Application version from package.json (accessed via Vite environment variables)
|
||||
const appVersion = import.meta.env.PACKAGE_VERSION as string;
|
||||
|
||||
|
||||
// Menu item configuration
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
@@ -50,6 +52,15 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
...(auth.user?.isAdmin && usePermissionCheck('x') ? [{
|
||||
path: '/users',
|
||||
label: t('nav.users'),
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
|
||||
</svg>
|
||||
),
|
||||
}] : []),
|
||||
{
|
||||
path: '/market',
|
||||
label: t('nav.market'),
|
||||
@@ -71,10 +82,9 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
];
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`bg-white dark:bg-gray-800 shadow-sm transition-all duration-300 ease-in-out flex flex-col h-full relative ${
|
||||
collapsed ? 'w-16' : 'w-64'
|
||||
}`}
|
||||
<aside
|
||||
className={`bg-white dark:bg-gray-800 shadow-sm transition-all duration-300 ease-in-out flex flex-col h-full relative ${collapsed ? 'w-16' : 'w-64'
|
||||
}`}
|
||||
>
|
||||
{/* Scrollable navigation area */}
|
||||
<div className="overflow-y-auto flex-grow">
|
||||
@@ -83,12 +93,11 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center px-3 py-2 rounded-md transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`
|
||||
className={({ isActive }) =>
|
||||
`flex items-center px-2.5 py-2 rounded-lg transition-colors duration-200
|
||||
${isActive
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-100'}`
|
||||
}
|
||||
end={item.path === '/'}
|
||||
>
|
||||
@@ -98,7 +107,7 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
|
||||
{/* User profile menu fixed at the bottom */}
|
||||
<div className="p-3 bg-white dark:bg-gray-800">
|
||||
<UserProfileMenu collapsed={collapsed} version={appVersion} />
|
||||
|
||||
@@ -93,7 +93,7 @@ const AboutDialog: React.FC<AboutDialogProps> = ({ isOpen, onClose, version }) =
|
||||
<button
|
||||
onClick={checkForUpdates}
|
||||
disabled={isChecking}
|
||||
className={`mt-4 inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium
|
||||
className={`mt-4 inline-flex items-center px-4 py-2 border border-gray-200 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium btn-secondary
|
||||
${isChecking
|
||||
? 'text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-800'
|
||||
: 'text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ServerStatus } from '@/types';
|
||||
import { cn } from '../../utils/cn';
|
||||
|
||||
type BadgeVariant = 'default' | 'secondary' | 'outline' | 'destructive';
|
||||
@@ -19,11 +18,11 @@ const badgeVariants = {
|
||||
destructive: 'bg-red-500 text-white hover:bg-red-600',
|
||||
};
|
||||
|
||||
export function Badge({
|
||||
children,
|
||||
variant = 'default',
|
||||
className,
|
||||
onClick
|
||||
export function Badge({
|
||||
children,
|
||||
variant = 'default',
|
||||
className,
|
||||
onClick
|
||||
}: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
@@ -43,11 +42,11 @@ export function Badge({
|
||||
// For backward compatibility with existing code
|
||||
export const StatusBadge = ({ status }: { status: 'connected' | 'disconnected' | 'connecting' }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
const colors = {
|
||||
connecting: 'bg-yellow-100 text-yellow-800',
|
||||
connected: 'bg-green-100 text-green-800',
|
||||
disconnected: 'bg-red-100 text-red-800',
|
||||
connecting: 'status-badge-connecting',
|
||||
connected: 'status-badge-online',
|
||||
disconnected: 'status-badge-offline',
|
||||
};
|
||||
|
||||
// Map status to translation keys
|
||||
|
||||
142
frontend/src/components/ui/ConfirmDialog.tsx
Normal file
142
frontend/src/components/ui/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title?: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: 'danger' | 'warning' | 'info';
|
||||
}
|
||||
|
||||
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText,
|
||||
cancelText,
|
||||
variant = 'warning'
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getVariantStyles = () => {
|
||||
switch (variant) {
|
||||
case 'danger':
|
||||
return {
|
||||
icon: (
|
||||
<svg className="w-6 h-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
),
|
||||
confirmClass: 'bg-red-600 hover:bg-red-700 text-white',
|
||||
};
|
||||
case 'warning':
|
||||
return {
|
||||
icon: (
|
||||
<svg className="w-6 h-6 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
),
|
||||
confirmClass: 'bg-yellow-600 hover:bg-yellow-700 text-white',
|
||||
};
|
||||
case 'info':
|
||||
return {
|
||||
icon: (
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
confirmClass: 'bg-blue-600 hover:bg-blue-700 text-white',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: null,
|
||||
confirmClass: 'bg-blue-600 hover:bg-blue-700 text-white',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const { icon, confirmClass } = getVariantStyles();
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
} else if (e.key === 'Enter') {
|
||||
onConfirm();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-[100] flex items-center justify-center p-4"
|
||||
onClick={handleBackdropClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-xl max-w-md w-full transform transition-all duration-200 ease-out"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
aria-describedby="confirm-dialog-message"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
{icon && (
|
||||
<div className="flex-shrink-0">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
{title && (
|
||||
<h3
|
||||
id="confirm-dialog-title"
|
||||
className="text-lg font-medium text-gray-900 mb-2"
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
<p
|
||||
id="confirm-dialog-message"
|
||||
className="text-gray-600 leading-relaxed"
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-md transition-colors duration-150 btn-secondary"
|
||||
autoFocus
|
||||
>
|
||||
{cancelText || t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`px-4 py-2 rounded-md transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 ${confirmClass} ${variant === 'danger' ? 'btn-danger' : variant === 'warning' ? 'btn-warning' : 'btn-primary'}`}
|
||||
>
|
||||
{confirmText || t('common.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmDialog;
|
||||
@@ -6,9 +6,10 @@ interface DeleteDialogProps {
|
||||
onConfirm: () => void
|
||||
serverName: string
|
||||
isGroup?: boolean
|
||||
isUser?: boolean
|
||||
}
|
||||
|
||||
const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName, isGroup = false }: DeleteDialogProps) => {
|
||||
const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName, isGroup = false, isUser = false }: DeleteDialogProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!isOpen) return null
|
||||
@@ -18,23 +19,29 @@ const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName, isGroup = false
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-3">
|
||||
{isGroup ? t('groups.confirmDelete') : t('server.confirmDelete')}
|
||||
{isUser
|
||||
? t('users.confirmDelete')
|
||||
: isGroup
|
||||
? t('groups.confirmDelete')
|
||||
: t('server.confirmDelete')}
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-6">
|
||||
{isGroup
|
||||
? t('groups.deleteWarning', { name: serverName })
|
||||
: t('server.deleteWarning', { name: serverName })}
|
||||
{isUser
|
||||
? t('users.deleteWarning', { username: serverName })
|
||||
: isGroup
|
||||
? t('groups.deleteWarning', { name: serverName })
|
||||
: t('server.deleteWarning', { name: serverName })}
|
||||
</p>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800 btn-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
||||
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 btn-danger"
|
||||
>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
|
||||
@@ -18,9 +18,10 @@ interface DynamicFormProps {
|
||||
onCancel: () => void;
|
||||
loading?: boolean;
|
||||
storageKey?: string; // Optional key for localStorage persistence
|
||||
title?: string; // Optional title to display instead of default parameters title
|
||||
}
|
||||
|
||||
const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, loading = false, storageKey }) => {
|
||||
const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, loading = false, storageKey, title }) => {
|
||||
const { t } = useTranslation();
|
||||
const [formValues, setFormValues] = useState<Record<string, any>>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
@@ -284,7 +285,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
type="text"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500 form-input"
|
||||
placeholder={schema.description || t('tool.enterKey', { key })}
|
||||
/>
|
||||
);
|
||||
@@ -301,7 +302,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
const val = e.target.value === '' ? '' : schema.type === 'integer' ? parseInt(e.target.value) : parseFloat(e.target.value);
|
||||
onChange(val);
|
||||
}}
|
||||
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500 form-input"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -323,7 +324,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
type="text"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500 form-input"
|
||||
placeholder={schema.description || t('tool.enterKey', { key })}
|
||||
/>
|
||||
);
|
||||
@@ -340,7 +341,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<div key={fullPath} className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
|
||||
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
|
||||
@@ -358,7 +359,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
newArray.splice(index, 1);
|
||||
handleInputChange(fullPath, newArray);
|
||||
}}
|
||||
className="text-red-500 hover:text-red-700 text-sm"
|
||||
className="text-status-red hover:text-red-700 text-sm"
|
||||
>
|
||||
{t('common.remove')}
|
||||
</button>
|
||||
@@ -387,7 +388,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<div key={objKey}>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
||||
{objKey}
|
||||
{propSchema.items?.required?.includes(objKey) && <span className="text-red-500 ml-1">*</span>}
|
||||
{propSchema.items?.required?.includes(objKey) && <span className="text-status-red ml-1">*</span>}
|
||||
</label>
|
||||
{renderObjectField(objKey, objSchema as JsonSchema, item, (newValue) => {
|
||||
const newArray = [...arrayValue];
|
||||
@@ -406,7 +407,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
newArray[index] = e.target.value;
|
||||
handleInputChange(fullPath, newArray);
|
||||
}}
|
||||
className="w-full border rounded-md px-3 py-2 border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full border rounded-md px-3 py-2 border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
|
||||
placeholder={t('tool.enterValue', { type: propSchema.items?.type || 'value' })}
|
||||
/>
|
||||
)}
|
||||
@@ -425,7 +426,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
} // Handle object type
|
||||
@@ -436,7 +437,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<div key={fullPath} className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
|
||||
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
|
||||
@@ -448,7 +449,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
@@ -457,7 +458,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<div key={fullPath} className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
|
||||
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
<span className="text-xs text-gray-500 ml-1">(JSON object)</span>
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
@@ -478,7 +479,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
className={`w-full border rounded-md px-3 py-2 font-mono text-sm ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
rows={4}
|
||||
/>
|
||||
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -488,7 +489,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<div key={fullPath} className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
|
||||
@@ -505,7 +506,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
@@ -513,7 +514,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<div key={fullPath} className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
|
||||
@@ -522,9 +523,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
type="text"
|
||||
value={value || ''}
|
||||
onChange={(e) => handleInputChange(fullPath, e.target.value)}
|
||||
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red' : 'border-gray-200'} focus:outline-none form-input`}
|
||||
/>
|
||||
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -533,7 +534,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<div key={fullPath} className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
|
||||
@@ -546,9 +547,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
const val = e.target.value === '' ? '' : propSchema.type === 'integer' ? parseInt(e.target.value) : parseFloat(e.target.value);
|
||||
handleInputChange(fullPath, val);
|
||||
}}
|
||||
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
className={`w-full border rounded-md px-3 py-2 form-input ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
/>
|
||||
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -565,13 +566,13 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
/>
|
||||
<label className="ml-2 block text-sm text-gray-700">
|
||||
{key}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
</label>
|
||||
</div>
|
||||
{propSchema.description && (
|
||||
<p className="text-xs text-gray-500 mt-1">{propSchema.description}</p>
|
||||
)}
|
||||
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
} // For other types, show as text input with description
|
||||
@@ -579,7 +580,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<div key={fullPath} className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
<span className="text-xs text-gray-500 ml-1">({propSchema.type})</span>
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
@@ -590,9 +591,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
value={value || ''}
|
||||
onChange={(e) => handleInputChange(fullPath, e.target.value)}
|
||||
placeholder={t('tool.enterValue', { type: propSchema.type })}
|
||||
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500 form-input`}
|
||||
/>
|
||||
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -624,15 +625,15 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Mode Toggle */}
|
||||
<div className="flex justify-between items-center border-b pb-3">
|
||||
<h3 className="text-lg font-medium text-gray-900">{t('tool.parameters')}</h3>
|
||||
<div className="flex justify-between items-center pb-3">
|
||||
<h6 className="text-md font-medium text-gray-900">{title}</h6>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={switchToFormMode}
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${!isJsonMode
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
? 'bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary'
|
||||
: 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary'
|
||||
}`}
|
||||
>
|
||||
{t('tool.formMode')}
|
||||
@@ -641,8 +642,8 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
type="button"
|
||||
onClick={switchToJsonMode}
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${isJsonMode
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
? 'px-4 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary'
|
||||
: 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary'
|
||||
}`}
|
||||
>
|
||||
{t('tool.jsonMode')}
|
||||
@@ -661,17 +662,17 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
value={jsonText}
|
||||
onChange={(e) => handleJsonTextChange(e.target.value)}
|
||||
placeholder={`{\n "key": "value"\n}`}
|
||||
className={`w-full h-64 border rounded-md px-3 py-2 font-mono text-sm resize-y ${jsonError ? 'border-red-500' : 'border-gray-300'
|
||||
className={`w-full h-64 border rounded-md px-3 py-2 font-mono text-sm resize-y form-input ${jsonError ? 'border-red-500' : 'border-gray-300'
|
||||
} focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
/>
|
||||
{jsonError && <p className="text-red-500 text-xs mt-1">{jsonError}</p>}
|
||||
{jsonError && <p className="text-status-red text-xs mt-1">{jsonError}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200"
|
||||
className="px-4 py-1 text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary"
|
||||
>
|
||||
{t('tool.cancel')}
|
||||
</button>
|
||||
@@ -685,7 +686,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
}
|
||||
}}
|
||||
disabled={loading || !!jsonError}
|
||||
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
className="px-4 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary"
|
||||
>
|
||||
{loading ? t('tool.running') : t('tool.runTool')}
|
||||
</button>
|
||||
@@ -702,14 +703,14 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200"
|
||||
className="px-4 py-1 text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary"
|
||||
>
|
||||
{t('tool.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
className="px-4 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary"
|
||||
>
|
||||
{loading ? t('tool.running') : t('tool.runTool')}
|
||||
</button>
|
||||
|
||||
83
frontend/src/components/ui/LanguageSwitch.tsx
Normal file
83
frontend/src/components/ui/LanguageSwitch.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LanguageIcon from '@/components/icons/LanguageIcon';
|
||||
|
||||
const LanguageSwitch: React.FC = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false);
|
||||
const [currentLanguage, setCurrentLanguage] = useState(i18n.language);
|
||||
|
||||
// Available languages
|
||||
const availableLanguages = [
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'zh', label: '中文' }
|
||||
];
|
||||
|
||||
// Update current language when it changes
|
||||
useEffect(() => {
|
||||
setCurrentLanguage(i18n.language);
|
||||
}, [i18n.language]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest('.language-dropdown')) {
|
||||
setLanguageDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (languageDropdownOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [languageDropdownOpen]);
|
||||
|
||||
const handleLanguageChange = (lang: string) => {
|
||||
localStorage.setItem('i18nextLng', lang);
|
||||
setLanguageDropdownOpen(false);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
// Always show dropdown for language selection
|
||||
const handleLanguageToggle = () => {
|
||||
setLanguageDropdownOpen(!languageDropdownOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative language-dropdown">
|
||||
<button
|
||||
onClick={handleLanguageToggle}
|
||||
className="p-2 rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none"
|
||||
aria-label="Language Switcher"
|
||||
>
|
||||
<LanguageIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* Show dropdown when opened */}
|
||||
{languageDropdownOpen && (
|
||||
<div className="absolute right-0 mt-2 w-24 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-50">
|
||||
<div>
|
||||
{availableLanguages.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => handleLanguageChange(lang.code)}
|
||||
className={`flex items-center w-full px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${currentLanguage.startsWith(lang.code)
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{lang.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSwitch;
|
||||
@@ -6,34 +6,33 @@ interface PaginationProps {
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
const Pagination: React.FC<PaginationProps> = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange
|
||||
const Pagination: React.FC<PaginationProps> = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange
|
||||
}) => {
|
||||
// Generate page buttons
|
||||
const getPageButtons = () => {
|
||||
const buttons = [];
|
||||
const maxDisplayedPages = 5; // Maximum number of page buttons to display
|
||||
|
||||
|
||||
// Always display first page
|
||||
buttons.push(
|
||||
<button
|
||||
key="first"
|
||||
onClick={() => onPageChange(1)}
|
||||
className={`px-3 py-1 mx-1 rounded ${
|
||||
currentPage === 1
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
|
||||
}`}
|
||||
className={`px-3 py-1 mx-1 rounded ${currentPage === 1
|
||||
? 'bg-blue-500 text-white btn-primary'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
|
||||
}`}
|
||||
>
|
||||
1
|
||||
</button>
|
||||
);
|
||||
|
||||
|
||||
// Start range
|
||||
let startPage = Math.max(2, currentPage - Math.floor(maxDisplayedPages / 2));
|
||||
|
||||
const startPage = Math.max(2, currentPage - Math.floor(maxDisplayedPages / 2));
|
||||
|
||||
// If we're showing ellipsis after first page
|
||||
if (startPage > 2) {
|
||||
buttons.push(
|
||||
@@ -42,24 +41,23 @@ const Pagination: React.FC<PaginationProps> = ({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Middle pages
|
||||
for (let i = startPage; i <= Math.min(totalPages - 1, startPage + maxDisplayedPages - 3); i++) {
|
||||
buttons.push(
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onPageChange(i)}
|
||||
className={`px-3 py-1 mx-1 rounded ${
|
||||
currentPage === i
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
|
||||
}`}
|
||||
className={`px-3 py-1 mx-1 rounded ${currentPage === i
|
||||
? 'bg-blue-500 text-white btn-primary'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
|
||||
}`}
|
||||
>
|
||||
{i}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// If we're showing ellipsis before last page
|
||||
if (startPage + maxDisplayedPages - 3 < totalPages - 1) {
|
||||
buttons.push(
|
||||
@@ -68,24 +66,23 @@ const Pagination: React.FC<PaginationProps> = ({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Always display last page if there's more than one page
|
||||
if (totalPages > 1) {
|
||||
buttons.push(
|
||||
<button
|
||||
key="last"
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
className={`px-3 py-1 mx-1 rounded ${
|
||||
currentPage === totalPages
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
|
||||
}`}
|
||||
className={`px-3 py-1 mx-1 rounded ${currentPage === totalPages
|
||||
? 'bg-blue-500 text-white btn-primary'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
|
||||
}`}
|
||||
>
|
||||
{totalPages}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return buttons;
|
||||
};
|
||||
|
||||
@@ -99,25 +96,23 @@ const Pagination: React.FC<PaginationProps> = ({
|
||||
<button
|
||||
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className={`px-3 py-1 rounded mr-2 ${
|
||||
currentPage === 1
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
|
||||
}`}
|
||||
className={`px-3 py-1 rounded mr-2 ${currentPage === 1
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
|
||||
}`}
|
||||
>
|
||||
« Prev
|
||||
</button>
|
||||
|
||||
|
||||
<div className="flex">{getPageButtons()}</div>
|
||||
|
||||
|
||||
<button
|
||||
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className={`px-3 py-1 rounded ml-2 ${
|
||||
currentPage === totalPages
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
|
||||
}`}
|
||||
className={`px-3 py-1 rounded ml-2 ${currentPage === totalPages
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
|
||||
}`}
|
||||
>
|
||||
Next »
|
||||
</button>
|
||||
|
||||
@@ -1,50 +1,25 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { Sun, Moon, Monitor } from 'lucide-react';
|
||||
import { Sun, Moon } from 'lucide-react';
|
||||
|
||||
const ThemeSwitch: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme === 'light' ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex bg-gray-200 dark:bg-gray-700 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setTheme('light')}
|
||||
className={`flex items-center justify-center rounded-md p-1.5 ${theme === 'light'
|
||||
? 'bg-white text-yellow-600 shadow'
|
||||
: 'text-black dark:text-gray-300 hover:text-yellow-600 dark:hover:text-yellow-500'
|
||||
}`}
|
||||
title={t('theme.light')}
|
||||
aria-label={t('theme.light')}
|
||||
>
|
||||
<Sun size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme('dark')}
|
||||
className={`flex items-center justify-center rounded-md p-1.5 ${theme === 'dark'
|
||||
? 'bg-gray-800 text-blue-400 shadow'
|
||||
: 'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400'
|
||||
}`}
|
||||
title={t('theme.dark')}
|
||||
aria-label={t('theme.dark')}
|
||||
>
|
||||
<Moon size={18} />
|
||||
</button>
|
||||
{/* <button
|
||||
onClick={() => setTheme('system')}
|
||||
className={`flex items-center justify-center rounded-md p-1.5 ${theme === 'system'
|
||||
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow'
|
||||
: 'text-black dark:text-gray-300 hover:text-green-600 dark:hover:text-green-400'
|
||||
}`}
|
||||
title={t('theme.system')}
|
||||
aria-label={t('theme.system')}
|
||||
>
|
||||
<Monitor size={18} />
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none"
|
||||
title={theme === 'light' ? t('theme.dark') : t('theme.light')}
|
||||
aria-label={theme === 'light' ? t('theme.dark') : t('theme.light')}
|
||||
>
|
||||
{theme === 'light' ? <Moon className="h-5 w-5" /> : <Sun className="h-5 w-5" />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ interface ToggleGroupItemProps {
|
||||
}
|
||||
|
||||
export const ToggleGroupItem: React.FC<ToggleGroupItemProps> = ({
|
||||
value,
|
||||
isSelected,
|
||||
onClick,
|
||||
children
|
||||
@@ -21,8 +20,8 @@ export const ToggleGroupItem: React.FC<ToggleGroupItemProps> = ({
|
||||
aria-checked={isSelected}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between p-2 rounded transition-colors cursor-pointer",
|
||||
isSelected
|
||||
? "bg-blue-50 text-blue-700 hover:bg-blue-100 border-l-4 border-blue-500"
|
||||
isSelected
|
||||
? "bg-blue-50 text-blue-700 hover:bg-blue-100 border-l-4 border-blue-500"
|
||||
: "hover:bg-gray-50 text-gray-700"
|
||||
)}
|
||||
onClick={onClick}
|
||||
@@ -72,7 +71,7 @@ export const ToggleGroup: React.FC<ToggleGroupProps> = ({
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2">
|
||||
{label}
|
||||
</label>
|
||||
<div className="border rounded shadow max-h-60 overflow-y-auto">
|
||||
<div className="border border-gray-200 rounded shadow max-h-60 overflow-y-auto">
|
||||
{options.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm p-3">{noOptionsText}</p>
|
||||
) : (
|
||||
@@ -118,7 +117,7 @@ export const Switch: React.FC<SwitchProps> = ({
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500",
|
||||
checked ? "bg-blue-600" : "bg-gray-200",
|
||||
checked ? "bg-blue-200" : "bg-gray-100",
|
||||
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
|
||||
)}
|
||||
onClick={() => !disabled && onCheckedChange(!checked)}
|
||||
|
||||
@@ -130,7 +130,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-300 shadow rounded-lg p-4 mb-4">
|
||||
<div className="bg-white border border-gray-200 shadow rounded-lg p-4 mb-4">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
@@ -144,7 +144,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
|
||||
<input
|
||||
ref={descriptionInputRef}
|
||||
type="text"
|
||||
className="px-2 py-1 border border-blue-300 rounded bg-white text-sm"
|
||||
className="px-2 py-1 border border-blue-300 rounded bg-white text-sm focus:outline-none form-input"
|
||||
value={customDescription}
|
||||
onChange={handleDescriptionChange}
|
||||
onKeyDown={handleDescriptionKeyDown}
|
||||
@@ -155,7 +155,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="ml-2 p-1 text-green-600 hover:text-green-800"
|
||||
className="ml-2 p-1 text-green-600 hover:text-green-800 cursor-pointer transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDescriptionSave()
|
||||
@@ -168,7 +168,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
|
||||
<>
|
||||
<span ref={descriptionTextRef}>{customDescription || t('tool.noDescription')}</span>
|
||||
<button
|
||||
className="ml-2 p-1 text-gray-500 hover:text-blue-600 transition-colors"
|
||||
className="ml-2 p-1 text-gray-500 hover:text-blue-600 cursor-pointer transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDescriptionEdit()
|
||||
@@ -198,7 +198,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
|
||||
setIsExpanded(true) // Ensure card is expanded when showing run form
|
||||
setShowRunForm(true)
|
||||
}}
|
||||
className="flex items-center space-x-1 px-3 py-1 text-sm text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-md transition-colors"
|
||||
className="flex items-center space-x-1 px-3 py-1 text-sm text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-md transition-colors btn-primary"
|
||||
disabled={isRunning || !tool.enabled}
|
||||
>
|
||||
{isRunning ? (
|
||||
@@ -228,14 +228,14 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
|
||||
|
||||
{/* Run Form */}
|
||||
{showRunForm && (
|
||||
<div className="border border-gray-300 rounded-lg p-4 bg-blue-50">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-3">{t('tool.runToolWithName', { name: tool.name.replace(server + '-', '') })}</h4>
|
||||
<div className="border border-gray-300 rounded-lg p-4">
|
||||
<DynamicForm
|
||||
schema={tool.inputSchema || { type: 'object' }}
|
||||
onSubmit={handleRunTool}
|
||||
onCancel={handleCancelRun}
|
||||
loading={isRunning}
|
||||
storageKey={getStorageKey()}
|
||||
title={t('tool.runToolWithName', { name: tool.name.replace(server + '-', '') })}
|
||||
/>
|
||||
{/* Tool Result */}
|
||||
{result && (
|
||||
|
||||
@@ -65,7 +65,6 @@ const ToolResult: React.FC<ToolResultProps> = ({ result, onClose }) => {
|
||||
|
||||
// For other structured content, try to parse as JSON
|
||||
try {
|
||||
const jsonString = typeof item === 'string' ? item : JSON.stringify(item, null, 2);
|
||||
const parsed = typeof item === 'string' ? JSON.parse(item) : item;
|
||||
|
||||
return (
|
||||
@@ -97,9 +96,9 @@ const ToolResult: React.FC<ToolResultProps> = ({ result, onClose }) => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{result.success ? (
|
||||
<CheckCircle size={20} className="text-green-500" />
|
||||
<CheckCircle size={20} className="text-status-green" />
|
||||
) : (
|
||||
<XCircle size={20} className="text-red-500" />
|
||||
<XCircle size={20} className="text-status-red" />
|
||||
)}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900">
|
||||
|
||||
@@ -4,6 +4,11 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { User, Settings, LogOut, Info } from 'lucide-react';
|
||||
import AboutDialog from './AboutDialog';
|
||||
import SponsorDialog from './SponsorDialog';
|
||||
import WeChatDialog from './WeChatDialog';
|
||||
import WeChatIcon from '@/components/icons/WeChatIcon';
|
||||
import DiscordIcon from '@/components/icons/DiscordIcon';
|
||||
import SponsorIcon from '@/components/icons/SponsorIcon';
|
||||
import { checkLatestVersion, compareVersions } from '@/utils/version';
|
||||
|
||||
interface UserProfileMenuProps {
|
||||
@@ -12,12 +17,14 @@ interface UserProfileMenuProps {
|
||||
}
|
||||
|
||||
const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version }) => {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { auth, logout } = useAuth();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showNewVersionInfo, setShowNewVersionInfo] = useState(false);
|
||||
const [showAboutDialog, setShowAboutDialog] = useState(false);
|
||||
const [sponsorDialogOpen, setSponsorDialogOpen] = useState(false);
|
||||
const [wechatDialogOpen, setWechatDialogOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Check for new version on login and component mount
|
||||
@@ -65,6 +72,16 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleSponsorClick = () => {
|
||||
setSponsorDialogOpen(true);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleWeChatClick = () => {
|
||||
setWechatDialogOpen(true);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="relative">
|
||||
<button
|
||||
@@ -73,7 +90,7 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
|
||||
}`}
|
||||
>
|
||||
<div className="flex-shrink-0 relative">
|
||||
<div className="w-7 h-7 flex items-center justify-center rounded-full border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700">
|
||||
<div className="w-5 h-5 flex items-center justify-center rounded-full border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700">
|
||||
<User className="h-4 w-4 text-gray-700 dark:text-gray-300" />
|
||||
</div>
|
||||
{showNewVersionInfo && (
|
||||
@@ -90,7 +107,35 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-0 transform -translate-y-full left-0 w-48 bg-white dark:bg-gray-800 shadow-lg rounded-md py-1 z-50">
|
||||
<div className="absolute top-0 transform -translate-y-full left-0 w-full min-w-max bg-white border border-gray-200 dark:bg-gray-800 z-50">
|
||||
<button
|
||||
onClick={handleSponsorClick}
|
||||
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<SponsorIcon className="h-4 w-4 mr-2" />
|
||||
{t('sponsor.label')}
|
||||
</button>
|
||||
|
||||
{i18n.language === 'zh' ? (
|
||||
<button
|
||||
onClick={handleWeChatClick}
|
||||
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<WeChatIcon className="h-4 w-4 mr-2" />
|
||||
{t('wechat.label')}
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href="https://discord.gg/qMKNsn5Q"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<DiscordIcon className="h-4 w-4 mr-2" />
|
||||
{t('discord.label')}
|
||||
</a>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSettingsClick}
|
||||
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
@@ -108,6 +153,9 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
|
||||
<span className="absolute top-2 right-4 block w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-600"></div>
|
||||
|
||||
<button
|
||||
onClick={handleLogoutClick}
|
||||
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
@@ -124,6 +172,12 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
|
||||
onClose={() => setShowAboutDialog(false)}
|
||||
version={version}
|
||||
/>
|
||||
|
||||
{/* Sponsor dialog */}
|
||||
<SponsorDialog open={sponsorDialogOpen} onOpenChange={setSponsorDialogOpen} />
|
||||
|
||||
{/* WeChat dialog */}
|
||||
<WeChatDialog open={wechatDialogOpen} onOpenChange={setWechatDialogOpen} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
9
frontend/src/constants/permissions.ts
Normal file
9
frontend/src/constants/permissions.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// Predefined permission constants
|
||||
export const PERMISSIONS = {
|
||||
// Settings page permissions
|
||||
SETTINGS_SMART_ROUTING: 'settings:smart_routing',
|
||||
SETTINGS_SKIP_AUTH: 'settings:skip_auth',
|
||||
SETTINGS_INSTALL_CONFIG: 'settings:install_config',
|
||||
} as const;
|
||||
|
||||
export default PERMISSIONS;
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { AuthState, IUser } from '../types';
|
||||
import { AuthState } from '../types';
|
||||
import * as authService from '../services/authService';
|
||||
import { getPublicConfig } from '../services/configService';
|
||||
|
||||
// Initial auth state
|
||||
const initialState: AuthState = {
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
loading: true,
|
||||
user: null,
|
||||
@@ -21,7 +21,7 @@ const AuthContext = createContext<{
|
||||
auth: initialState,
|
||||
login: async () => false,
|
||||
register: async () => false,
|
||||
logout: () => {},
|
||||
logout: () => { },
|
||||
});
|
||||
|
||||
// Auth provider component
|
||||
@@ -31,8 +31,27 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
// Load user if token exists
|
||||
useEffect(() => {
|
||||
const loadUser = async () => {
|
||||
// First check if authentication should be skipped
|
||||
const { skipAuth, permissions } = await getPublicConfig();
|
||||
|
||||
if (skipAuth) {
|
||||
// If authentication is disabled, set user as authenticated with a dummy user
|
||||
setAuth({
|
||||
isAuthenticated: true,
|
||||
loading: false,
|
||||
user: {
|
||||
username: 'guest',
|
||||
isAdmin: true,
|
||||
permissions,
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal authentication flow
|
||||
const token = authService.getToken();
|
||||
|
||||
|
||||
if (!token) {
|
||||
setAuth({
|
||||
...initialState,
|
||||
@@ -40,13 +59,12 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const response = await authService.getCurrentUser();
|
||||
|
||||
|
||||
if (response.success && response.user) {
|
||||
setAuth({
|
||||
token,
|
||||
isAuthenticated: true,
|
||||
loading: false,
|
||||
user: response.user,
|
||||
@@ -67,7 +85,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
loadUser();
|
||||
}, []);
|
||||
|
||||
@@ -75,10 +93,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
const login = async (username: string, password: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await authService.login({ username, password });
|
||||
|
||||
|
||||
if (response.success && response.token && response.user) {
|
||||
setAuth({
|
||||
token: response.token,
|
||||
isAuthenticated: true,
|
||||
loading: false,
|
||||
user: response.user,
|
||||
@@ -105,16 +122,15 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
|
||||
// Register function
|
||||
const register = async (
|
||||
username: string,
|
||||
password: string,
|
||||
username: string,
|
||||
password: string,
|
||||
isAdmin = false
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const response = await authService.register({ username, password, isAdmin });
|
||||
|
||||
|
||||
if (response.success && response.token && response.user) {
|
||||
setAuth({
|
||||
token: response.token,
|
||||
isAuthenticated: true,
|
||||
loading: false,
|
||||
user: response.user,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Group, ApiResponse } from '@/types';
|
||||
import { getApiUrl } from '../utils/runtime';
|
||||
import { Group, ApiResponse, IGroupServerConfig } from '@/types';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../utils/fetchInterceptor';
|
||||
|
||||
export const useGroupData = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -13,18 +13,7 @@ export const useGroupData = () => {
|
||||
const fetchGroups = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/groups'), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<Group[]> = await response.json();
|
||||
const data: ApiResponse<Group[]> = await apiGet('/groups');
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setGroups(data.data);
|
||||
@@ -49,27 +38,22 @@ export const useGroupData = () => {
|
||||
}, []);
|
||||
|
||||
// Create a new group with server associations
|
||||
const createGroup = async (name: string, description?: string, servers: string[] = []) => {
|
||||
const createGroup = async (
|
||||
name: string,
|
||||
description?: string,
|
||||
servers: string[] | IGroupServerConfig[] = [],
|
||||
) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/groups'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: JSON.stringify({ name, description, servers }),
|
||||
});
|
||||
const result: ApiResponse<Group> = await apiPost('/groups', { name, description, servers });
|
||||
console.log('Group created successfully:', result);
|
||||
|
||||
const result: ApiResponse<Group> = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(result.message || t('groups.createError'));
|
||||
return null;
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('groups.createError'));
|
||||
return result;
|
||||
}
|
||||
|
||||
triggerRefresh();
|
||||
return result.data || null;
|
||||
return result || null;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create group');
|
||||
return null;
|
||||
@@ -79,28 +63,17 @@ export const useGroupData = () => {
|
||||
// Update an existing group with server associations
|
||||
const updateGroup = async (
|
||||
id: string,
|
||||
data: { name?: string; description?: string; servers?: string[] },
|
||||
data: { name?: string; description?: string; servers?: string[] | IGroupServerConfig[] },
|
||||
) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl(`/groups/${id}`), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const result: ApiResponse<Group> = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(result.message || t('groups.updateError'));
|
||||
return null;
|
||||
const result: ApiResponse<Group> = await apiPut(`/groups/${id}`, data);
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('groups.updateError'));
|
||||
return result;
|
||||
}
|
||||
|
||||
triggerRefresh();
|
||||
return result.data || null;
|
||||
return result || null;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update group');
|
||||
return null;
|
||||
@@ -108,22 +81,14 @@ export const useGroupData = () => {
|
||||
};
|
||||
|
||||
// Update servers in a group (for batch updates)
|
||||
const updateGroupServers = async (groupId: string, servers: string[]) => {
|
||||
const updateGroupServers = async (groupId: string, servers: string[] | IGroupServerConfig[]) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl(`/groups/${groupId}/servers/batch`), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: JSON.stringify({ servers }),
|
||||
const result: ApiResponse<Group> = await apiPut(`/groups/${groupId}/servers/batch`, {
|
||||
servers,
|
||||
});
|
||||
|
||||
const result: ApiResponse<Group> = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(result.message || t('groups.updateError'));
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('groups.updateError'));
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -138,46 +103,29 @@ export const useGroupData = () => {
|
||||
// Delete a group
|
||||
const deleteGroup = async (id: string) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl(`/groups/${id}`), {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(result.message || t('groups.deleteError'));
|
||||
return false;
|
||||
const result = await apiDelete(`/groups/${id}`);
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('groups.deleteError'));
|
||||
return result;
|
||||
}
|
||||
|
||||
triggerRefresh();
|
||||
return true;
|
||||
return result;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete group');
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Add server to a group
|
||||
const addServerToGroup = async (groupId: string, serverName: string) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl(`/groups/${groupId}/servers`), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: JSON.stringify({ serverName }),
|
||||
const result: ApiResponse<Group> = await apiPost(`/groups/${groupId}/servers`, {
|
||||
serverName,
|
||||
});
|
||||
|
||||
const result: ApiResponse<Group> = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(result.message || t('groups.serverAddError'));
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('groups.serverAddError'));
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -192,18 +140,12 @@ export const useGroupData = () => {
|
||||
// Remove server from group
|
||||
const removeServerFromGroup = async (groupId: string, serverName: string) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl(`/groups/${groupId}/servers/${serverName}`), {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
const result: ApiResponse<Group> = await apiDelete(
|
||||
`/groups/${groupId}/servers/${serverName}`,
|
||||
);
|
||||
|
||||
const result: ApiResponse<Group> = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(result.message || t('groups.serverRemoveError'));
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('groups.serverRemoveError'));
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MarketServer, ApiResponse, ServerConfig } from '@/types';
|
||||
import { getApiUrl } from '../utils/runtime';
|
||||
import { apiGet, apiPost } from '../utils/fetchInterceptor';
|
||||
|
||||
export const useMarketData = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -26,18 +26,7 @@ export const useMarketData = () => {
|
||||
const fetchMarketServers = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/market/servers'), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<MarketServer[]> = await response.json();
|
||||
const data: ApiResponse<MarketServer[]> = await apiGet('/market/servers');
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setAllServers(data.data);
|
||||
@@ -87,18 +76,7 @@ export const useMarketData = () => {
|
||||
// Fetch all categories
|
||||
const fetchCategories = useCallback(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/market/categories'), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<string[]> = await response.json();
|
||||
const data: ApiResponse<string[]> = await apiGet('/market/categories');
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setCategories(data.data);
|
||||
@@ -113,18 +91,7 @@ export const useMarketData = () => {
|
||||
// Fetch all tags
|
||||
const fetchTags = useCallback(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/market/tags'), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<string[]> = await response.json();
|
||||
const data: ApiResponse<string[]> = await apiGet('/market/tags');
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setTags(data.data);
|
||||
@@ -141,18 +108,7 @@ export const useMarketData = () => {
|
||||
async (name: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl(`/market/servers/${name}`), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<MarketServer> = await response.json();
|
||||
const data: ApiResponse<MarketServer> = await apiGet(`/market/servers/${name}`);
|
||||
|
||||
if (data && data.success && data.data) {
|
||||
setCurrentServer(data.data);
|
||||
@@ -186,22 +142,10 @@ export const useMarketData = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(
|
||||
getApiUrl(`/market/servers/search?query=${encodeURIComponent(query)}`),
|
||||
{
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
},
|
||||
const data: ApiResponse<MarketServer[]> = await apiGet(
|
||||
`/market/servers/search?query=${encodeURIComponent(query)}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<MarketServer[]> = await response.json();
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setAllServers(data.data);
|
||||
setCurrentPage(1);
|
||||
@@ -233,22 +177,10 @@ export const useMarketData = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(
|
||||
getApiUrl(`/market/categories/${encodeURIComponent(category)}`),
|
||||
{
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
},
|
||||
const data: ApiResponse<MarketServer[]> = await apiGet(
|
||||
`/market/categories/${encodeURIComponent(category)}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<MarketServer[]> = await response.json();
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setAllServers(data.data);
|
||||
setCurrentPage(1);
|
||||
@@ -280,18 +212,9 @@ export const useMarketData = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl(`/market/tags/${encodeURIComponent(tag)}`), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<MarketServer[]> = await response.json();
|
||||
const data: ApiResponse<MarketServer[]> = await apiGet(
|
||||
`/market/tags/${encodeURIComponent(tag)}`,
|
||||
);
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setAllServers(data.data);
|
||||
@@ -314,18 +237,7 @@ export const useMarketData = () => {
|
||||
// Fetch installed servers
|
||||
const fetchInstalledServers = useCallback(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/servers'), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const data = await apiGet<{ success: boolean; data: any[] }>('/servers');
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
// Extract server names
|
||||
@@ -365,27 +277,24 @@ export const useMarketData = () => {
|
||||
// Prepare server configuration, merging with customConfig
|
||||
const serverConfig = {
|
||||
name: server.name,
|
||||
config: customConfig.type === 'stdio' ? {
|
||||
command: customConfig.command || installation.command || '',
|
||||
args: customConfig.args || installation.args || [],
|
||||
env: { ...installation.env, ...customConfig.env },
|
||||
} : customConfig
|
||||
config:
|
||||
customConfig.type === 'stdio'
|
||||
? {
|
||||
command: customConfig.command || installation.command || '',
|
||||
args: customConfig.args || installation.args || [],
|
||||
env: { ...installation.env, ...customConfig.env },
|
||||
}
|
||||
: customConfig,
|
||||
};
|
||||
|
||||
// Call the createServer API
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/servers'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: JSON.stringify(serverConfig),
|
||||
});
|
||||
const result = await apiPost<{ success: boolean; message?: string }>(
|
||||
'/servers',
|
||||
serverConfig,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || `Status: ${response.status}`);
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || 'Failed to install server');
|
||||
}
|
||||
|
||||
// Update installed servers list after successful installation
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server, ApiResponse } from '@/types';
|
||||
import { getApiUrl } from '../utils/runtime';
|
||||
import { apiGet, apiPost, apiDelete } from '../utils/fetchInterceptor';
|
||||
|
||||
// Configuration options
|
||||
const CONFIG = {
|
||||
@@ -44,13 +44,7 @@ export const useServerData = () => {
|
||||
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/servers'), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
const data = await apiGet('/servers');
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
@@ -97,13 +91,7 @@ export const useServerData = () => {
|
||||
// Initialization phase request function
|
||||
const fetchInitialData = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/servers'), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
const data = await apiGet('/servers');
|
||||
|
||||
// Handle API response wrapper object, extract data field
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
@@ -203,14 +191,8 @@ export const useServerData = () => {
|
||||
const handleServerEdit = async (server: Server) => {
|
||||
try {
|
||||
// Fetch settings to get the full server config before editing
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/settings'), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> = await response.json();
|
||||
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> =
|
||||
await apiGet('/settings');
|
||||
|
||||
if (
|
||||
settingsData &&
|
||||
@@ -240,17 +222,10 @@ export const useServerData = () => {
|
||||
|
||||
const handleServerRemove = async (serverName: string) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl(`/servers/${serverName}`), {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
const result = await response.json();
|
||||
const result = await apiDelete(`/servers/${serverName}`);
|
||||
|
||||
if (!response.ok) {
|
||||
setError(result.message || t('server.deleteError', { serverName }));
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('server.deleteError', { serverName }));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -264,21 +239,11 @@ export const useServerData = () => {
|
||||
|
||||
const handleServerToggle = async (server: Server, enabled: boolean) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl(`/servers/${server.name}/toggle`), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
const result = await apiPost(`/servers/${server.name}/toggle`, { enabled });
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
if (!result || !result.success) {
|
||||
console.error('Failed to toggle server:', result);
|
||||
setError(t('server.toggleError', { serverName: server.name }));
|
||||
setError(result?.message || t('server.toggleError', { serverName: server.name }));
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ApiResponse } from '@/types';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import { getApiUrl } from '../utils/runtime';
|
||||
import { apiGet, apiPut } from '../utils/fetchInterceptor';
|
||||
|
||||
// Define types for the settings data
|
||||
interface RoutingConfig {
|
||||
@@ -10,11 +10,13 @@ interface RoutingConfig {
|
||||
enableGroupNameRoute: boolean;
|
||||
enableBearerAuth: boolean;
|
||||
bearerAuthKey: string;
|
||||
skipAuth: boolean;
|
||||
}
|
||||
|
||||
interface InstallConfig {
|
||||
pythonIndexUrl: string;
|
||||
npmRegistry: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
interface SmartRoutingConfig {
|
||||
@@ -46,6 +48,7 @@ export const useSettingsData = () => {
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
skipAuth: false,
|
||||
});
|
||||
|
||||
const [tempRoutingConfig, setTempRoutingConfig] = useState<TempRoutingConfig>({
|
||||
@@ -55,6 +58,7 @@ export const useSettingsData = () => {
|
||||
const [installConfig, setInstallConfig] = useState<InstallConfig>({
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
});
|
||||
|
||||
const [smartRoutingConfig, setSmartRoutingConfig] = useState<SmartRoutingConfig>({
|
||||
@@ -80,18 +84,7 @@ export const useSettingsData = () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/settings'), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<SystemSettings> = await response.json();
|
||||
const data: ApiResponse<SystemSettings> = await apiGet('/settings');
|
||||
|
||||
if (data.success && data.data?.systemConfig?.routing) {
|
||||
setRoutingConfig({
|
||||
@@ -99,12 +92,14 @@ export const useSettingsData = () => {
|
||||
enableGroupNameRoute: data.data.systemConfig.routing.enableGroupNameRoute ?? true,
|
||||
enableBearerAuth: data.data.systemConfig.routing.enableBearerAuth ?? false,
|
||||
bearerAuthKey: data.data.systemConfig.routing.bearerAuthKey || '',
|
||||
skipAuth: data.data.systemConfig.routing.skipAuth ?? false,
|
||||
});
|
||||
}
|
||||
if (data.success && data.data?.systemConfig?.install) {
|
||||
setInstallConfig({
|
||||
pythonIndexUrl: data.data.systemConfig.install.pythonIndexUrl || '',
|
||||
npmRegistry: data.data.systemConfig.install.npmRegistry || '',
|
||||
baseUrl: data.data.systemConfig.install.baseUrl || 'http://localhost:3000',
|
||||
});
|
||||
}
|
||||
if (data.success && data.data?.systemConfig?.smartRouting) {
|
||||
@@ -128,34 +123,17 @@ export const useSettingsData = () => {
|
||||
}, [t]); // 移除 showToast 依赖
|
||||
|
||||
// Update routing configuration
|
||||
const updateRoutingConfig = async <T extends keyof RoutingConfig>(
|
||||
key: T,
|
||||
value: RoutingConfig[T],
|
||||
) => {
|
||||
const updateRoutingConfig = async (key: keyof RoutingConfig, value: any) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/system-config'), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
const data = await apiPut('/system-config', {
|
||||
routing: {
|
||||
[key]: value,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
routing: {
|
||||
[key]: value,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setRoutingConfig({
|
||||
...routingConfig,
|
||||
@@ -164,7 +142,7 @@ export const useSettingsData = () => {
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
showToast(t('errors.failedToUpdateRouteConfig'));
|
||||
showToast(data.message || t('errors.failedToUpdateRouteConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -183,26 +161,12 @@ export const useSettingsData = () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/system-config'), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
const data = await apiPut('/system-config', {
|
||||
install: {
|
||||
[key]: value,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
install: {
|
||||
[key]: value,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setInstallConfig({
|
||||
...installConfig,
|
||||
@@ -211,7 +175,7 @@ export const useSettingsData = () => {
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
showToast(t('errors.failedToUpdateSystemConfig'));
|
||||
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -233,27 +197,12 @@ export const useSettingsData = () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/system-config'), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
const data = await apiPut('/system-config', {
|
||||
smartRouting: {
|
||||
[key]: value,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
smartRouting: {
|
||||
[key]: value,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || `HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setSmartRoutingConfig({
|
||||
...smartRoutingConfig,
|
||||
@@ -283,25 +232,10 @@ export const useSettingsData = () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/system-config'), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
smartRouting: updates,
|
||||
}),
|
||||
const data = await apiPut('/system-config', {
|
||||
smartRouting: updates,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || `HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setSmartRoutingConfig({
|
||||
...smartRoutingConfig,
|
||||
@@ -331,24 +265,10 @@ export const useSettingsData = () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/system-config'), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
routing: updates,
|
||||
}),
|
||||
const data = await apiPut('/system-config', {
|
||||
routing: updates,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setRoutingConfig({
|
||||
...routingConfig,
|
||||
@@ -357,7 +277,7 @@ export const useSettingsData = () => {
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
showToast(t('errors.failedToUpdateRouteConfig'));
|
||||
showToast(data.message || t('errors.failedToUpdateRouteConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,9 +2,9 @@ import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
// Import translations
|
||||
import enTranslation from './locales/en.json';
|
||||
import zhTranslation from './locales/zh.json';
|
||||
// Import shared translations from root locales directory
|
||||
import enTranslation from '../../locales/en.json';
|
||||
import zhTranslation from '../../locales/zh.json';
|
||||
|
||||
i18n
|
||||
// Detect user language
|
||||
@@ -15,18 +15,18 @@ i18n
|
||||
.init({
|
||||
resources: {
|
||||
en: {
|
||||
translation: enTranslation
|
||||
translation: enTranslation,
|
||||
},
|
||||
zh: {
|
||||
translation: zhTranslation
|
||||
}
|
||||
translation: zhTranslation,
|
||||
},
|
||||
},
|
||||
fallbackLng: 'en',
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
|
||||
|
||||
// Common namespace used for all translations
|
||||
defaultNS: 'translation',
|
||||
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false, // React already safe from XSS
|
||||
},
|
||||
@@ -36,7 +36,7 @@ i18n
|
||||
order: ['localStorage', 'cookie', 'htmlTag', 'navigator'],
|
||||
// Cache the language in localStorage
|
||||
caches: ['localStorage', 'cookie'],
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
export default i18n;
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
/* Use project's custom Tailwind import */
|
||||
@import "tailwindcss";
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* Add some custom styles to verify CSS is working correctly */
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
font-family:
|
||||
'Inter',
|
||||
'PingFang SC',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
'Roboto',
|
||||
'Oxygen',
|
||||
'Ubuntu',
|
||||
'Cantarell',
|
||||
'Fira Sans',
|
||||
'Droid Sans',
|
||||
'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
@@ -13,7 +24,7 @@ body {
|
||||
|
||||
/* Dark mode override styles - these will apply when dark class is on html element */
|
||||
.dark body {
|
||||
background-color: #111827;
|
||||
background-color: #1f2a37;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
@@ -37,30 +48,435 @@ body {
|
||||
color: #d1d5db !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-500 {
|
||||
/* .dark .text-gray-500 {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
} */
|
||||
|
||||
.dark .border-gray-300 {
|
||||
border-color: #4b5563 !important;
|
||||
border-color: #2f3b4c !important;
|
||||
}
|
||||
|
||||
.dark .border-gray-200 {
|
||||
border-color: #2f3b4c !important;
|
||||
}
|
||||
|
||||
.dark .divide-gray-200 > :not([hidden]) ~ :not([hidden]) {
|
||||
border-color: #2f3b4c !important;
|
||||
}
|
||||
|
||||
.dark .bg-gray-100 {
|
||||
background-color: #374151 !important;
|
||||
}
|
||||
|
||||
/* Specific hover effects for dark mode */
|
||||
.dark .hover\:bg-gray-100:hover {
|
||||
background-color: rgba(110, 127, 156, 0.15) !important;
|
||||
}
|
||||
|
||||
.dark .hover\:text-gray-900:hover {
|
||||
color: rgb(190, 188, 185) !important;
|
||||
}
|
||||
|
||||
.dark .bg-gray-50 {
|
||||
background-color: #1f2937 !important;
|
||||
}
|
||||
|
||||
.dark .text-blue-700 {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.dark .bg-blue-50 {
|
||||
background-color: #4b5563 !important;
|
||||
}
|
||||
|
||||
.dark .bg-blue-200 {
|
||||
background-color: #576476 !important;
|
||||
}
|
||||
|
||||
.dark .shadow {
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.4), 0 1px 2px 0 rgba(0, 0, 0, 0.24) !important;
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.15),
|
||||
0 2px 6px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.bg-custom-blue {
|
||||
background-color: #4a90e2;
|
||||
background-color: #4a90e2;
|
||||
}
|
||||
|
||||
.text-custom-white {
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.status-badge-online {
|
||||
background-color: white !important;
|
||||
color: rgba(129, 199, 132, 0.9) !important;
|
||||
border: 1px solid #a6d7b7;
|
||||
}
|
||||
|
||||
/* Enhanced status badge styles for dark theme */
|
||||
.dark .status-badge-online {
|
||||
background-color: rgba(76, 175, 80, 0.15) !important;
|
||||
color: rgba(129, 199, 132, 0.9) !important;
|
||||
border: 1px solid rgba(76, 175, 80, 0.3);
|
||||
}
|
||||
|
||||
.status-badge-offline {
|
||||
background-color: white !important;
|
||||
color: rgba(107, 114, 128, 0.9) !important;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.dark .status-badge-offline {
|
||||
background-color: rgba(107, 114, 128, 0.15) !important;
|
||||
color: rgba(156, 163, 175, 0.9) !important;
|
||||
border: 1px solid rgba(107, 114, 128, 0.3);
|
||||
}
|
||||
|
||||
.status-badge-connecting {
|
||||
background-color: white !important;
|
||||
color: rgba(255, 213, 79, 0.9) !important;
|
||||
border: 1px solid #ffd57f;
|
||||
}
|
||||
|
||||
.dark .status-badge-connecting {
|
||||
background-color: rgba(255, 193, 7, 0.15) !important;
|
||||
color: rgba(255, 213, 79, 0.9) !important;
|
||||
border: 1px solid rgba(255, 193, 7, 0.3);
|
||||
}
|
||||
|
||||
/* Enhanced status icons for dark theme */
|
||||
.dark .status-icon-blue {
|
||||
background-color: rgba(59, 130, 246, 0.15) !important;
|
||||
color: rgba(96, 165, 250, 0.9) !important;
|
||||
}
|
||||
|
||||
.dark .status-icon-green {
|
||||
background-color: rgba(76, 175, 80, 0.15) !important;
|
||||
color: rgba(129, 199, 132, 0.9) !important;
|
||||
}
|
||||
|
||||
.dark .status-icon-red {
|
||||
background-color: rgba(244, 67, 54, 0.15) !important;
|
||||
color: rgba(239, 154, 154, 0.9) !important;
|
||||
}
|
||||
|
||||
.dark .status-icon-yellow {
|
||||
background-color: rgba(255, 193, 7, 0.15) !important;
|
||||
color: rgba(255, 213, 79, 0.9) !important;
|
||||
}
|
||||
|
||||
/* Enhanced card hover effects */
|
||||
.dashboard-card {
|
||||
transition: all 0.3s ease;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.dashboard-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 8px 25px rgba(0, 0, 0, 0.2),
|
||||
0 4px 12px rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
/* Icon container hover effects */
|
||||
.icon-container {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.icon-container:hover {
|
||||
transform: scale(1.05);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* Progress bar enhancements */
|
||||
.progress-bar-online {
|
||||
background: linear-gradient(90deg, rgba(76, 175, 80, 0.8), rgba(129, 199, 132, 0.6));
|
||||
}
|
||||
|
||||
.progress-bar-offline {
|
||||
background: linear-gradient(90deg, rgba(244, 67, 54, 0.8), rgba(239, 154, 154, 0.6));
|
||||
}
|
||||
|
||||
.progress-bar-connecting {
|
||||
background: linear-gradient(90deg, rgba(255, 193, 7, 0.8), rgba(255, 213, 79, 0.6));
|
||||
}
|
||||
|
||||
/* Table enhancements for dark theme */
|
||||
.dark .table-container {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.dark thead {
|
||||
background-color: #252d3a !important;
|
||||
}
|
||||
|
||||
.dark tbody tr {
|
||||
border-bottom: 1px solid #2f3b4c;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background-color: var(--color-gray-100) !important;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.dark tbody tr:hover {
|
||||
background-color: rgba(55, 65, 81, 0.5) !important;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Error box enhancements for dark theme */
|
||||
.dark .error-box {
|
||||
background-color: rgba(244, 67, 54, 0.1) !important;
|
||||
border-color: rgba(244, 67, 54, 0.3) !important;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.1);
|
||||
}
|
||||
|
||||
.dark .error-box h3 {
|
||||
color: rgba(239, 154, 154, 0.9) !important;
|
||||
}
|
||||
|
||||
.dark .error-box p {
|
||||
color: #d1d5db !important;
|
||||
}
|
||||
|
||||
/* Loading container enhancements */
|
||||
.loading-container {
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.dark .loading-container {
|
||||
background-color: rgba(31, 41, 55, 0.8) !important;
|
||||
border: 1px solid #2f3b4c;
|
||||
}
|
||||
|
||||
.label-primary {
|
||||
background-color: var(--color-blue-50) !important;
|
||||
color: var(--color-blue-500) !important;
|
||||
}
|
||||
|
||||
.dark .label-primary {
|
||||
background-color: rgba(59, 130, 246, 0.15) !important;
|
||||
color: rgba(96, 165, 250, 0.9) !important;
|
||||
}
|
||||
|
||||
.label-secondary {
|
||||
background-color: var(--color-green-50) !important;
|
||||
color: var(--color-green-500) !important;
|
||||
}
|
||||
|
||||
.dark .label-secondary {
|
||||
background-color: rgba(76, 175, 80, 0.15) !important;
|
||||
color: rgba(129, 199, 132, 0.9) !important;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #60a5fa !important;
|
||||
color: #ffffff !important;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 4px rgba(96, 165, 250, 0.2);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #3b82f6 !important;
|
||||
color: #ffffff !important;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
/* Enhanced button styles for dark theme */
|
||||
.dark .btn-primary {
|
||||
background-color: rgba(59, 130, 246, 0.15) !important;
|
||||
color: rgba(96, 165, 250, 0.9) !important;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.dark .btn-primary:hover {
|
||||
background-color: rgba(59, 130, 246, 0.25) !important;
|
||||
color: rgba(96, 165, 250, 1) !important;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #f9fafb !important;
|
||||
color: #374151 !important;
|
||||
border: 1px solid #d1d5db !important;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #e5e7eb !important;
|
||||
color: #374151 !important;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dark .btn-secondary {
|
||||
background-color: rgba(107, 114, 128, 0.15) !important;
|
||||
color: rgba(156, 163, 175, 0.9) !important;
|
||||
border: 1px solid rgba(107, 114, 128, 0.3) !important;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.dark .btn-secondary:hover {
|
||||
background-color: rgba(107, 114, 128, 0.25) !important;
|
||||
color: rgba(156, 163, 175, 1) !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background-color: var(--color-yellow-100) !important;
|
||||
color: var(--color-yellow-800) !important;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background-color: var(--color-yellow-200) !important;
|
||||
color: var(--color-yellow-800) !important;
|
||||
}
|
||||
|
||||
.dark .btn-warning {
|
||||
background-color: rgba(234, 179, 8, 0.15) !important;
|
||||
color: rgba(250, 204, 21, 0.9) !important;
|
||||
border: 1px solid rgba(234, 179, 8, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.dark .btn-warning:hover {
|
||||
background-color: rgba(234, 179, 8, 0.25) !important;
|
||||
color: rgba(250, 204, 21, 1) !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--color-red-100) !important;
|
||||
color: var(--color-red-800) !important;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: var(--color-red-200) !important;
|
||||
color: var(--color-red-800) !important;
|
||||
}
|
||||
|
||||
.dark .btn-danger {
|
||||
background-color: rgba(244, 67, 54, 0.15) !important;
|
||||
color: rgba(239, 154, 154, 0.9) !important;
|
||||
border: 1px solid rgba(244, 67, 54, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.dark .btn-danger:hover {
|
||||
background-color: rgba(244, 67, 54, 0.25) !important;
|
||||
color: rgba(239, 154, 154, 1) !important;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.2);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
background-color: #f9fafb !important;
|
||||
border-color: #d1d5db !important;
|
||||
color: #374151 !important;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: rgba(184, 193, 207, 0.5);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* Form input enhancements for dark theme */
|
||||
.dark .form-input {
|
||||
background-color: #1f2937 !important;
|
||||
border-color: #2f3b4c !important;
|
||||
color: #e5e7eb !important;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.dark .form-input:focus {
|
||||
border-color: rgba(59, 130, 246, 0.5) !important;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1) !important;
|
||||
}
|
||||
|
||||
.dark .form-input::placeholder {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
/* Card spacing and layout improvements */
|
||||
.page-card {
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.page-card:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.dark .page-card {
|
||||
background-color: #1f2937 !important;
|
||||
border: 1px solid #2f3b4c;
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.15),
|
||||
0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Custom text color to match status-icon-red */
|
||||
.text-status-red {
|
||||
color: #991b1b; /* Tailwind red-800 for light mode */
|
||||
}
|
||||
|
||||
.dark .text-status-red {
|
||||
color: rgba(239, 154, 154, 0.9) !important;
|
||||
}
|
||||
|
||||
.border-red {
|
||||
border-color: #937d7d; /* Tailwind red-800 for light mode */
|
||||
}
|
||||
|
||||
.dark .border-red {
|
||||
border-color: rgba(188, 161, 161, 0.9) !important;
|
||||
}
|
||||
|
||||
.dark .text-status-green {
|
||||
color: rgba(129, 199, 132, 0.9) !important;
|
||||
}
|
||||
|
||||
/* Empty state styling */
|
||||
.dark .empty-state {
|
||||
background-color: #1f2937 !important;
|
||||
border: 1px solid #2f3b4c;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
|
||||
.dark .empty-state p {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
/* Login page enhancements for dark theme */
|
||||
.dark .login-container {
|
||||
background-color: #1f2a37 !important;
|
||||
}
|
||||
|
||||
.dark .login-card {
|
||||
background-color: #1f2937 !important;
|
||||
border: 1px solid #2f3b4c;
|
||||
box-shadow:
|
||||
0 8px 25px rgba(0, 0, 0, 0.2),
|
||||
0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import App from './App';
|
||||
import './index.css';
|
||||
// Import the i18n configuration
|
||||
import './i18n';
|
||||
// Setup fetch interceptors
|
||||
import './utils/setupInterceptors';
|
||||
import { loadRuntimeConfig } from './utils/runtime';
|
||||
|
||||
// Load runtime configuration before starting the app
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useServerData } from '@/hooks/useServerData';
|
||||
import { ServerStatus } from '@/types';
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -22,26 +21,20 @@ const DashboardPage: React.FC = () => {
|
||||
connecting: 'status.connecting'
|
||||
}
|
||||
|
||||
// Calculate percentage for each status (for dashboard display)
|
||||
const getStatusPercentage = (status: ServerStatus) => {
|
||||
if (servers.length === 0) return 0;
|
||||
return Math.round((servers.filter(server => server.status === status).length / servers.length) * 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-8">{t('pages.dashboard.title')}</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm">
|
||||
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm error-box">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-red-600 text-lg font-medium">{t('app.error')}</h3>
|
||||
<h3 className="text-status-red text-lg font-medium">{t('app.error')}</h3>
|
||||
<p className="text-gray-600 mt-1">{error}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="ml-4 text-gray-500 hover:text-gray-700"
|
||||
className="ml-4 text-gray-500 hover:text-gray-700 transition-colors duration-200"
|
||||
aria-label={t('app.closeButton')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
@@ -52,8 +45,8 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center">
|
||||
{isLoading && (
|
||||
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center loading-container">
|
||||
<div className="flex flex-col items-center">
|
||||
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
@@ -62,12 +55,14 @@ const DashboardPage: React.FC = () => {
|
||||
<p className="text-gray-600">{t('app.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
)}
|
||||
|
||||
{!isLoading && (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Total servers */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-blue-100 text-blue-800">
|
||||
<div className="p-3 rounded-full bg-blue-100 text-blue-800 icon-container status-icon-blue">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
@@ -80,9 +75,9 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Online servers */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-green-100 text-green-800">
|
||||
<div className="p-3 rounded-full bg-green-100 text-green-800 icon-container status-icon-green">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
@@ -92,18 +87,12 @@ const DashboardPage: React.FC = () => {
|
||||
<p className="text-3xl font-bold text-gray-900">{serverStats.online}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 h-2 bg-gray-200 rounded-full">
|
||||
<div
|
||||
className="h-full bg-green-500 rounded-full"
|
||||
style={{ width: `${getStatusPercentage('connected')}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Offline servers */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-red-100 text-red-800">
|
||||
<div className="p-3 rounded-full bg-red-100 text-red-800 icon-container status-icon-red">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
@@ -113,18 +102,12 @@ const DashboardPage: React.FC = () => {
|
||||
<p className="text-3xl font-bold text-gray-900">{serverStats.offline}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 h-2 bg-gray-200 rounded-full">
|
||||
<div
|
||||
className="h-full bg-red-500 rounded-full"
|
||||
style={{ width: `${getStatusPercentage('disconnected')}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connecting servers */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-yellow-100 text-yellow-800">
|
||||
<div className="p-3 rounded-full bg-yellow-100 text-yellow-800 icon-container status-icon-yellow">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
@@ -134,12 +117,7 @@ const DashboardPage: React.FC = () => {
|
||||
<p className="text-3xl font-bold text-gray-900">{serverStats.connecting}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 h-2 bg-gray-200 rounded-full">
|
||||
<div
|
||||
className="h-full bg-yellow-500 rounded-full"
|
||||
style={{ width: `${getStatusPercentage('connecting')}%` }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -148,20 +126,20 @@ const DashboardPage: React.FC = () => {
|
||||
{servers.length > 0 && !isLoading && (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">{t('pages.dashboard.recentServers')}</h2>
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden table-container">
|
||||
<table className="min-w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('server.name')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('server.status')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('server.tools')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('server.enabled')}
|
||||
</th>
|
||||
</tr>
|
||||
@@ -173,11 +151,11 @@ const DashboardPage: React.FC = () => {
|
||||
{server.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${server.status === 'connected'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: server.status === 'disconnected'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${server.status === 'connected'
|
||||
? 'status-badge-online'
|
||||
: server.status === 'disconnected'
|
||||
? 'status-badge-offline'
|
||||
: 'status-badge-connecting'
|
||||
}`}>
|
||||
{t(statusTranslations[server.status] || server.status)}
|
||||
</span>
|
||||
@@ -189,7 +167,7 @@ const DashboardPage: React.FC = () => {
|
||||
{server.enabled !== false ? (
|
||||
<span className="text-green-600">✓</span>
|
||||
) : (
|
||||
<span className="text-red-600">✗</span>
|
||||
<span className="text-status-red">✗</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -9,16 +9,16 @@ import GroupCard from '@/components/GroupCard';
|
||||
|
||||
const GroupsPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
groups,
|
||||
loading: groupsLoading,
|
||||
error: groupError,
|
||||
const {
|
||||
groups,
|
||||
loading: groupsLoading,
|
||||
error: groupError,
|
||||
setError: setGroupError,
|
||||
deleteGroup,
|
||||
triggerRefresh
|
||||
} = useGroupData();
|
||||
const { servers } = useServerData();
|
||||
|
||||
|
||||
const [editingGroup, setEditingGroup] = useState<Group | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
|
||||
@@ -32,9 +32,9 @@ const GroupsPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleDeleteGroup = async (groupId: string) => {
|
||||
const success = await deleteGroup(groupId);
|
||||
if (!success) {
|
||||
setGroupError(t('groups.deleteError'));
|
||||
const result = await deleteGroup(groupId);
|
||||
if (!result || !result.success) {
|
||||
setGroupError(result?.message || t('groups.deleteError'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -54,7 +54,7 @@ const GroupsPage: React.FC = () => {
|
||||
<div className="flex space-x-4">
|
||||
<button
|
||||
onClick={handleAddGroup}
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center"
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 3a1 1 0 00-1 1v5H4a1 1 0 100 2h5v5a1 1 0 102 0v-5h5a1 1 0 100-2h-5V4a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
@@ -65,13 +65,13 @@ const GroupsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{groupError && (
|
||||
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6">
|
||||
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6 error-box rounded-lg">
|
||||
<p>{groupError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupsLoading ? (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<div className="bg-white shadow rounded-lg p-6 loading-container">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
@@ -81,7 +81,7 @@ const GroupsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<div className="bg-white shadow rounded-lg p-6 empty-state">
|
||||
<p className="text-gray-600">{t('groups.noGroups')}</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ThemeSwitch from '@/components/ui/ThemeSwitch';
|
||||
import LanguageSwitch from '@/components/ui/LanguageSwitch';
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -26,7 +27,7 @@ const LoginPage: React.FC = () => {
|
||||
}
|
||||
|
||||
const success = await login(username, password);
|
||||
|
||||
|
||||
if (success) {
|
||||
navigate('/');
|
||||
} else {
|
||||
@@ -40,18 +41,19 @@ const LoginPage: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="absolute top-4 right-4">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8 login-container">
|
||||
<div className="absolute top-4 right-4 w-full max-w-xs flex justify-end">
|
||||
<ThemeSwitch />
|
||||
<LanguageSwitch />
|
||||
</div>
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="max-w-md w-full space-y-8 login-card p-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
{t('auth.loginTitle')}
|
||||
</h2>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="sr-only">
|
||||
{t('auth.username')}
|
||||
@@ -62,7 +64,7 @@ const LoginPage: React.FC = () => {
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
className="appearance-none relative block w-full px-3 py-3 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm transition-all duration-200 form-input shadow-sm"
|
||||
placeholder={t('auth.username')}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
@@ -78,7 +80,7 @@ const LoginPage: React.FC = () => {
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
className="appearance-none relative block w-full px-3 py-3 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm login-input transition-all duration-200 form-input shadow-sm"
|
||||
placeholder={t('auth.password')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
@@ -87,14 +89,14 @@ const LoginPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-500 dark:text-red-400 text-sm text-center">{error}</div>
|
||||
<div className="text-red-500 dark:text-red-400 text-sm text-center error-box p-2 rounded">{error}</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 login-button transition-all duration-200 btn-primary"
|
||||
>
|
||||
{loading ? t('auth.loggingIn') : t('auth.login')}
|
||||
</button>
|
||||
|
||||
@@ -11,9 +11,9 @@ const LogsPage: React.FC = () => {
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="text-2xl font-bold">{t('pages.logs.title')}</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{t('pages.logs.title')}</h1>
|
||||
</div>
|
||||
<div className="bg-card rounded-md shadow-sm">
|
||||
<div className="bg-card rounded-md shadow-sm border border-gray-200 page-card">
|
||||
<LogViewer
|
||||
logs={logs}
|
||||
isLoading={loading}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { MarketServer, ServerConfig } from '@/types';
|
||||
import { useMarketData } from '@/hooks/useMarketData';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
@@ -11,15 +11,13 @@ import Pagination from '@/components/ui/Pagination';
|
||||
const MarketPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { serverName } = useParams<{ serverName?: string }>();
|
||||
const { showToast } = useToast();
|
||||
|
||||
|
||||
const {
|
||||
servers,
|
||||
allServers,
|
||||
categories,
|
||||
tags,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
@@ -42,7 +40,6 @@ const MarketPage: React.FC = () => {
|
||||
const [selectedServer, setSelectedServer] = useState<MarketServer | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [showTags, setShowTags] = useState(false);
|
||||
|
||||
// Load server details if a server name is in the URL
|
||||
useEffect(() => {
|
||||
@@ -59,7 +56,7 @@ const MarketPage: React.FC = () => {
|
||||
setSelectedServer(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
loadServerDetails();
|
||||
}, [serverName, fetchServerByName, navigate]);
|
||||
|
||||
@@ -72,10 +69,6 @@ const MarketPage: React.FC = () => {
|
||||
filterByCategory(category);
|
||||
};
|
||||
|
||||
const handleTagClick = (tag: string) => {
|
||||
filterByTag(tag);
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSearchQuery('');
|
||||
filterByCategory('');
|
||||
@@ -115,10 +108,6 @@ const MarketPage: React.FC = () => {
|
||||
changeServersPerPage(newValue);
|
||||
};
|
||||
|
||||
const toggleTagsVisibility = () => {
|
||||
setShowTags(!showTags);
|
||||
};
|
||||
|
||||
// Render detailed view if a server is selected
|
||||
if (selectedServer) {
|
||||
return (
|
||||
@@ -144,12 +133,12 @@ const MarketPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6">
|
||||
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6 error-box rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<p>{error}</p>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="text-red-700 hover:text-red-900"
|
||||
className="text-red-700 hover:text-red-900 transition-colors duration-200"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 01.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
|
||||
@@ -160,7 +149,7 @@ const MarketPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Search bar at the top */}
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6 page-card">
|
||||
<form onSubmit={handleSearch} className="flex space-x-4 mb-0">
|
||||
<div className="flex-grow">
|
||||
<input
|
||||
@@ -168,12 +157,12 @@ const MarketPage: React.FC = () => {
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('market.searchPlaceholder')}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
className="shadow appearance-none border border-gray-200 rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded"
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
|
||||
>
|
||||
{t('market.search')}
|
||||
</button>
|
||||
@@ -181,7 +170,7 @@ const MarketPage: React.FC = () => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearFilters}
|
||||
className="border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded hover:bg-gray-50"
|
||||
className="border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded hover:bg-gray-50 btn-secondary transition-all duration-200"
|
||||
>
|
||||
{t('market.clearFilters')}
|
||||
</button>
|
||||
@@ -192,14 +181,14 @@ const MarketPage: React.FC = () => {
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
{/* Left sidebar for filters (without search) */}
|
||||
<div className="md:w-48 flex-shrink-0">
|
||||
<div className="bg-white shadow rounded-lg p-4 mb-6 sticky top-4">
|
||||
<div className="bg-white shadow rounded-lg p-4 mb-6 sticky top-4 page-card">
|
||||
{/* Categories */}
|
||||
{categories.length > 0 ? (
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
|
||||
{selectedCategory && (
|
||||
<span className="text-xs text-blue-600 cursor-pointer hover:underline" onClick={() => filterByCategory('')}>
|
||||
<span className="text-xs text-blue-600 cursor-pointer hover:underline transition-colors duration-200" onClick={() => filterByCategory('')}>
|
||||
{t('market.clearCategoryFilter')}
|
||||
</span>
|
||||
)}
|
||||
@@ -209,9 +198,9 @@ const MarketPage: React.FC = () => {
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => handleCategoryClick(category)}
|
||||
className={`px-3 py-2 rounded text-sm text-left ${selectedCategory === category
|
||||
? 'bg-blue-100 text-blue-800 font-medium'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
||||
className={`px-3 py-2 rounded text-sm text-left transition-all duration-200 ${selectedCategory === category
|
||||
? 'bg-blue-100 text-blue-800 font-medium btn-primary'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-secondary'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
@@ -224,7 +213,7 @@ const MarketPage: React.FC = () => {
|
||||
<div className="mb-3">
|
||||
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 items-center py-4">
|
||||
<div className="flex flex-col gap-2 items-center py-4 loading-container">
|
||||
<svg className="animate-spin h-6 w-6 text-blue-500 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
@@ -333,7 +322,7 @@ const MarketPage: React.FC = () => {
|
||||
id="perPage"
|
||||
value={serversPerPage}
|
||||
onChange={handleChangeItemsPerPage}
|
||||
className="border rounded p-1 text-sm"
|
||||
className="border rounded p-1 text-sm btn-secondary outline-none"
|
||||
>
|
||||
<option value="6">6</option>
|
||||
<option value="9">9</option>
|
||||
|
||||
@@ -6,6 +6,7 @@ import ServerCard from '@/components/ServerCard';
|
||||
import AddServerForm from '@/components/AddServerForm';
|
||||
import EditServerForm from '@/components/EditServerForm';
|
||||
import { useServerData } from '@/hooks/useServerData';
|
||||
import DxtUploadForm from '@/components/DxtUploadForm';
|
||||
|
||||
const ServersPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -23,6 +24,7 @@ const ServersPage: React.FC = () => {
|
||||
} = useServerData();
|
||||
const [editingServer, setEditingServer] = useState<Server | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [showDxtUpload, setShowDxtUpload] = useState(false);
|
||||
|
||||
const handleEditClick = async (server: Server) => {
|
||||
const fullServerData = await handleServerEdit(server);
|
||||
@@ -47,6 +49,12 @@ const ServersPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDxtUploadSuccess = (_serverConfig: any) => {
|
||||
// Close upload dialog and refresh servers
|
||||
setShowDxtUpload(false);
|
||||
triggerRefresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
@@ -54,7 +62,7 @@ const ServersPage: React.FC = () => {
|
||||
<div className="flex space-x-4">
|
||||
<button
|
||||
onClick={() => navigate('/market')}
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center"
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M3 1a1 1 0 000 2h1.22l.305 1.222a.997.997 0 00.01.042l1.358 5.43-.893.892C3.74 11.846 4.632 14 6.414 14H15a1 1 0 000-2H6.414l1-1H14a1 1 0 00.894-.553l3-6A1 1 0 0017 3H6.28l-.31-1.243A1 1 0 005 1H3z" />
|
||||
@@ -62,10 +70,19 @@ const ServersPage: React.FC = () => {
|
||||
{t('nav.market')}
|
||||
</button>
|
||||
<AddServerForm onAdd={handleServerAdd} />
|
||||
<button
|
||||
onClick={() => setShowDxtUpload(true)}
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M5.5 13a3.5 3.5 0 01-.369-6.98 4 4 0 117.753-1.977A4.5 4.5 0 1113.5 13H11V9.413l1.293 1.293a1 1 0 001.414-1.414l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 001.414 1.414L9 9.413V13H5.5z" />
|
||||
</svg>
|
||||
{t('dxt.upload')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className={`px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center ${isRefreshing ? 'opacity-70 cursor-not-allowed' : ''}`}
|
||||
className={`px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200 ${isRefreshing ? 'opacity-70 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<svg className="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
@@ -83,15 +100,14 @@ const ServersPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm">
|
||||
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm error-box">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-red-600 text-lg font-medium">{t('app.error')}</h3>
|
||||
<p className="text-gray-600 mt-1">{error}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="ml-4 text-gray-500 hover:text-gray-700"
|
||||
className="ml-4 text-gray-500 hover:text-gray-700 transition-colors duration-200 btn-secondary"
|
||||
aria-label={t('app.closeButton')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
@@ -103,7 +119,7 @@ const ServersPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center">
|
||||
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center loading-container">
|
||||
<div className="flex flex-col items-center">
|
||||
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
@@ -113,7 +129,7 @@ const ServersPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<div className="bg-white shadow rounded-lg p-6 empty-state">
|
||||
<p className="text-gray-600">{t('app.noServers')}</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -138,6 +154,13 @@ const ServersPage: React.FC = () => {
|
||||
onCancel={() => setEditingServer(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDxtUpload && (
|
||||
<DxtUploadForm
|
||||
onSuccess={handleDxtUploadSuccess}
|
||||
onCancel={() => setShowDxtUpload(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,24 +6,22 @@ import { Switch } from '@/components/ui/ToggleGroup';
|
||||
import { useSettingsData } from '@/hooks/useSettingsData';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import { generateRandomKey } from '@/utils/key';
|
||||
import { PermissionChecker } from '@/components/PermissionChecker';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const [currentLanguage, setCurrentLanguage] = useState(i18n.language);
|
||||
|
||||
// Update current language when it changes
|
||||
useEffect(() => {
|
||||
setCurrentLanguage(i18n.language);
|
||||
}, [i18n.language]);
|
||||
|
||||
const [installConfig, setInstallConfig] = useState<{
|
||||
pythonIndexUrl: string;
|
||||
npmRegistry: string;
|
||||
baseUrl: string;
|
||||
}>({
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
});
|
||||
|
||||
const [tempSmartRoutingConfig, setTempSmartRoutingConfig] = useState<{
|
||||
@@ -85,7 +83,7 @@ const SettingsPage: React.FC = () => {
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRoutingConfigChange = async (key: 'enableGlobalRoute' | 'enableGroupNameRoute' | 'enableBearerAuth' | 'bearerAuthKey', value: boolean | string) => {
|
||||
const handleRoutingConfigChange = async (key: 'enableGlobalRoute' | 'enableGroupNameRoute' | 'enableBearerAuth' | 'bearerAuthKey' | 'skipAuth', value: boolean | string) => {
|
||||
// If enableBearerAuth is turned on and there's no key, generate one first
|
||||
if (key === 'enableBearerAuth' && value === true) {
|
||||
if (!tempRoutingConfig.bearerAuthKey && !routingConfig.bearerAuthKey) {
|
||||
@@ -123,14 +121,14 @@ const SettingsPage: React.FC = () => {
|
||||
await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey);
|
||||
};
|
||||
|
||||
const handleInstallConfigChange = (key: 'pythonIndexUrl' | 'npmRegistry', value: string) => {
|
||||
const handleInstallConfigChange = (key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl', value: string) => {
|
||||
setInstallConfig({
|
||||
...installConfig,
|
||||
[key]: value
|
||||
});
|
||||
};
|
||||
|
||||
const saveInstallConfig = async (key: 'pythonIndexUrl' | 'npmRegistry') => {
|
||||
const saveInstallConfig = async (key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl') => {
|
||||
await updateInstallConfig(key, installConfig[key]);
|
||||
};
|
||||
|
||||
@@ -193,166 +191,136 @@ const SettingsPage: React.FC = () => {
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleLanguageChange = (lang: string) => {
|
||||
localStorage.setItem('i18nextLng', lang);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-8">{t('pages.settings.title')}</h1>
|
||||
|
||||
{/* Language Settings */}
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold text-gray-800">{t('pages.settings.language')}</h2>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
className={`px-3 py-1.5 rounded-md transition-colors text-sm ${currentLanguage.startsWith('en')
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-blue-100 text-blue-800 hover:bg-blue-200'
|
||||
}`}
|
||||
onClick={() => handleLanguageChange('en')}
|
||||
>
|
||||
English
|
||||
</button>
|
||||
<button
|
||||
className={`px-3 py-1.5 rounded-md transition-colors text-sm ${currentLanguage.startsWith('zh')
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-blue-100 text-blue-800 hover:bg-blue-200'
|
||||
}`}
|
||||
onClick={() => handleLanguageChange('zh')}
|
||||
>
|
||||
中文
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Smart Routing Configuration Settings */}
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('smartRoutingConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('pages.settings.smartRouting')}</h2>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.smartRoutingConfig ? '▼' : '►'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.smartRoutingConfig && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableSmartRouting')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.enableSmartRoutingDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={smartRoutingConfig.enabled}
|
||||
onCheckedChange={(checked) => handleSmartRoutingEnabledChange(checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">
|
||||
<span className="text-red-500 px-1">*</span>{t('settings.dbUrl')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempSmartRoutingConfig.dbUrl}
|
||||
onChange={(e) => handleSmartRoutingConfigChange('dbUrl', e.target.value)}
|
||||
placeholder={t('settings.dbUrlPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveSmartRoutingConfig('dbUrl')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">
|
||||
<span className="text-red-500 px-1">*</span>{t('settings.openaiApiKey')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="password"
|
||||
value={tempSmartRoutingConfig.openaiApiKey}
|
||||
onChange={(e) => handleSmartRoutingConfigChange('openaiApiKey', e.target.value)}
|
||||
placeholder={t('settings.openaiApiKeyPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveSmartRoutingConfig('openaiApiKey')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.openaiApiBaseUrl')}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempSmartRoutingConfig.openaiApiBaseUrl}
|
||||
onChange={(e) => handleSmartRoutingConfigChange('openaiApiBaseUrl', e.target.value)}
|
||||
placeholder={t('settings.openaiApiBaseUrlPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveSmartRoutingConfig('openaiApiBaseUrl')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.openaiApiEmbeddingModel')}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempSmartRoutingConfig.openaiApiEmbeddingModel}
|
||||
onChange={(e) => handleSmartRoutingConfigChange('openaiApiEmbeddingModel', e.target.value)}
|
||||
placeholder={t('settings.openaiApiEmbeddingModelPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveSmartRoutingConfig('openaiApiEmbeddingModel')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<PermissionChecker permissions={PERMISSIONS.SETTINGS_SMART_ROUTING}>
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 page-card">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer transition-colors duration-200 hover:text-blue-600"
|
||||
onClick={() => toggleSection('smartRoutingConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('pages.settings.smartRouting')}</h2>
|
||||
<span className="text-gray-500 transition-transform duration-200">
|
||||
{sectionsVisible.smartRoutingConfig ? '▼' : '►'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sectionsVisible.smartRoutingConfig && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableSmartRouting')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.enableSmartRoutingDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={smartRoutingConfig.enabled}
|
||||
onCheckedChange={(checked) => handleSmartRoutingEnabledChange(checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">
|
||||
<span className="text-red-500 px-1">*</span>{t('settings.dbUrl')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempSmartRoutingConfig.dbUrl}
|
||||
onChange={(e) => handleSmartRoutingConfigChange('dbUrl', e.target.value)}
|
||||
placeholder={t('settings.dbUrlPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300 form-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveSmartRoutingConfig('dbUrl')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">
|
||||
<span className="text-red-500 px-1">*</span>{t('settings.openaiApiKey')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="password"
|
||||
value={tempSmartRoutingConfig.openaiApiKey}
|
||||
onChange={(e) => handleSmartRoutingConfigChange('openaiApiKey', e.target.value)}
|
||||
placeholder={t('settings.openaiApiKeyPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveSmartRoutingConfig('openaiApiKey')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.openaiApiBaseUrl')}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempSmartRoutingConfig.openaiApiBaseUrl}
|
||||
onChange={(e) => handleSmartRoutingConfigChange('openaiApiBaseUrl', e.target.value)}
|
||||
placeholder={t('settings.openaiApiBaseUrlPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveSmartRoutingConfig('openaiApiBaseUrl')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.openaiApiEmbeddingModel')}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempSmartRoutingConfig.openaiApiEmbeddingModel}
|
||||
onChange={(e) => handleSmartRoutingConfigChange('openaiApiEmbeddingModel', e.target.value)}
|
||||
placeholder={t('settings.openaiApiEmbeddingModelPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveSmartRoutingConfig('openaiApiEmbeddingModel')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
|
||||
{/* Route Configuration Settings */}
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
||||
@@ -392,13 +360,13 @@ const SettingsPage: React.FC = () => {
|
||||
value={tempRoutingConfig.bearerAuthKey}
|
||||
onChange={(e) => handleBearerAuthKeyChange(e.target.value)}
|
||||
placeholder={t('settings.bearerAuthKeyPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading || !routingConfig.enableBearerAuth}
|
||||
/>
|
||||
<button
|
||||
onClick={saveBearerAuthKey}
|
||||
disabled={loading || !routingConfig.enableBearerAuth}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
@@ -430,74 +398,114 @@ const SettingsPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PermissionChecker permissions={PERMISSIONS.SETTINGS_SKIP_AUTH}>
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.skipAuth')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.skipAuthDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.skipAuth}
|
||||
onCheckedChange={(checked) => handleRoutingConfigChange('skipAuth', checked)}
|
||||
/>
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Installation Configuration Settings */}
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('installConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('settings.installConfig')}</h2>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.installConfig ? '▼' : '►'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.installConfig && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.pythonIndexUrl')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.pythonIndexUrlDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={installConfig.pythonIndexUrl}
|
||||
onChange={(e) => handleInstallConfigChange('pythonIndexUrl', e.target.value)}
|
||||
placeholder={t('settings.pythonIndexUrlPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveInstallConfig('pythonIndexUrl')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.npmRegistry')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.npmRegistryDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={installConfig.npmRegistry}
|
||||
onChange={(e) => handleInstallConfigChange('npmRegistry', e.target.value)}
|
||||
placeholder={t('settings.npmRegistryPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveInstallConfig('npmRegistry')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<PermissionChecker permissions={PERMISSIONS.SETTINGS_INSTALL_CONFIG}>
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('installConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('settings.installConfig')}</h2>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.installConfig ? '▼' : '►'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sectionsVisible.installConfig && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.baseUrl')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.baseUrlDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={installConfig.baseUrl}
|
||||
onChange={(e) => handleInstallConfigChange('baseUrl', e.target.value)}
|
||||
placeholder={t('settings.baseUrlPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveInstallConfig('baseUrl')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.pythonIndexUrl')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.pythonIndexUrlDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={installConfig.pythonIndexUrl}
|
||||
onChange={(e) => handleInstallConfigChange('pythonIndexUrl', e.target.value)}
|
||||
placeholder={t('settings.pythonIndexUrlPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveInstallConfig('pythonIndexUrl')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.npmRegistry')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.npmRegistryDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={installConfig.npmRegistry}
|
||||
onChange={(e) => handleInstallConfigChange('npmRegistry', e.target.value)}
|
||||
placeholder={t('settings.npmRegistryPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveInstallConfig('npmRegistry')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
|
||||
{/* Change Password */}
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
||||
|
||||
9
frontend/src/pages/UsersPage.tsx
Normal file
9
frontend/src/pages/UsersPage.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
const UsersPage: React.FC = () => {
|
||||
return (
|
||||
<div></div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsersPage;
|
||||
@@ -4,45 +4,27 @@ import {
|
||||
RegisterCredentials,
|
||||
ChangePasswordCredentials,
|
||||
} from '../types';
|
||||
import { getApiUrl } from '../utils/runtime';
|
||||
import { apiPost, apiGet } from '../utils/fetchInterceptor';
|
||||
import { getToken, setToken, removeToken } from '../utils/interceptors';
|
||||
|
||||
// Token key in localStorage
|
||||
const TOKEN_KEY = 'mcphub_token';
|
||||
|
||||
// Get token from localStorage
|
||||
export const getToken = (): string | null => {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
};
|
||||
|
||||
// Set token in localStorage
|
||||
export const setToken = (token: string): void => {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
};
|
||||
|
||||
// Remove token from localStorage
|
||||
export const removeToken = (): void => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
};
|
||||
// Export token management functions
|
||||
export { getToken, setToken, removeToken };
|
||||
|
||||
// Login user
|
||||
export const login = async (credentials: LoginCredentials): Promise<AuthResponse> => {
|
||||
try {
|
||||
console.log(getApiUrl('/auth/login'));
|
||||
const response = await fetch(getApiUrl('/auth/login'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(credentials),
|
||||
});
|
||||
const response = await apiPost<AuthResponse>('/auth/login', credentials);
|
||||
|
||||
const data: AuthResponse = await response.json();
|
||||
|
||||
if (data.success && data.token) {
|
||||
setToken(data.token);
|
||||
// The auth API returns data directly, not wrapped in a data field
|
||||
if (response.success && response.token) {
|
||||
setToken(response.token);
|
||||
return response;
|
||||
}
|
||||
|
||||
return data;
|
||||
return {
|
||||
success: false,
|
||||
message: response.message || 'Login failed',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return {
|
||||
@@ -55,21 +37,17 @@ export const login = async (credentials: LoginCredentials): Promise<AuthResponse
|
||||
// Register user
|
||||
export const register = async (credentials: RegisterCredentials): Promise<AuthResponse> => {
|
||||
try {
|
||||
const response = await fetch(getApiUrl('/auth/register'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(credentials),
|
||||
});
|
||||
const response = await apiPost<AuthResponse>('/auth/register', credentials);
|
||||
|
||||
const data: AuthResponse = await response.json();
|
||||
|
||||
if (data.success && data.token) {
|
||||
setToken(data.token);
|
||||
if (response.success && response.token) {
|
||||
setToken(response.token);
|
||||
return response;
|
||||
}
|
||||
|
||||
return data;
|
||||
return {
|
||||
success: false,
|
||||
message: response.message || 'Registration failed',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Register error:', error);
|
||||
return {
|
||||
@@ -91,14 +69,8 @@ export const getCurrentUser = async (): Promise<AuthResponse> => {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(getApiUrl('/auth/user'), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-auth-token': token,
|
||||
},
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
const response = await apiGet<AuthResponse>('/auth/user');
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Get current user error:', error);
|
||||
return {
|
||||
@@ -122,16 +94,8 @@ export const changePassword = async (
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(getApiUrl('/auth/change-password'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token,
|
||||
},
|
||||
body: JSON.stringify(credentials),
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
const response = await apiPost<AuthResponse>('/auth/change-password', credentials);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Change password error:', error);
|
||||
return {
|
||||
|
||||
98
frontend/src/services/configService.ts
Normal file
98
frontend/src/services/configService.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { apiGet, fetchWithInterceptors } from '../utils/fetchInterceptor';
|
||||
import { getBasePath } from '../utils/runtime';
|
||||
|
||||
export interface SystemConfig {
|
||||
routing?: {
|
||||
enableGlobalRoute?: boolean;
|
||||
enableGroupNameRoute?: boolean;
|
||||
enableBearerAuth?: boolean;
|
||||
bearerAuthKey?: string;
|
||||
skipAuth?: boolean;
|
||||
};
|
||||
install?: {
|
||||
pythonIndexUrl?: string;
|
||||
npmRegistry?: string;
|
||||
};
|
||||
smartRouting?: {
|
||||
enabled?: boolean;
|
||||
dbUrl?: string;
|
||||
openaiApiBaseUrl?: string;
|
||||
openaiApiKey?: string;
|
||||
openaiApiEmbeddingModel?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PublicConfigResponse {
|
||||
success: boolean;
|
||||
data?: {
|
||||
skipAuth?: boolean;
|
||||
permissions?: any;
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface SystemConfigResponse {
|
||||
success: boolean;
|
||||
data?: {
|
||||
systemConfig?: SystemConfig;
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public configuration (skipAuth setting) without authentication
|
||||
*/
|
||||
export const getPublicConfig = async (): Promise<{ skipAuth: boolean; permissions?: any }> => {
|
||||
try {
|
||||
const basePath = getBasePath();
|
||||
const response = await fetchWithInterceptors(`${basePath}/public-config`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data: PublicConfigResponse = await response.json();
|
||||
return { skipAuth: data.data?.skipAuth === true, permissions: data.data?.permissions || {} };
|
||||
}
|
||||
|
||||
return { skipAuth: false };
|
||||
} catch (error) {
|
||||
console.debug('Failed to get public config:', error);
|
||||
return { skipAuth: false };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get system configuration without authentication
|
||||
* This function tries to get the system configuration first without auth,
|
||||
* and if that fails (likely due to auth requirements), it returns null
|
||||
*/
|
||||
export const getSystemConfigPublic = async (): Promise<SystemConfig | null> => {
|
||||
try {
|
||||
const response = await apiGet<SystemConfigResponse>('/settings');
|
||||
|
||||
if (response.success) {
|
||||
return response.data?.systemConfig || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.debug('Failed to get system config without auth:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if authentication should be skipped based on system configuration
|
||||
*/
|
||||
export const shouldSkipAuth = async (): Promise<boolean> => {
|
||||
try {
|
||||
const config = await getPublicConfig();
|
||||
return config.skipAuth;
|
||||
} catch (error) {
|
||||
console.debug('Failed to check skipAuth setting:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getToken } from './authService'; // Import getToken function
|
||||
import { apiGet, apiDelete } from '../utils/fetchInterceptor';
|
||||
import { getApiUrl } from '../utils/runtime';
|
||||
import { getToken } from '../utils/interceptors';
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: number;
|
||||
@@ -13,25 +14,13 @@ export interface LogEntry {
|
||||
// Fetch all logs
|
||||
export const fetchLogs = async (): Promise<LogEntry[]> => {
|
||||
try {
|
||||
// Get authentication token
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
throw new Error('Authentication token not found. Please log in.');
|
||||
const response = await apiGet<{ success: boolean; data: LogEntry[]; error?: string }>('/logs');
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to fetch logs');
|
||||
}
|
||||
|
||||
const response = await fetch(getApiUrl('/logs'), {
|
||||
headers: {
|
||||
'x-auth-token': token,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to fetch logs');
|
||||
}
|
||||
|
||||
return result.data;
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching logs:', error);
|
||||
throw error;
|
||||
@@ -41,23 +30,10 @@ export const fetchLogs = async (): Promise<LogEntry[]> => {
|
||||
// Clear all logs
|
||||
export const clearLogs = async (): Promise<void> => {
|
||||
try {
|
||||
// Get authentication token
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
throw new Error('Authentication token not found. Please log in.');
|
||||
}
|
||||
const response = await apiDelete<{ success: boolean; error?: string }>('/logs');
|
||||
|
||||
const response = await fetch(getApiUrl('/logs'), {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-auth-token': token,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to clear logs');
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to clear logs');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error clearing logs:', error);
|
||||
@@ -84,12 +60,6 @@ export const useLogs = () => {
|
||||
|
||||
// Get the authentication token
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
setError(new Error('Authentication token not found. Please log in.'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Connect to SSE endpoint with auth token in URL
|
||||
eventSource = new EventSource(getApiUrl(`/logs/stream?token=${token}`));
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getApiUrl } from '../utils/runtime';
|
||||
import { getToken } from './authService';
|
||||
import { apiPost, apiPut } from '../utils/fetchInterceptor';
|
||||
|
||||
export interface ToolCallRequest {
|
||||
toolName: string;
|
||||
@@ -25,42 +24,32 @@ export const callTool = async (
|
||||
server?: string,
|
||||
): Promise<ToolCallResult> => {
|
||||
try {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
throw new Error('Authentication token not found. Please log in.');
|
||||
}
|
||||
|
||||
// Construct the URL with optional server parameter
|
||||
const url = server ? `/tools/call/${server}` : '/tools/call';
|
||||
|
||||
const response = await fetch(getApiUrl(url), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token,
|
||||
Authorization: `Bearer ${token}`, // Add bearer auth for MCP routing
|
||||
},
|
||||
body: JSON.stringify({
|
||||
const response = await apiPost<any>(
|
||||
url,
|
||||
{
|
||||
toolName: request.toolName,
|
||||
arguments: request.arguments,
|
||||
}),
|
||||
});
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('mcphub_token')}`, // Add bearer auth for MCP routing
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
if (!response.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: data.message || 'Tool call failed',
|
||||
error: response.message || 'Tool call failed',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
content: data.data.content || [],
|
||||
content: response.data?.content || [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error calling tool:', error);
|
||||
@@ -80,29 +69,19 @@ export const toggleTool = async (
|
||||
enabled: boolean,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
throw new Error('Authentication token not found. Please log in.');
|
||||
}
|
||||
|
||||
const response = await fetch(getApiUrl(`/servers/${serverName}/tools/${toolName}/toggle`), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token,
|
||||
Authorization: `Bearer ${token}`,
|
||||
const response = await apiPost<any>(
|
||||
`/servers/${serverName}/tools/${toolName}/toggle`,
|
||||
{ enabled },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('mcphub_token')}`,
|
||||
},
|
||||
},
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
success: data.success,
|
||||
error: data.success ? undefined : data.message,
|
||||
success: response.success,
|
||||
error: response.success ? undefined : response.message,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error toggling tool:', error);
|
||||
@@ -122,32 +101,19 @@ export const updateToolDescription = async (
|
||||
description: string,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
throw new Error('Authentication token not found. Please log in.');
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
getApiUrl(`/servers/${serverName}/tools/${toolName}/description`),
|
||||
const response = await apiPut<any>(
|
||||
`/servers/${serverName}/tools/${toolName}/description`,
|
||||
{ description },
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token,
|
||||
Authorization: `Bearer ${token}`,
|
||||
Authorization: `Bearer ${localStorage.getItem('mcphub_token')}`,
|
||||
},
|
||||
body: JSON.stringify({ description }),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
success: data.success,
|
||||
error: data.success ? undefined : data.message,
|
||||
success: response.success,
|
||||
error: response.success ? undefined : response.message,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error updating tool description:', error);
|
||||
|
||||
@@ -137,11 +137,17 @@ export interface Server {
|
||||
}
|
||||
|
||||
// Group types
|
||||
// Group server configuration - supports tool selection
|
||||
export interface IGroupServerConfig {
|
||||
name: string; // Server name
|
||||
tools?: string[] | 'all'; // Array of specific tool names to include, or 'all' for all tools (default: 'all')
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
servers: string[];
|
||||
servers: string[] | IGroupServerConfig[]; // Supports both old and new format
|
||||
}
|
||||
|
||||
// Environment variable types
|
||||
@@ -196,7 +202,7 @@ export interface ServerFormData {
|
||||
export interface GroupFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
servers: string[]; // Added servers array to include in form data
|
||||
servers: string[] | IGroupServerConfig[]; // Updated to support new format
|
||||
}
|
||||
|
||||
// API response types
|
||||
@@ -210,6 +216,30 @@ export interface ApiResponse<T = any> {
|
||||
export interface IUser {
|
||||
username: string;
|
||||
isAdmin?: boolean;
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
// User management types
|
||||
export interface User {
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface UserFormData {
|
||||
username: string;
|
||||
password: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface UserUpdateData {
|
||||
isAdmin?: boolean;
|
||||
newPassword?: string;
|
||||
}
|
||||
|
||||
export interface UserStats {
|
||||
totalUsers: number;
|
||||
adminUsers: number;
|
||||
regularUsers: number;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
|
||||
174
frontend/src/utils/fetchInterceptor.ts
Normal file
174
frontend/src/utils/fetchInterceptor.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { getApiUrl } from './runtime';
|
||||
|
||||
// Define the interceptor interface
|
||||
export interface FetchInterceptor {
|
||||
request?: (url: string, config: RequestInit) => Promise<{ url: string; config: RequestInit }>;
|
||||
response?: (response: Response) => Promise<Response>;
|
||||
error?: (error: Error) => Promise<Error>;
|
||||
}
|
||||
|
||||
// Define the enhanced fetch response interface
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Global interceptors store
|
||||
const interceptors: FetchInterceptor[] = [];
|
||||
|
||||
// Add an interceptor
|
||||
export const addInterceptor = (interceptor: FetchInterceptor): void => {
|
||||
interceptors.push(interceptor);
|
||||
};
|
||||
|
||||
// Remove an interceptor
|
||||
export const removeInterceptor = (interceptor: FetchInterceptor): void => {
|
||||
const index = interceptors.indexOf(interceptor);
|
||||
if (index > -1) {
|
||||
interceptors.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
// Clear all interceptors
|
||||
export const clearInterceptors = (): void => {
|
||||
interceptors.length = 0;
|
||||
};
|
||||
|
||||
// Enhanced fetch function with interceptors
|
||||
export const fetchWithInterceptors = async (
|
||||
input: string | URL | Request,
|
||||
init: RequestInit = {},
|
||||
): Promise<Response> => {
|
||||
let url = input.toString();
|
||||
let config = { ...init };
|
||||
|
||||
try {
|
||||
// Apply request interceptors
|
||||
for (const interceptor of interceptors) {
|
||||
if (interceptor.request) {
|
||||
const result = await interceptor.request(url, config);
|
||||
url = result.url;
|
||||
config = result.config;
|
||||
}
|
||||
}
|
||||
|
||||
// Make the actual fetch request
|
||||
let response = await fetch(url, config);
|
||||
|
||||
// Apply response interceptors
|
||||
for (const interceptor of interceptors) {
|
||||
if (interceptor.response) {
|
||||
response = await interceptor.response(response);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
let processedError = error as Error;
|
||||
|
||||
// Apply error interceptors
|
||||
for (const interceptor of interceptors) {
|
||||
if (interceptor.error) {
|
||||
processedError = await interceptor.error(processedError);
|
||||
}
|
||||
}
|
||||
|
||||
throw processedError;
|
||||
}
|
||||
};
|
||||
|
||||
// Convenience function for API calls with automatic URL construction
|
||||
export const apiRequest = async <T = any>(endpoint: string, init: RequestInit = {}): Promise<T> => {
|
||||
try {
|
||||
const url = getApiUrl(endpoint);
|
||||
const response = await fetchWithInterceptors(url, init);
|
||||
|
||||
// Try to parse JSON response
|
||||
let data: T;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (parseError) {
|
||||
// If JSON parsing fails, create a generic response
|
||||
const genericResponse = {
|
||||
success: response.ok,
|
||||
message: response.ok
|
||||
? 'Request successful'
|
||||
: `HTTP ${response.status}: ${response.statusText}`,
|
||||
};
|
||||
data = genericResponse as T;
|
||||
}
|
||||
|
||||
// If response is not ok, but no explicit error in parsed data
|
||||
if (!response.ok && typeof data === 'object' && data !== null) {
|
||||
const responseObj = data as any;
|
||||
if (responseObj.success !== false) {
|
||||
responseObj.success = false;
|
||||
responseObj.message =
|
||||
responseObj.message || `HTTP ${response.status}: ${response.statusText}`;
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('API request error:', error);
|
||||
const errorResponse = {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'An unknown error occurred',
|
||||
};
|
||||
return errorResponse as T;
|
||||
}
|
||||
};
|
||||
|
||||
// Convenience methods for common HTTP methods
|
||||
export const apiGet = <T = any>(endpoint: string, init: Omit<RequestInit, 'method'> = {}) =>
|
||||
apiRequest<T>(endpoint, { ...init, method: 'GET' });
|
||||
|
||||
export const apiPost = <T = any>(
|
||||
endpoint: string,
|
||||
data?: any,
|
||||
init: Omit<RequestInit, 'method' | 'body'> = {},
|
||||
) =>
|
||||
apiRequest<T>(endpoint, {
|
||||
...init,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...init.headers,
|
||||
},
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
|
||||
export const apiPut = <T = any>(
|
||||
endpoint: string,
|
||||
data?: any,
|
||||
init: Omit<RequestInit, 'method' | 'body'> = {},
|
||||
) =>
|
||||
apiRequest<T>(endpoint, {
|
||||
...init,
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...init.headers,
|
||||
},
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
|
||||
export const apiDelete = <T = any>(endpoint: string, init: Omit<RequestInit, 'method'> = {}) =>
|
||||
apiRequest<T>(endpoint, { ...init, method: 'DELETE' });
|
||||
|
||||
export const apiPatch = <T = any>(
|
||||
endpoint: string,
|
||||
data?: any,
|
||||
init: Omit<RequestInit, 'method' | 'body'> = {},
|
||||
) =>
|
||||
apiRequest<T>(endpoint, {
|
||||
...init,
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...init.headers,
|
||||
},
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
99
frontend/src/utils/interceptors.ts
Normal file
99
frontend/src/utils/interceptors.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { addInterceptor, removeInterceptor, type FetchInterceptor } from './fetchInterceptor';
|
||||
|
||||
// Token key in localStorage
|
||||
const TOKEN_KEY = 'mcphub_token';
|
||||
|
||||
// Get token from localStorage
|
||||
export const getToken = (): string | null => {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
};
|
||||
|
||||
// Set token in localStorage
|
||||
export const setToken = (token: string): void => {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
};
|
||||
|
||||
// Remove token from localStorage
|
||||
export const removeToken = (): void => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
};
|
||||
|
||||
// Auth interceptor for automatically adding authorization headers
|
||||
export const authInterceptor: FetchInterceptor = {
|
||||
request: async (url: string, config: RequestInit) => {
|
||||
const headers = new Headers(config.headers);
|
||||
const language = localStorage.getItem('i18nextLng') || 'en';
|
||||
headers.set('Accept-Language', language);
|
||||
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
headers.set('x-auth-token', token);
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
config: {
|
||||
...config,
|
||||
headers,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
response: async (response: Response) => {
|
||||
// Handle unauthorized responses
|
||||
if (response.status === 401) {
|
||||
// Token might be expired or invalid, remove it
|
||||
removeToken();
|
||||
|
||||
// You could also trigger a redirect to login page here
|
||||
// window.location.href = '/login';
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
error: async (error: Error) => {
|
||||
console.error('Auth interceptor error:', error);
|
||||
return error;
|
||||
},
|
||||
};
|
||||
|
||||
// Install the auth interceptor
|
||||
export const installAuthInterceptor = (): void => {
|
||||
addInterceptor(authInterceptor);
|
||||
};
|
||||
|
||||
// Uninstall the auth interceptor
|
||||
export const uninstallAuthInterceptor = (): void => {
|
||||
removeInterceptor(authInterceptor);
|
||||
};
|
||||
|
||||
// Logging interceptor for development
|
||||
export const loggingInterceptor: FetchInterceptor = {
|
||||
request: async (url: string, config: RequestInit) => {
|
||||
console.log(`🚀 [${config.method || 'GET'}] ${url}`, config);
|
||||
return { url, config };
|
||||
},
|
||||
|
||||
response: async (response: Response) => {
|
||||
console.log(`✅ [${response.status}] ${response.url}`);
|
||||
return response;
|
||||
},
|
||||
|
||||
error: async (error: Error) => {
|
||||
console.error(`❌ Fetch error:`, error);
|
||||
return error;
|
||||
},
|
||||
};
|
||||
|
||||
// Install the logging interceptor (only in development)
|
||||
export const installLoggingInterceptor = (): void => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
addInterceptor(loggingInterceptor);
|
||||
}
|
||||
};
|
||||
|
||||
// Uninstall the logging interceptor
|
||||
export const uninstallLoggingInterceptor = (): void => {
|
||||
removeInterceptor(loggingInterceptor);
|
||||
};
|
||||
@@ -56,7 +56,7 @@ export const loadRuntimeConfig = async (): Promise<RuntimeConfig> => {
|
||||
const currentPath = window.location.pathname;
|
||||
const possibleConfigPaths = [
|
||||
// If we're already on a subpath, try to use it
|
||||
currentPath.replace(/\/[^\/]*$/, '') + '/config',
|
||||
currentPath.replace(/\/[^/]*$/, '') + '/config',
|
||||
// Try root config
|
||||
'/config',
|
||||
// Try with potential base paths
|
||||
|
||||
19
frontend/src/utils/setupInterceptors.ts
Normal file
19
frontend/src/utils/setupInterceptors.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { installAuthInterceptor, installLoggingInterceptor } from './interceptors';
|
||||
|
||||
/**
|
||||
* Setup all default interceptors for the application
|
||||
* This should be called once when the app initializes
|
||||
*/
|
||||
export const setupInterceptors = (): void => {
|
||||
// Install auth interceptor for automatic token handling
|
||||
installAuthInterceptor();
|
||||
|
||||
// Install logging interceptor in development mode
|
||||
installLoggingInterceptor();
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize interceptors automatically when this module is imported
|
||||
* This ensures interceptors are set up as early as possible
|
||||
*/
|
||||
setupInterceptors();
|
||||
27
frontend/src/utils/variableDetection.ts
Normal file
27
frontend/src/utils/variableDetection.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Utility function to detect ${} variables in server configurations
|
||||
export const detectVariables = (payload: any): string[] => {
|
||||
const variables = new Set<string>();
|
||||
const variableRegex = /\$\{([^}]+)\}/g;
|
||||
|
||||
const checkString = (str: string) => {
|
||||
let match;
|
||||
while ((match = variableRegex.exec(str)) !== null) {
|
||||
variables.add(match[1]);
|
||||
}
|
||||
};
|
||||
|
||||
const checkObject = (obj: any, path: string = '') => {
|
||||
if (typeof obj === 'string') {
|
||||
checkString(obj);
|
||||
} else if (Array.isArray(obj)) {
|
||||
obj.forEach((item, index) => checkObject(item, `${path}[${index}]`));
|
||||
} else if (obj && typeof obj === 'object') {
|
||||
Object.entries(obj).forEach(([key, value]) => {
|
||||
checkObject(value, path ? `${path}.${key}` : key);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
checkObject(payload);
|
||||
return Array.from(variables);
|
||||
};
|
||||
@@ -39,6 +39,14 @@ export default defineConfig({
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
[`${basePath}/config`]: {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
[`${basePath}/public-config`]: {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
47
jest.config.cjs
Normal file
47
jest.config.cjs
Normal file
@@ -0,0 +1,47 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest/presets/default-esm',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src', '<rootDir>/tests'],
|
||||
testMatch: [
|
||||
'<rootDir>/src/**/__tests__/**/*.{ts,tsx}',
|
||||
'<rootDir>/src/**/*.{test,spec}.{ts,tsx}',
|
||||
'<rootDir>/tests/**/*.{test,spec}.{ts,tsx}',
|
||||
],
|
||||
transform: {
|
||||
'^.+\\.tsx?$': [
|
||||
'ts-jest',
|
||||
{
|
||||
useESM: true,
|
||||
tsconfig: './tsconfig.test.json',
|
||||
},
|
||||
],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
||||
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{ts,tsx}',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/index.ts',
|
||||
'!src/**/__tests__/**',
|
||||
'!src/**/*.test.{ts,tsx}',
|
||||
'!src/**/*.spec.{ts,tsx}',
|
||||
],
|
||||
coverageDirectory: 'coverage',
|
||||
coverageReporters: ['text', 'lcov', 'html'],
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
branches: 0,
|
||||
functions: 0,
|
||||
lines: 0,
|
||||
statements: 0,
|
||||
},
|
||||
},
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!(@modelcontextprotocol|other-esm-packages)/)'],
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
testTimeout: 30000,
|
||||
verbose: true,
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src'],
|
||||
transform: {
|
||||
'^.+\\.tsx?$': 'ts-jest',
|
||||
},
|
||||
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$',
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
||||
};
|
||||
@@ -117,6 +117,11 @@
|
||||
"argumentsPlaceholder": "Enter arguments",
|
||||
"errorDetails": "Error Details",
|
||||
"viewErrorDetails": "View error details",
|
||||
"confirmVariables": "Confirm Variable Configuration",
|
||||
"variablesDetected": "Variables detected in configuration. Please confirm these variables are properly configured:",
|
||||
"detectedVariables": "Detected Variables",
|
||||
"confirmVariablesMessage": "Please ensure these variables are properly defined in your runtime environment. Continue adding server?",
|
||||
"confirmAndAdd": "Confirm and Add",
|
||||
"openapi": {
|
||||
"inputMode": "Input Mode",
|
||||
"inputModeUrl": "Specification URL",
|
||||
@@ -168,18 +173,27 @@
|
||||
"cancel": "Cancel",
|
||||
"refresh": "Refresh",
|
||||
"create": "Create",
|
||||
"creating": "Creating...",
|
||||
"update": "Update",
|
||||
"updating": "Updating...",
|
||||
"submitting": "Submitting...",
|
||||
"delete": "Delete",
|
||||
"remove": "Remove",
|
||||
"copy": "Copy",
|
||||
"copyId": "Copy ID",
|
||||
"copyUrl": "Copy URL",
|
||||
"copyJson": "Copy JSON",
|
||||
"copySuccess": "Copied to clipboard",
|
||||
"copyFailed": "Copy failed",
|
||||
"close": "Close"
|
||||
"close": "Close",
|
||||
"confirm": "Confirm",
|
||||
"language": "Language"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"servers": "Servers",
|
||||
"groups": "Groups",
|
||||
"users": "Users",
|
||||
"settings": "Settings",
|
||||
"changePassword": "Change Password",
|
||||
"market": "Market",
|
||||
@@ -200,6 +214,9 @@
|
||||
"groups": {
|
||||
"title": "Group Management"
|
||||
},
|
||||
"users": {
|
||||
"title": "User Management"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"language": "Language",
|
||||
@@ -254,7 +271,14 @@
|
||||
"noGroups": "No groups available. Create a new group to get started.",
|
||||
"noServers": "No servers in this group.",
|
||||
"noServerOptions": "No servers available",
|
||||
"serverCount": "{{count}} Servers"
|
||||
"serverCount": "{{count}} Servers",
|
||||
"toolSelection": "Tool Selection",
|
||||
"toolsSelected": "Selected",
|
||||
"allTools": "All",
|
||||
"selectedTools": "Selected tools",
|
||||
"selectAll": "Select All",
|
||||
"selectNone": "Select None",
|
||||
"configureTools": "Configure Tools"
|
||||
},
|
||||
"market": {
|
||||
"title": "Server Market",
|
||||
@@ -296,7 +320,9 @@
|
||||
"tagFilterError": "Error filtering servers by tag",
|
||||
"noInstallationMethod": "No installation method available for this server",
|
||||
"showing": "Showing {{from}}-{{to}} of {{total}} servers",
|
||||
"perPage": "Per page"
|
||||
"perPage": "Per page",
|
||||
"confirmVariablesMessage": "Please ensure these variables are properly defined in your runtime environment. Continue installing server?",
|
||||
"confirmAndInstall": "Confirm and Install"
|
||||
},
|
||||
"tool": {
|
||||
"run": "Run",
|
||||
@@ -343,12 +369,17 @@
|
||||
"bearerAuthKey": "Bearer Authentication Key",
|
||||
"bearerAuthKeyDescription": "The authentication key that will be required in the Bearer token",
|
||||
"bearerAuthKeyPlaceholder": "Enter bearer authentication key",
|
||||
"skipAuth": "Skip Authentication",
|
||||
"skipAuthDescription": "Bypass login requirement for frontend and API access (DEFAULT OFF for security)",
|
||||
"pythonIndexUrl": "Python Package Repository URL",
|
||||
"pythonIndexUrlDescription": "Set UV_DEFAULT_INDEX environment variable for Python package installation",
|
||||
"pythonIndexUrlPlaceholder": "e.g. https://pypi.org/simple",
|
||||
"npmRegistry": "NPM Registry URL",
|
||||
"npmRegistryDescription": "Set npm_config_registry environment variable for NPM package installation",
|
||||
"npmRegistryPlaceholder": "e.g. https://registry.npmjs.org/",
|
||||
"baseUrl": "Base URL",
|
||||
"baseUrlDescription": "Base URL for MCP requests",
|
||||
"baseUrlPlaceholder": "e.g. http://localhost:3000",
|
||||
"installConfig": "Installation",
|
||||
"systemConfigUpdated": "System configuration updated successfully",
|
||||
"enableSmartRouting": "Enable Smart Routing",
|
||||
@@ -364,5 +395,124 @@
|
||||
"smartRoutingConfigUpdated": "Smart routing configuration updated successfully",
|
||||
"smartRoutingRequiredFields": "Database URL and OpenAI API Key are required to enable smart routing",
|
||||
"smartRoutingValidationError": "Please fill in the required fields before enabling Smart Routing: {{fields}}"
|
||||
},
|
||||
"dxt": {
|
||||
"upload": "Upload",
|
||||
"uploadTitle": "Upload DXT Extension",
|
||||
"dropFileHere": "Drop your .dxt file here",
|
||||
"orClickToSelect": "or click to select from your computer",
|
||||
"invalidFileType": "Please select a valid .dxt file",
|
||||
"noFileSelected": "Please select a .dxt file to upload",
|
||||
"uploading": "Uploading...",
|
||||
"uploadFailed": "Failed to upload DXT file",
|
||||
"installServer": "Install MCP Server from DXT",
|
||||
"extensionInfo": "Extension Information",
|
||||
"name": "Name",
|
||||
"version": "Version",
|
||||
"description": "Description",
|
||||
"author": "Author",
|
||||
"tools": "Tools",
|
||||
"serverName": "Server Name",
|
||||
"serverNamePlaceholder": "Enter a name for this server",
|
||||
"install": "Install",
|
||||
"installing": "Installing...",
|
||||
"installFailed": "Failed to install server from DXT",
|
||||
"serverExistsTitle": "Server Already Exists",
|
||||
"serverExistsConfirm": "Server '{{serverName}}' already exists. Do you want to override it with the new version?",
|
||||
"override": "Override"
|
||||
},
|
||||
"users": {
|
||||
"add": "Add User",
|
||||
"addNew": "Add New User",
|
||||
"edit": "Edit User",
|
||||
"delete": "Delete User",
|
||||
"create": "Create User",
|
||||
"update": "Update User",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"newPassword": "New Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"adminRole": "Administrator",
|
||||
"admin": "Admin",
|
||||
"user": "User",
|
||||
"permissions": "Permissions",
|
||||
"adminPermissions": "Full system access",
|
||||
"userPermissions": "Limited access",
|
||||
"currentUser": "You",
|
||||
"noUsers": "No users found",
|
||||
"adminRequired": "Administrator access required to manage users",
|
||||
"usernameRequired": "Username is required",
|
||||
"passwordRequired": "Password is required",
|
||||
"passwordTooShort": "Password must be at least 6 characters long",
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"usernamePlaceholder": "Enter username",
|
||||
"passwordPlaceholder": "Enter password",
|
||||
"newPasswordPlaceholder": "Leave empty to keep current password",
|
||||
"confirmPasswordPlaceholder": "Confirm new password",
|
||||
"createError": "Failed to create user",
|
||||
"updateError": "Failed to update user",
|
||||
"deleteError": "Failed to delete user",
|
||||
"statsError": "Failed to fetch user statistics",
|
||||
"deleteConfirmation": "Are you sure you want to delete user '{{username}}'? This action cannot be undone.",
|
||||
"confirmDelete": "Delete User",
|
||||
"deleteWarning": "Are you sure you want to delete user '{{username}}'? This action cannot be undone."
|
||||
},
|
||||
"api": {
|
||||
"errors": {
|
||||
"readonly": "Readonly for demo environment",
|
||||
"serverNameRequired": "Server name is required",
|
||||
"serverConfigRequired": "Server configuration is required",
|
||||
"serverConfigInvalid": "Server configuration must include either a URL, OpenAPI specification URL or schema, or command with arguments",
|
||||
"serverTypeInvalid": "Server type must be one of: stdio, sse, streamable-http, openapi",
|
||||
"urlRequiredForType": "URL is required for {{type}} server type",
|
||||
"openapiSpecRequired": "OpenAPI specification URL or schema is required for openapi server type",
|
||||
"headersInvalidFormat": "Headers must be an object",
|
||||
"headersNotSupportedForStdio": "Headers are not supported for stdio server type",
|
||||
"serverNotFound": "Server not found",
|
||||
"failedToRemoveServer": "Server not found or failed to remove",
|
||||
"internalServerError": "Internal server error",
|
||||
"failedToGetServers": "Failed to get servers information",
|
||||
"failedToGetServerSettings": "Failed to get server settings",
|
||||
"failedToGetServerConfig": "Failed to get server configuration",
|
||||
"failedToSaveSettings": "Failed to save settings",
|
||||
"toolNameRequired": "Server name and tool name are required",
|
||||
"descriptionMustBeString": "Description must be a string",
|
||||
"groupIdRequired": "Group ID is required",
|
||||
"groupNameRequired": "Group name is required",
|
||||
"groupNotFound": "Group not found",
|
||||
"groupIdAndServerNameRequired": "Group ID and server name are required",
|
||||
"groupOrServerNotFound": "Group or server not found",
|
||||
"toolsMustBeAllOrArray": "Tools must be \"all\" or an array of strings",
|
||||
"serverNameAndToolNameRequired": "Server name and tool name are required",
|
||||
"usernameRequired": "Username is required",
|
||||
"userNotFound": "User not found",
|
||||
"failedToGetUsers": "Failed to get users information",
|
||||
"failedToGetUserInfo": "Failed to get user information",
|
||||
"failedToGetUserStats": "Failed to get user statistics",
|
||||
"marketServerNameRequired": "Server name is required",
|
||||
"marketServerNotFound": "Market server not found",
|
||||
"failedToGetMarketServers": "Failed to get market servers information",
|
||||
"failedToGetMarketServer": "Failed to get market server information",
|
||||
"failedToGetMarketCategories": "Failed to get market categories",
|
||||
"failedToGetMarketTags": "Failed to get market tags",
|
||||
"failedToSearchMarketServers": "Failed to search market servers",
|
||||
"failedToFilterMarketServers": "Failed to filter market servers",
|
||||
"failedToProcessDxtFile": "Failed to process DXT file"
|
||||
},
|
||||
"success": {
|
||||
"serverCreated": "Server created successfully",
|
||||
"serverUpdated": "Server updated successfully",
|
||||
"serverRemoved": "Server removed successfully",
|
||||
"serverToggled": "Server status toggled successfully",
|
||||
"toolToggled": "Tool {{name}} {{action}} successfully",
|
||||
"toolDescriptionUpdated": "Tool {{name}} description updated successfully",
|
||||
"systemConfigUpdated": "System configuration updated successfully",
|
||||
"groupCreated": "Group created successfully",
|
||||
"groupUpdated": "Group updated successfully",
|
||||
"groupDeleted": "Group deleted successfully",
|
||||
"serverAddedToGroup": "Server added to group successfully",
|
||||
"serverRemovedFromGroup": "Server removed from group successfully",
|
||||
"serverToolsUpdated": "Server tools updated successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,11 @@
|
||||
"argumentsPlaceholder": "请输入参数",
|
||||
"errorDetails": "错误详情",
|
||||
"viewErrorDetails": "查看错误详情",
|
||||
"confirmVariables": "确认变量配置",
|
||||
"variablesDetected": "检测到配置中包含变量,请确认这些变量是否已正确配置:",
|
||||
"detectedVariables": "检测到的变量",
|
||||
"confirmVariablesMessage": "请确保这些变量在运行环境中已正确定义。是否继续添加服务器?",
|
||||
"confirmAndAdd": "确认并添加",
|
||||
"openapi": {
|
||||
"inputMode": "输入模式",
|
||||
"inputModeUrl": "规范 URL",
|
||||
@@ -169,13 +174,21 @@
|
||||
"cancel": "取消",
|
||||
"refresh": "刷新",
|
||||
"create": "创建",
|
||||
"creating": "创建中...",
|
||||
"update": "更新",
|
||||
"updating": "更新中...",
|
||||
"submitting": "提交中...",
|
||||
"delete": "删除",
|
||||
"remove": "移除",
|
||||
"copy": "复制",
|
||||
"copyId": "复制ID",
|
||||
"copyUrl": "复制URL",
|
||||
"copyJson": "复制JSON",
|
||||
"copySuccess": "已复制到剪贴板",
|
||||
"copyFailed": "复制失败",
|
||||
"close": "关闭"
|
||||
"close": "关闭",
|
||||
"confirm": "确认",
|
||||
"language": "语言"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "仪表盘",
|
||||
@@ -183,6 +196,7 @@
|
||||
"settings": "设置",
|
||||
"changePassword": "修改密码",
|
||||
"groups": "分组",
|
||||
"users": "用户",
|
||||
"market": "市场",
|
||||
"logs": "日志"
|
||||
},
|
||||
@@ -211,6 +225,9 @@
|
||||
"groups": {
|
||||
"title": "分组管理"
|
||||
},
|
||||
"users": {
|
||||
"title": "用户管理"
|
||||
},
|
||||
"market": {
|
||||
"title": "服务器市场 - (数据来源于 mcpm.sh)"
|
||||
},
|
||||
@@ -255,7 +272,14 @@
|
||||
"noGroups": "暂无可用分组。创建一个新分组以开始使用。",
|
||||
"noServers": "此分组中没有服务器。",
|
||||
"noServerOptions": "没有可用的服务器",
|
||||
"serverCount": "{{count}} 台服务器"
|
||||
"serverCount": "{{count}} 台服务器",
|
||||
"toolSelection": "工具选择",
|
||||
"toolsSelected": "选择",
|
||||
"allTools": "全部",
|
||||
"selectedTools": "选中的工具",
|
||||
"selectAll": "全选",
|
||||
"selectNone": "全不选",
|
||||
"configureTools": "配置工具"
|
||||
},
|
||||
"market": {
|
||||
"title": "服务器市场",
|
||||
@@ -297,12 +321,14 @@
|
||||
"tagFilterError": "按标签筛选服务器失败",
|
||||
"noInstallationMethod": "该服务器没有可用的安装方法",
|
||||
"showing": "显示 {{from}}-{{to}}/{{total}} 个服务器",
|
||||
"perPage": "每页显示"
|
||||
"perPage": "每页显示",
|
||||
"confirmVariablesMessage": "请确保这些变量在运行环境中已正确定义。是否继续安装服务器?",
|
||||
"confirmAndInstall": "确认并安装"
|
||||
},
|
||||
"tool": {
|
||||
"run": "运行",
|
||||
"running": "运行中...",
|
||||
"runTool": "运行工具",
|
||||
"runTool": "运行",
|
||||
"cancel": "取消",
|
||||
"noDescription": "无描述信息",
|
||||
"inputSchema": "输入模式:",
|
||||
@@ -344,12 +370,17 @@
|
||||
"bearerAuthKey": "Bearer 认证密钥",
|
||||
"bearerAuthKeyDescription": "Bearer 令牌中需要携带的认证密钥",
|
||||
"bearerAuthKeyPlaceholder": "请输入 Bearer 认证密钥",
|
||||
"skipAuth": "免登录开关",
|
||||
"skipAuthDescription": "跳过前端和 API 访问的登录要求(默认关闭确保安全性)",
|
||||
"pythonIndexUrl": "Python 包仓库地址",
|
||||
"pythonIndexUrlDescription": "设置 UV_DEFAULT_INDEX 环境变量,用于 Python 包安装",
|
||||
"pythonIndexUrlPlaceholder": "例如: https://mirrors.aliyun.com/pypi/simple",
|
||||
"npmRegistry": "NPM 仓库地址",
|
||||
"npmRegistryDescription": "设置 npm_config_registry 环境变量,用于 NPM 包安装",
|
||||
"npmRegistryPlaceholder": "例如: https://registry.npmmirror.com/",
|
||||
"baseUrl": "基础地址",
|
||||
"baseUrlDescription": "用于 MCP 请求的基础地址",
|
||||
"baseUrlPlaceholder": "例如: http://localhost:3000",
|
||||
"installConfig": "安装配置",
|
||||
"systemConfigUpdated": "系统配置更新成功",
|
||||
"enableSmartRouting": "启用智能路由",
|
||||
@@ -366,5 +397,124 @@
|
||||
"smartRoutingConfigUpdated": "智能路由配置更新成功",
|
||||
"smartRoutingRequiredFields": "启用智能路由需要填写数据库连接地址和 OpenAI API 密钥",
|
||||
"smartRoutingValidationError": "启用智能路由前请先填写必要字段:{{fields}}"
|
||||
},
|
||||
"dxt": {
|
||||
"upload": "上传",
|
||||
"uploadTitle": "上传 DXT 扩展",
|
||||
"dropFileHere": "将 .dxt 文件拖拽到此处",
|
||||
"orClickToSelect": "或点击从计算机选择",
|
||||
"invalidFileType": "请选择有效的 .dxt 文件",
|
||||
"noFileSelected": "请选择要上传的 .dxt 文件",
|
||||
"uploading": "上传中...",
|
||||
"uploadFailed": "上传 DXT 文件失败",
|
||||
"installServer": "从 DXT 安装 MCP 服务器",
|
||||
"extensionInfo": "扩展信息",
|
||||
"name": "名称",
|
||||
"version": "版本",
|
||||
"description": "描述",
|
||||
"author": "作者",
|
||||
"tools": "工具",
|
||||
"serverName": "服务器名称",
|
||||
"serverNamePlaceholder": "为此服务器输入名称",
|
||||
"install": "安装",
|
||||
"installing": "安装中...",
|
||||
"installFailed": "从 DXT 安装服务器失败",
|
||||
"serverExistsTitle": "服务器已存在",
|
||||
"serverExistsConfirm": "服务器 '{{serverName}}' 已存在。是否要用新版本覆盖它?",
|
||||
"override": "覆盖"
|
||||
},
|
||||
"users": {
|
||||
"add": "添加",
|
||||
"addNew": "添加新用户",
|
||||
"edit": "编辑用户",
|
||||
"delete": "删除用户",
|
||||
"create": "创建",
|
||||
"update": "更新",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"newPassword": "新密码",
|
||||
"confirmPassword": "确认密码",
|
||||
"adminRole": "管理员",
|
||||
"admin": "管理员",
|
||||
"user": "用户",
|
||||
"permissions": "权限",
|
||||
"adminPermissions": "完全系统访问权限",
|
||||
"userPermissions": "受限访问权限",
|
||||
"currentUser": "当前用户",
|
||||
"noUsers": "没有找到用户",
|
||||
"adminRequired": "需要管理员权限才能管理用户",
|
||||
"usernameRequired": "用户名是必需的",
|
||||
"passwordRequired": "密码是必需的",
|
||||
"passwordTooShort": "密码至少需要6个字符",
|
||||
"passwordMismatch": "密码不匹配",
|
||||
"usernamePlaceholder": "输入用户名",
|
||||
"passwordPlaceholder": "输入密码",
|
||||
"newPasswordPlaceholder": "留空保持当前密码",
|
||||
"confirmPasswordPlaceholder": "确认新密码",
|
||||
"createError": "创建用户失败",
|
||||
"updateError": "更新用户失败",
|
||||
"deleteError": "删除用户失败",
|
||||
"statsError": "获取用户统计失败",
|
||||
"deleteConfirmation": "您确定要删除用户 '{{username}}' 吗?此操作无法撤消。",
|
||||
"confirmDelete": "删除用户",
|
||||
"deleteWarning": "您确定要删除用户 '{{username}}' 吗?此操作无法撤消。"
|
||||
},
|
||||
"api": {
|
||||
"errors": {
|
||||
"readonly": "演示环境无法修改数据",
|
||||
"serverNameRequired": "服务器名称是必需的",
|
||||
"serverConfigRequired": "服务器配置是必需的",
|
||||
"serverConfigInvalid": "服务器配置必须包含 URL、OpenAPI 规范 URL 或模式,或者带参数的命令",
|
||||
"serverTypeInvalid": "服务器类型必须是以下之一:stdio、sse、streamable-http、openapi",
|
||||
"urlRequiredForType": "{{type}} 服务器类型需要 URL",
|
||||
"openapiSpecRequired": "openapi 服务器类型需要 OpenAPI 规范 URL 或模式",
|
||||
"headersInvalidFormat": "请求头必须是对象格式",
|
||||
"headersNotSupportedForStdio": "stdio 服务器类型不支持请求头",
|
||||
"serverNotFound": "找不到服务器",
|
||||
"failedToRemoveServer": "找不到服务器或删除失败",
|
||||
"internalServerError": "服务器内部错误",
|
||||
"failedToGetServers": "获取服务器信息失败",
|
||||
"failedToGetServerSettings": "获取服务器设置失败",
|
||||
"failedToGetServerConfig": "获取服务器配置失败",
|
||||
"failedToSaveSettings": "保存设置失败",
|
||||
"toolNameRequired": "服务器名称和工具名称是必需的",
|
||||
"descriptionMustBeString": "描述必须是字符串",
|
||||
"groupIdRequired": "分组 ID 是必需的",
|
||||
"groupNameRequired": "分组名称是必需的",
|
||||
"groupNotFound": "找不到分组",
|
||||
"groupIdAndServerNameRequired": "分组 ID 和服务器名称是必需的",
|
||||
"groupOrServerNotFound": "找不到分组或服务器",
|
||||
"toolsMustBeAllOrArray": "工具必须是 \"all\" 或字符串数组",
|
||||
"serverNameAndToolNameRequired": "服务器名称和工具名称是必需的",
|
||||
"usernameRequired": "用户名是必需的",
|
||||
"userNotFound": "找不到用户",
|
||||
"failedToGetUsers": "获取用户信息失败",
|
||||
"failedToGetUserInfo": "获取用户信息失败",
|
||||
"failedToGetUserStats": "获取用户统计信息失败",
|
||||
"marketServerNameRequired": "服务器名称是必需的",
|
||||
"marketServerNotFound": "找不到市场服务器",
|
||||
"failedToGetMarketServers": "获取市场服务器信息失败",
|
||||
"failedToGetMarketServer": "获取市场服务器信息失败",
|
||||
"failedToGetMarketCategories": "获取市场类别失败",
|
||||
"failedToGetMarketTags": "获取市场标签失败",
|
||||
"failedToSearchMarketServers": "搜索市场服务器失败",
|
||||
"failedToFilterMarketServers": "过滤市场服务器失败",
|
||||
"failedToProcessDxtFile": "处理 DXT 文件失败"
|
||||
},
|
||||
"success": {
|
||||
"serverCreated": "服务器创建成功",
|
||||
"serverUpdated": "服务器更新成功",
|
||||
"serverRemoved": "服务器删除成功",
|
||||
"serverToggled": "服务器状态切换成功",
|
||||
"toolToggled": "工具 {{name}} {{action}} 成功",
|
||||
"toolDescriptionUpdated": "工具 {{name}} 描述更新成功",
|
||||
"systemConfigUpdated": "系统配置更新成功",
|
||||
"groupCreated": "分组创建成功",
|
||||
"groupUpdated": "分组更新成功",
|
||||
"groupDeleted": "分组删除成功",
|
||||
"serverAddedToGroup": "服务器添加到分组成功",
|
||||
"serverRemovedFromGroup": "服务器从分组移除成功",
|
||||
"serverToolsUpdated": "服务器工具更新成功"
|
||||
}
|
||||
}
|
||||
}
|
||||
19
package.json
19
package.json
@@ -25,6 +25,10 @@
|
||||
"lint": "eslint . --ext .ts",
|
||||
"format": "prettier --write \"src/**/*.ts\"",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"test:verbose": "jest --verbose",
|
||||
"test:ci": "jest --ci --coverage --watchAll=false",
|
||||
"frontend:dev": "cd frontend && vite",
|
||||
"frontend:build": "cd frontend && vite build",
|
||||
"frontend:preview": "cd frontend && vite preview",
|
||||
@@ -43,14 +47,19 @@
|
||||
"dependencies": {
|
||||
"@apidevtools/swagger-parser": "^11.0.1",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/multer": "^1.4.13",
|
||||
"@types/pg": "^8.15.2",
|
||||
"adm-zip": "^0.5.16",
|
||||
"axios": "^1.10.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"dotenv": "^16.3.1",
|
||||
"dotenv-expand": "^12.0.2",
|
||||
"express": "^4.21.2",
|
||||
"express-validator": "^7.2.1",
|
||||
"i18next-fs-backend": "^2.6.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.1",
|
||||
"openai": "^4.103.0",
|
||||
"openapi-types": "^12.1.3",
|
||||
"pg": "^8.16.0",
|
||||
@@ -64,6 +73,8 @@
|
||||
"@radix-ui/react-accordion": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@shadcn/ui": "^0.0.4",
|
||||
"@swc/core": "^1.13.0",
|
||||
"@swc/jest": "^0.2.39",
|
||||
"@tailwindcss/postcss": "^4.1.3",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
@@ -73,6 +84,7 @@
|
||||
"@types/node": "^22.15.21",
|
||||
"@types/react": "^19.0.12",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
||||
"@typescript-eslint/parser": "^6.7.4",
|
||||
@@ -85,6 +97,8 @@
|
||||
"i18next": "^24.2.3",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-node": "^30.0.0",
|
||||
"jest-mock-extended": "4.0.0-beta1",
|
||||
"lucide-react": "^0.486.0",
|
||||
"next": "^15.2.4",
|
||||
"postcss": "^8.5.3",
|
||||
@@ -93,6 +107,7 @@
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"supertest": "^7.1.1",
|
||||
"tailwind-merge": "^3.1.0",
|
||||
"tailwind-scrollbar-hide": "^2.0.0",
|
||||
"tailwindcss": "^4.0.17",
|
||||
@@ -106,5 +121,5 @@
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.11.0+sha256.a69e9cb077da419d47d18f1dd52e207245b29cac6e076acedbeb8be3b1a67bd7"
|
||||
}
|
||||
"packageManager": "pnpm@10.12.4+sha256.cadfd9e6c9fcc2cb76fe7c0779a5250b632898aea5f53d833a73690c77a778d9"
|
||||
}
|
||||
|
||||
1653
pnpm-lock.yaml
generated
1653
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
ignoredBuiltDependencies:
|
||||
- '@swc/core'
|
||||
200
src/clients/__tests__/openapi-operation-name.test.ts
Normal file
200
src/clients/__tests__/openapi-operation-name.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { OpenAPIClient } from '../openapi.js';
|
||||
import { ServerConfig } from '../../types/index.js';
|
||||
import { OpenAPIV3 } from 'openapi-types';
|
||||
|
||||
describe('OpenAPIClient - Operation Name Generation', () => {
|
||||
describe('generateOperationName', () => {
|
||||
test('should generate operation name from method and path', async () => {
|
||||
const config: ServerConfig = {
|
||||
type: 'openapi',
|
||||
openapi: {
|
||||
schema: {
|
||||
openapi: '3.0.0',
|
||||
info: { title: 'Test API', version: '1.0.0' },
|
||||
paths: {
|
||||
'/users': {
|
||||
get: {
|
||||
summary: 'Get users',
|
||||
responses: { '200': { description: 'Success' } },
|
||||
},
|
||||
post: {
|
||||
summary: 'Create user',
|
||||
responses: { '201': { description: 'Created' } },
|
||||
},
|
||||
},
|
||||
'/users/{id}': {
|
||||
get: {
|
||||
summary: 'Get user by ID',
|
||||
responses: { '200': { description: 'Success' } },
|
||||
},
|
||||
delete: {
|
||||
summary: 'Delete user',
|
||||
responses: { '204': { description: 'Deleted' } },
|
||||
},
|
||||
},
|
||||
'/admin/settings': {
|
||||
get: {
|
||||
summary: 'Get admin settings',
|
||||
responses: { '200': { description: 'Success' } },
|
||||
},
|
||||
},
|
||||
'/': {
|
||||
get: {
|
||||
summary: 'Root endpoint',
|
||||
responses: { '200': { description: 'Success' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenAPIV3.Document,
|
||||
},
|
||||
};
|
||||
|
||||
const testClient = new OpenAPIClient(config);
|
||||
await testClient.initialize();
|
||||
const tools = testClient.getTools();
|
||||
|
||||
// Verify generated operation names
|
||||
expect(tools).toHaveLength(6);
|
||||
|
||||
const toolNames = tools.map((t) => t.name).sort();
|
||||
expect(toolNames).toEqual(
|
||||
[
|
||||
'delete_users',
|
||||
'get_admin_settings',
|
||||
'get_root',
|
||||
'get_users',
|
||||
'post_users',
|
||||
'get_users1', // Second GET /users/{id}, will add numeric suffix
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test('should use operationId when available and generate name when missing', async () => {
|
||||
const config: ServerConfig = {
|
||||
type: 'openapi',
|
||||
openapi: {
|
||||
schema: {
|
||||
openapi: '3.0.0',
|
||||
info: { title: 'Test API', version: '1.0.0' },
|
||||
paths: {
|
||||
'/users': {
|
||||
get: {
|
||||
operationId: 'listUsers',
|
||||
summary: 'Get users',
|
||||
responses: { '200': { description: 'Success' } },
|
||||
},
|
||||
post: {
|
||||
// No operationId, should generate post_users
|
||||
summary: 'Create user',
|
||||
responses: { '201': { description: 'Created' } },
|
||||
},
|
||||
},
|
||||
'/users/{id}': {
|
||||
get: {
|
||||
operationId: 'getUserById',
|
||||
summary: 'Get user by ID',
|
||||
responses: { '200': { description: 'Success' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenAPIV3.Document,
|
||||
},
|
||||
};
|
||||
|
||||
const testClient = new OpenAPIClient(config);
|
||||
await testClient.initialize();
|
||||
const tools = testClient.getTools();
|
||||
|
||||
expect(tools).toHaveLength(3);
|
||||
|
||||
const toolsByName = tools.reduce(
|
||||
(acc, tool) => {
|
||||
acc[tool.name] = tool;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
);
|
||||
|
||||
// Those with operationId should use the original operationId
|
||||
expect(toolsByName['listUsers']).toBeDefined();
|
||||
expect(toolsByName['listUsers'].operationId).toBe('listUsers');
|
||||
expect(toolsByName['getUserById']).toBeDefined();
|
||||
expect(toolsByName['getUserById'].operationId).toBe('getUserById');
|
||||
|
||||
// Those without operationId should generate names
|
||||
expect(toolsByName['post_users']).toBeDefined();
|
||||
expect(toolsByName['post_users'].operationId).toBe('post_users');
|
||||
});
|
||||
|
||||
test('should handle duplicate generated names with counter', async () => {
|
||||
const config: ServerConfig = {
|
||||
type: 'openapi',
|
||||
openapi: {
|
||||
schema: {
|
||||
openapi: '3.0.0',
|
||||
info: { title: 'Test API', version: '1.0.0' },
|
||||
paths: {
|
||||
'/users': {
|
||||
get: {
|
||||
summary: 'Get users',
|
||||
responses: { '200': { description: 'Success' } },
|
||||
},
|
||||
},
|
||||
'/users/': {
|
||||
get: {
|
||||
summary: 'Get users with trailing slash',
|
||||
responses: { '200': { description: 'Success' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenAPIV3.Document,
|
||||
},
|
||||
};
|
||||
|
||||
const testClient = new OpenAPIClient(config);
|
||||
await testClient.initialize();
|
||||
const tools = testClient.getTools();
|
||||
|
||||
expect(tools).toHaveLength(2);
|
||||
|
||||
const toolNames = tools.map((t) => t.name).sort();
|
||||
expect(toolNames).toEqual(['get_users', 'get_users1']);
|
||||
});
|
||||
|
||||
test('should handle complex paths with parameters and special characters', async () => {
|
||||
const config: ServerConfig = {
|
||||
type: 'openapi',
|
||||
openapi: {
|
||||
schema: {
|
||||
openapi: '3.0.0',
|
||||
info: { title: 'Test API', version: '1.0.0' },
|
||||
paths: {
|
||||
'/api/v1/users/{user-id}/posts/{post_id}': {
|
||||
get: {
|
||||
summary: 'Get user post',
|
||||
responses: { '200': { description: 'Success' } },
|
||||
},
|
||||
},
|
||||
'/api-v2/user-profiles': {
|
||||
post: {
|
||||
summary: 'Create user profile',
|
||||
responses: { '201': { description: 'Created' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenAPIV3.Document,
|
||||
},
|
||||
};
|
||||
|
||||
const testClient = new OpenAPIClient(config);
|
||||
await testClient.initialize();
|
||||
const tools = testClient.getTools();
|
||||
|
||||
expect(tools).toHaveLength(2);
|
||||
|
||||
const toolNames = tools.map((t) => t.name);
|
||||
expect(toolNames).toContain('get_api_v1_users_posts'); // Path parameters removed, special characters cleaned
|
||||
expect(toolNames).toContain('post_apiv2_userprofiles'); // Hyphens and underscores cleaned, lowercase with underscores
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,7 @@ export class OpenAPIClient {
|
||||
throw new Error('OpenAPI URL or schema is required');
|
||||
}
|
||||
|
||||
// 初始 baseUrl,将在 initialize() 中从 OpenAPI servers 字段更新
|
||||
// Initial baseUrl, will be updated from OpenAPI servers field in initialize()
|
||||
this.baseUrl = config.openapi?.url ? this.extractBaseUrl(config.openapi.url) : '';
|
||||
this.securityConfig = config.openapi.security;
|
||||
|
||||
@@ -117,7 +117,7 @@ export class OpenAPIClient {
|
||||
throw new Error('Either OpenAPI URL or schema must be provided');
|
||||
}
|
||||
|
||||
// 从 OpenAPI servers 字段更新 baseUrl
|
||||
// Update baseUrl from OpenAPI servers field
|
||||
this.updateBaseUrlFromServers();
|
||||
|
||||
this.extractTools();
|
||||
@@ -127,33 +127,48 @@ export class OpenAPIClient {
|
||||
}
|
||||
}
|
||||
|
||||
private generateOperationName(method: string, path: string): string {
|
||||
// Clean path, remove parameter brackets and special characters
|
||||
const cleanPath = path
|
||||
.replace(/\{[^}]+\}/g, '') // Remove {param} format parameters
|
||||
.replace(/[^\w/]/g, '') // Remove special characters, keep alphanumeric and slashes
|
||||
.split('/')
|
||||
.filter((segment) => segment.length > 0) // Remove empty segments
|
||||
.map((segment) => segment.toLowerCase()) // Convert to lowercase
|
||||
.join('_'); // Join with underscores
|
||||
|
||||
// Convert method to lowercase and combine with path
|
||||
const methodName = method.toLowerCase();
|
||||
return `${methodName}_${cleanPath || 'root'}`;
|
||||
}
|
||||
|
||||
private updateBaseUrlFromServers(): void {
|
||||
if (!this.spec?.servers || this.spec.servers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取第一个 server 的 URL
|
||||
// Get the first server's URL
|
||||
const serverUrl = this.spec.servers[0].url;
|
||||
|
||||
// 如果是相对路径,需要与原始 spec URL 结合
|
||||
// If it's a relative path, combine with original spec URL
|
||||
if (serverUrl.startsWith('/')) {
|
||||
// 相对路径,使用原始 spec URL 的协议和主机
|
||||
// Relative path, use protocol and host from original spec URL
|
||||
if (this.config.openapi?.url) {
|
||||
const originalUrl = new URL(this.config.openapi.url);
|
||||
this.baseUrl = `${originalUrl.protocol}//${originalUrl.host}${serverUrl}`;
|
||||
}
|
||||
} else if (serverUrl.startsWith('http://') || serverUrl.startsWith('https://')) {
|
||||
// 绝对路径
|
||||
// Absolute path
|
||||
this.baseUrl = serverUrl;
|
||||
} else {
|
||||
// 相对路径但不以 / 开头,可能是相对于当前路径
|
||||
// Relative path but doesn't start with /, might be relative to current path
|
||||
if (this.config.openapi?.url) {
|
||||
const originalUrl = new URL(this.config.openapi.url);
|
||||
this.baseUrl = `${originalUrl.protocol}//${originalUrl.host}/${serverUrl}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新 HTTP 客户端的 baseURL
|
||||
// Update HTTP client's baseURL
|
||||
this.httpClient.defaults.baseURL = this.baseUrl;
|
||||
}
|
||||
|
||||
@@ -163,6 +178,7 @@ export class OpenAPIClient {
|
||||
}
|
||||
|
||||
this.tools = [];
|
||||
const generatedNames = new Set<string>(); // Used to ensure generated names are unique
|
||||
|
||||
for (const [path, pathItem] of Object.entries(this.spec.paths)) {
|
||||
if (!pathItem) continue;
|
||||
@@ -180,14 +196,33 @@ export class OpenAPIClient {
|
||||
|
||||
for (const method of methods) {
|
||||
const operation = pathItem[method] as OpenAPIV3.OperationObject | undefined;
|
||||
if (!operation || !operation.operationId) continue;
|
||||
if (!operation) continue;
|
||||
|
||||
// Generate operation name: use operationId first, otherwise generate unique name
|
||||
let operationName: string;
|
||||
if (operation.operationId) {
|
||||
operationName = operation.operationId;
|
||||
} else {
|
||||
operationName = this.generateOperationName(method, path);
|
||||
|
||||
// Ensure name uniqueness, add numeric suffix if duplicate
|
||||
let uniqueName = operationName;
|
||||
let counter = 1;
|
||||
while (generatedNames.has(uniqueName) || this.tools.some((t) => t.name === uniqueName)) {
|
||||
uniqueName = `${operationName}${counter}`;
|
||||
counter++;
|
||||
}
|
||||
operationName = uniqueName;
|
||||
}
|
||||
|
||||
generatedNames.add(operationName);
|
||||
|
||||
const tool: OpenAPIToolInfo = {
|
||||
name: operation.operationId,
|
||||
name: operationName,
|
||||
description:
|
||||
operation.summary || operation.description || `${method.toUpperCase()} ${path}`,
|
||||
inputSchema: this.generateInputSchema(operation, path, method as string),
|
||||
operationId: operation.operationId,
|
||||
operationId: operation.operationId || operationName,
|
||||
method: method as string,
|
||||
path,
|
||||
parameters: operation.parameters as OpenAPIV3.ParameterObject[],
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
import { McpSettings } from '../types/index.js';
|
||||
import { McpSettings, IUser } from '../types/index.js';
|
||||
import { getConfigFilePath } from '../utils/path.js';
|
||||
import { getPackageVersion } from '../utils/version.js';
|
||||
import { getDataService } from '../services/services.js';
|
||||
import { DataService } from '../services/dataService.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -11,10 +13,13 @@ const defaultConfig = {
|
||||
initTimeout: process.env.INIT_TIMEOUT || 300000,
|
||||
timeout: process.env.REQUEST_TIMEOUT || 60000,
|
||||
basePath: process.env.BASE_PATH || '',
|
||||
readonly: 'true' === process.env.READONLY || false,
|
||||
mcpHubName: 'mcphub',
|
||||
mcpHubVersion: getPackageVersion(),
|
||||
};
|
||||
|
||||
const dataService: DataService = getDataService();
|
||||
|
||||
// Settings cache
|
||||
let settingsCache: McpSettings | null = null;
|
||||
|
||||
@@ -22,7 +27,7 @@ export const getSettingsPath = (): string => {
|
||||
return getConfigFilePath('mcp_settings.json', 'Settings');
|
||||
};
|
||||
|
||||
export const loadSettings = (): McpSettings => {
|
||||
export const loadOriginalSettings = (): McpSettings => {
|
||||
// If cache exists, return cached data directly
|
||||
if (settingsCache) {
|
||||
return settingsCache;
|
||||
@@ -49,13 +54,18 @@ export const loadSettings = (): McpSettings => {
|
||||
}
|
||||
};
|
||||
|
||||
export const saveSettings = (settings: McpSettings): boolean => {
|
||||
export const loadSettings = (user?: IUser): McpSettings => {
|
||||
return dataService.filterSettings!(loadOriginalSettings(), user);
|
||||
};
|
||||
|
||||
export const saveSettings = (settings: McpSettings, user?: IUser): boolean => {
|
||||
const settingsPath = getSettingsPath();
|
||||
try {
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
|
||||
const mergedSettings = dataService.mergeSettings!(loadOriginalSettings(), settings, user);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(mergedSettings, null, 2), 'utf8');
|
||||
|
||||
// Update cache after successful save
|
||||
settingsCache = settings;
|
||||
settingsCache = mergedSettings;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -80,17 +90,42 @@ export const getSettingsCacheInfo = (): { hasCache: boolean } => {
|
||||
};
|
||||
};
|
||||
|
||||
export const replaceEnvVars = (env: Record<string, any>): Record<string, any> => {
|
||||
const res: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (typeof value === 'string') {
|
||||
res[key] = expandEnvVars(value);
|
||||
} else {
|
||||
res[key] = String(value);
|
||||
export function replaceEnvVars(input: Record<string, any>): Record<string, any>;
|
||||
export function replaceEnvVars(input: string[] | undefined): string[];
|
||||
export function replaceEnvVars(input: string): string;
|
||||
export function replaceEnvVars(
|
||||
input: Record<string, any> | string[] | string | undefined,
|
||||
): Record<string, any> | string[] | string {
|
||||
// Handle object input
|
||||
if (input && typeof input === 'object' && !Array.isArray(input)) {
|
||||
const res: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (typeof value === 'string') {
|
||||
res[key] = expandEnvVars(value);
|
||||
} else {
|
||||
res[key] = String(value);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
return res;
|
||||
};
|
||||
|
||||
// Handle array input
|
||||
if (Array.isArray(input)) {
|
||||
return input.map((item) => expandEnvVars(item));
|
||||
}
|
||||
|
||||
// Handle string input
|
||||
if (typeof input === 'string') {
|
||||
return expandEnvVars(input);
|
||||
}
|
||||
|
||||
// Handle undefined/null array input
|
||||
if (input === undefined || input === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
export const expandEnvVars = (value: string): string => {
|
||||
if (typeof value !== 'string') {
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { Request, Response } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { validationResult } from 'express-validator';
|
||||
import { findUserByUsername, verifyPassword, createUser, updateUserPassword } from '../models/User.js';
|
||||
import {
|
||||
findUserByUsername,
|
||||
verifyPassword,
|
||||
createUser,
|
||||
updateUserPassword,
|
||||
} from '../models/User.js';
|
||||
import { getDataService } from '../services/services.js';
|
||||
import { DataService } from '../services/dataService.js';
|
||||
|
||||
const dataService: DataService = getDataService();
|
||||
|
||||
// Default secret key - in production, use an environment variable
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this';
|
||||
@@ -9,10 +18,17 @@ const TOKEN_EXPIRY = '24h';
|
||||
|
||||
// Login user
|
||||
export const login = async (req: Request, res: Response): Promise<void> => {
|
||||
// Get translation function from request
|
||||
const t = (req as any).t;
|
||||
|
||||
// Validate request
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
res.status(400).json({ success: false, errors: errors.array() });
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: t('api.errors.validation_failed'),
|
||||
errors: errors.array(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -21,17 +37,23 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Find user by username
|
||||
const user = findUserByUsername(username);
|
||||
|
||||
|
||||
if (!user) {
|
||||
res.status(401).json({ success: false, message: 'Invalid credentials' });
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: t('api.errors.invalid_credentials'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isPasswordValid = await verifyPassword(password, user.password);
|
||||
|
||||
|
||||
if (!isPasswordValid) {
|
||||
res.status(401).json({ success: false, message: 'Invalid credentials' });
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: t('api.errors.invalid_credentials'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,38 +61,45 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
||||
const payload = {
|
||||
user: {
|
||||
username: user.username,
|
||||
isAdmin: user.isAdmin || false
|
||||
}
|
||||
isAdmin: user.isAdmin || false,
|
||||
},
|
||||
};
|
||||
|
||||
jwt.sign(
|
||||
payload,
|
||||
JWT_SECRET,
|
||||
{ expiresIn: TOKEN_EXPIRY },
|
||||
(err, token) => {
|
||||
if (err) throw err;
|
||||
res.json({
|
||||
success: true,
|
||||
token,
|
||||
user: {
|
||||
username: user.username,
|
||||
isAdmin: user.isAdmin
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY }, (err, token) => {
|
||||
if (err) throw err;
|
||||
res.json({
|
||||
success: true,
|
||||
message: t('api.success.login_successful'),
|
||||
token,
|
||||
user: {
|
||||
username: user.username,
|
||||
isAdmin: user.isAdmin,
|
||||
permissions: dataService.getPermissions(user),
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ success: false, message: 'Server error' });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: t('api.errors.server_error'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Register new user
|
||||
export const register = async (req: Request, res: Response): Promise<void> => {
|
||||
// Get translation function from request
|
||||
const t = (req as any).t;
|
||||
|
||||
// Validate request
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
res.status(400).json({ success: false, errors: errors.array() });
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: t('api.errors.validation_failed'),
|
||||
errors: errors.array(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -79,7 +108,7 @@ export const register = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Create new user
|
||||
const newUser = await createUser({ username, password, isAdmin });
|
||||
|
||||
|
||||
if (!newUser) {
|
||||
res.status(400).json({ success: false, message: 'User already exists' });
|
||||
return;
|
||||
@@ -89,26 +118,22 @@ export const register = async (req: Request, res: Response): Promise<void> => {
|
||||
const payload = {
|
||||
user: {
|
||||
username: newUser.username,
|
||||
isAdmin: newUser.isAdmin || false
|
||||
}
|
||||
isAdmin: newUser.isAdmin || false,
|
||||
},
|
||||
};
|
||||
|
||||
jwt.sign(
|
||||
payload,
|
||||
JWT_SECRET,
|
||||
{ expiresIn: TOKEN_EXPIRY },
|
||||
(err, token) => {
|
||||
if (err) throw err;
|
||||
res.json({
|
||||
success: true,
|
||||
token,
|
||||
user: {
|
||||
username: newUser.username,
|
||||
isAdmin: newUser.isAdmin
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY }, (err, token) => {
|
||||
if (err) throw err;
|
||||
res.json({
|
||||
success: true,
|
||||
token,
|
||||
user: {
|
||||
username: newUser.username,
|
||||
isAdmin: newUser.isAdmin,
|
||||
permissions: dataService.getPermissions(newUser),
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
res.status(500).json({ success: false, message: 'Server error' });
|
||||
@@ -120,13 +145,14 @@ export const getCurrentUser = (req: Request, res: Response): void => {
|
||||
try {
|
||||
// User is already attached to request by auth middleware
|
||||
const user = (req as any).user;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: {
|
||||
username: user.username,
|
||||
isAdmin: user.isAdmin
|
||||
}
|
||||
isAdmin: user.isAdmin,
|
||||
permissions: dataService.getPermissions(user),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get current user error:', error);
|
||||
@@ -149,7 +175,7 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
|
||||
try {
|
||||
// Find user by username
|
||||
const user = findUserByUsername(username);
|
||||
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({ success: false, message: 'User not found' });
|
||||
return;
|
||||
@@ -157,7 +183,7 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
|
||||
|
||||
// Verify current password
|
||||
const isPasswordValid = await verifyPassword(currentPassword, user.password);
|
||||
|
||||
|
||||
if (!isPasswordValid) {
|
||||
res.status(401).json({ success: false, message: 'Current password is incorrect' });
|
||||
return;
|
||||
@@ -165,7 +191,7 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
|
||||
|
||||
// Update the password
|
||||
const updated = await updateUserPassword(username, newPassword);
|
||||
|
||||
|
||||
if (!updated) {
|
||||
res.status(500).json({ success: false, message: 'Failed to update password' });
|
||||
return;
|
||||
@@ -176,4 +202,4 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
|
||||
console.error('Change password error:', error);
|
||||
res.status(500).json({ success: false, message: 'Server error' });
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Request, Response } from 'express';
|
||||
import config from '../config/index.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { getDataService } from '../services/services.js';
|
||||
import { DataService } from '../services/dataService.js';
|
||||
import { IUser } from '../types/index.js';
|
||||
|
||||
const dataService: DataService = getDataService();
|
||||
|
||||
/**
|
||||
* Get runtime configuration for frontend
|
||||
@@ -28,3 +34,41 @@ export const getRuntimeConfig = (req: Request, res: Response): void => {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get public system configuration (only skipAuth setting)
|
||||
* This endpoint doesn't require authentication to allow checking if auth should be skipped
|
||||
*/
|
||||
export const getPublicConfig = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
const skipAuth = settings.systemConfig?.routing?.skipAuth || false;
|
||||
let permissions = {};
|
||||
if (skipAuth) {
|
||||
const user: IUser = {
|
||||
username: 'guest',
|
||||
password: '',
|
||||
isAdmin: true,
|
||||
};
|
||||
permissions = dataService.getPermissions(user);
|
||||
}
|
||||
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.setHeader('Expires', '0');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
skipAuth,
|
||||
permissions,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting public config:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get public configuration',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
154
src/controllers/dxtController.ts
Normal file
154
src/controllers/dxtController.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { Request, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import AdmZip from 'adm-zip';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
|
||||
// Configure multer for file uploads
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const uploadDir = path.join(process.cwd(), 'data/uploads/dxt');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const timestamp = Date.now();
|
||||
const originalName = path.parse(file.originalname).name;
|
||||
cb(null, `${originalName}-${timestamp}.dxt`);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.originalname.endsWith('.dxt')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only .dxt files are allowed'));
|
||||
}
|
||||
},
|
||||
limits: {
|
||||
fileSize: 500 * 1024 * 1024, // 500MB limit
|
||||
},
|
||||
});
|
||||
|
||||
export const uploadMiddleware = upload.single('dxtFile');
|
||||
|
||||
// Clean up old DXT server files when installing a new version
|
||||
const cleanupOldDxtServer = (serverName: string): void => {
|
||||
try {
|
||||
const uploadDir = path.join(process.cwd(), 'data/uploads/dxt');
|
||||
const serverPattern = `server-${serverName}`;
|
||||
|
||||
if (fs.existsSync(uploadDir)) {
|
||||
const files = fs.readdirSync(uploadDir);
|
||||
files.forEach((file) => {
|
||||
if (file.startsWith(serverPattern)) {
|
||||
const filePath = path.join(uploadDir, file);
|
||||
if (fs.statSync(filePath).isDirectory()) {
|
||||
fs.rmSync(filePath, { recursive: true, force: true });
|
||||
console.log(`Cleaned up old DXT server directory: ${filePath}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to cleanup old DXT server files:', error);
|
||||
// Don't fail the installation if cleanup fails
|
||||
}
|
||||
};
|
||||
|
||||
export const uploadDxtFile = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'No DXT file uploaded',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const dxtFilePath = req.file.path;
|
||||
const timestamp = Date.now();
|
||||
const tempExtractDir = path.join(path.dirname(dxtFilePath), `temp-extracted-${timestamp}`);
|
||||
|
||||
try {
|
||||
// Extract the DXT file (which is a ZIP archive) to a temporary directory first
|
||||
const zip = new AdmZip(dxtFilePath);
|
||||
zip.extractAllTo(tempExtractDir, true);
|
||||
|
||||
// Read and validate the manifest.json
|
||||
const manifestPath = path.join(tempExtractDir, 'manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
throw new Error('manifest.json not found in DXT file');
|
||||
}
|
||||
|
||||
const manifestContent = fs.readFileSync(manifestPath, 'utf-8');
|
||||
const manifest = JSON.parse(manifestContent);
|
||||
|
||||
// Validate required fields in manifest
|
||||
if (!manifest.dxt_version) {
|
||||
throw new Error('Invalid manifest: missing dxt_version');
|
||||
}
|
||||
if (!manifest.name) {
|
||||
throw new Error('Invalid manifest: missing name');
|
||||
}
|
||||
if (!manifest.version) {
|
||||
throw new Error('Invalid manifest: missing version');
|
||||
}
|
||||
if (!manifest.server) {
|
||||
throw new Error('Invalid manifest: missing server configuration');
|
||||
}
|
||||
|
||||
// Use server name as the final extract directory for automatic version management
|
||||
const finalExtractDir = path.join(path.dirname(dxtFilePath), `server-${manifest.name}`);
|
||||
|
||||
// Clean up any existing version of this server
|
||||
cleanupOldDxtServer(manifest.name);
|
||||
if (!fs.existsSync(finalExtractDir)) {
|
||||
fs.mkdirSync(finalExtractDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Move the temporary directory to the final location
|
||||
fs.renameSync(tempExtractDir, finalExtractDir);
|
||||
console.log(`DXT server extracted to: ${finalExtractDir}`);
|
||||
|
||||
// Clean up the uploaded DXT file
|
||||
fs.unlinkSync(dxtFilePath);
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: {
|
||||
manifest,
|
||||
extractDir: finalExtractDir,
|
||||
},
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (extractError) {
|
||||
// Clean up files on error
|
||||
if (fs.existsSync(dxtFilePath)) {
|
||||
fs.unlinkSync(dxtFilePath);
|
||||
}
|
||||
if (fs.existsSync(tempExtractDir)) {
|
||||
fs.rmSync(tempExtractDir, { recursive: true, force: true });
|
||||
}
|
||||
throw extractError;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('DXT upload error:', error);
|
||||
|
||||
let message = 'Failed to process DXT file';
|
||||
if (error instanceof Error) {
|
||||
message = error.message;
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
deleteGroup,
|
||||
addServerToGroup,
|
||||
removeServerFromGroup,
|
||||
getServersInGroup
|
||||
getServerConfigInGroup,
|
||||
getServerConfigsInGroup,
|
||||
updateServerToolsInGroup,
|
||||
} from '../services/groupService.js';
|
||||
|
||||
// Get all groups
|
||||
@@ -76,7 +78,12 @@ export const createNewGroup = (req: Request, res: Response): void => {
|
||||
}
|
||||
|
||||
const serverList = Array.isArray(servers) ? servers : [];
|
||||
const newGroup = createGroup(name, description, serverList);
|
||||
|
||||
// Set owner property - use current user's username, default to 'admin'
|
||||
const currentUser = (req as any).user;
|
||||
const owner = currentUser?.username || 'admin';
|
||||
|
||||
const newGroup = createGroup(name, description, serverList, owner);
|
||||
if (!newGroup) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -149,12 +156,12 @@ export const updateExistingGroup = (req: Request, res: Response): void => {
|
||||
}
|
||||
};
|
||||
|
||||
// Update servers in a group (batch update)
|
||||
// Update servers in a group (batch update) - supports both string[] and server config format
|
||||
export const updateGroupServersBatch = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { servers } = req.body;
|
||||
|
||||
|
||||
if (!id) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -166,11 +173,36 @@ export const updateGroupServersBatch = (req: Request, res: Response): void => {
|
||||
if (!Array.isArray(servers)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Servers must be an array of server names',
|
||||
message: 'Servers must be an array of server names or server configurations',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate server configurations if provided in new format
|
||||
for (const server of servers) {
|
||||
if (typeof server === 'object' && server !== null) {
|
||||
if (!server.name || typeof server.name !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Each server configuration must have a valid name',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
server.tools &&
|
||||
server.tools !== 'all' &&
|
||||
(!Array.isArray(server.tools) ||
|
||||
!server.tools.every((tool: any) => typeof tool === 'string'))
|
||||
) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Tools must be "all" or an array of strings',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updatedGroup = updateGroupServers(id, servers);
|
||||
if (!updatedGroup) {
|
||||
res.status(404).json({
|
||||
@@ -338,4 +370,113 @@ export const getGroupServers = (req: Request, res: Response): void => {
|
||||
message: 'Failed to get group servers',
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Get server configurations in a group (including tool selections)
|
||||
export const getGroupServerConfigs = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!id) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Group ID is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const serverConfigs = getServerConfigsInGroup(id);
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: serverConfigs,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get group server configurations',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get specific server configuration in a group
|
||||
export const getGroupServerConfig = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { id, serverName } = req.params;
|
||||
if (!id || !serverName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Group ID and server name are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const serverConfig = getServerConfigInGroup(id, serverName);
|
||||
if (!serverConfig) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Server not found in group',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: serverConfig,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get server configuration',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Update tools for a specific server in a group
|
||||
export const updateGroupServerTools = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { id, serverName } = req.params;
|
||||
const { tools } = req.body;
|
||||
|
||||
if (!id || !serverName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Group ID and server name are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate tools parameter
|
||||
if (
|
||||
tools !== 'all' &&
|
||||
(!Array.isArray(tools) || !tools.every((tool) => typeof tool === 'string'))
|
||||
) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Tools must be "all" or an array of strings',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedGroup = updateServerToolsInGroup(id, serverName, tools);
|
||||
if (!updatedGroup) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Group or server not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: updatedGroup,
|
||||
message: 'Server tools updated successfully',
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal server error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,24 +3,26 @@ import { ApiResponse, AddServerRequest } from '../types/index.js';
|
||||
import {
|
||||
getServersInfo,
|
||||
addServer,
|
||||
addOrUpdateServer,
|
||||
removeServer,
|
||||
updateMcpServer,
|
||||
notifyToolChanged,
|
||||
syncToolEmbedding,
|
||||
toggleServerStatus,
|
||||
} from '../services/mcpService.js';
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
import { syncAllServerToolsEmbeddings } from '../services/vectorSearchService.js';
|
||||
import { createSafeJSON } from '../utils/serialization.js';
|
||||
|
||||
export const getAllServers = (_: Request, res: Response): void => {
|
||||
try {
|
||||
const serversInfo = getServersInfo();
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: serversInfo,
|
||||
data: createSafeJSON(serversInfo),
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to get servers information:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get servers information',
|
||||
@@ -33,7 +35,7 @@ export const getAllSettings = (_: Request, res: Response): void => {
|
||||
const settings = loadSettings();
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: settings,
|
||||
data: createSafeJSON(settings),
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
@@ -127,6 +129,12 @@ export const createServer = async (req: Request, res: Response): Promise<void> =
|
||||
config.keepAliveInterval = 60000; // Default 60 seconds for SSE servers
|
||||
}
|
||||
|
||||
// Set owner property - use current user's username, default to 'admin'
|
||||
if (!config.owner) {
|
||||
const currentUser = (req as any).user;
|
||||
config.owner = currentUser?.username || 'admin';
|
||||
}
|
||||
|
||||
const result = await addServer(name, config);
|
||||
if (result.success) {
|
||||
notifyToolChanged();
|
||||
@@ -264,7 +272,13 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
|
||||
config.keepAliveInterval = 60000; // Default 60 seconds for SSE servers
|
||||
}
|
||||
|
||||
const result = await updateMcpServer(name, config);
|
||||
// Set owner property if not provided - use current user's username, default to 'admin'
|
||||
if (!config.owner) {
|
||||
const currentUser = (req as any).user;
|
||||
config.owner = currentUser?.username || 'admin';
|
||||
}
|
||||
|
||||
const result = await addOrUpdateServer(name, config, true); // Allow override for updates
|
||||
if (result.success) {
|
||||
notifyToolChanged();
|
||||
res.json({
|
||||
@@ -492,15 +506,19 @@ export const updateToolDescription = async (req: Request, res: Response): Promis
|
||||
export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { routing, install, smartRouting } = req.body;
|
||||
const currentUser = (req as any).user;
|
||||
|
||||
if (
|
||||
(!routing ||
|
||||
(typeof routing.enableGlobalRoute !== 'boolean' &&
|
||||
typeof routing.enableGroupNameRoute !== 'boolean' &&
|
||||
typeof routing.enableBearerAuth !== 'boolean' &&
|
||||
typeof routing.bearerAuthKey !== 'string')) &&
|
||||
typeof routing.bearerAuthKey !== 'string' &&
|
||||
typeof routing.skipAuth !== 'boolean')) &&
|
||||
(!install ||
|
||||
(typeof install.pythonIndexUrl !== 'string' && typeof install.npmRegistry !== 'string')) &&
|
||||
(typeof install.pythonIndexUrl !== 'string' &&
|
||||
typeof install.npmRegistry !== 'string' &&
|
||||
typeof install.baseUrl !== 'string')) &&
|
||||
(!smartRouting ||
|
||||
(typeof smartRouting.enabled !== 'boolean' &&
|
||||
typeof smartRouting.dbUrl !== 'string' &&
|
||||
@@ -523,10 +541,12 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
skipAuth: false,
|
||||
},
|
||||
install: {
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
},
|
||||
smartRouting: {
|
||||
enabled: false,
|
||||
@@ -544,6 +564,7 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
skipAuth: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -551,6 +572,7 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
settings.systemConfig.install = {
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -580,6 +602,10 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
if (typeof routing.bearerAuthKey === 'string') {
|
||||
settings.systemConfig.routing.bearerAuthKey = routing.bearerAuthKey;
|
||||
}
|
||||
|
||||
if (typeof routing.skipAuth === 'boolean') {
|
||||
settings.systemConfig.routing.skipAuth = routing.skipAuth;
|
||||
}
|
||||
}
|
||||
|
||||
if (install) {
|
||||
@@ -589,6 +615,9 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
if (typeof install.npmRegistry === 'string') {
|
||||
settings.systemConfig.install.npmRegistry = install.npmRegistry;
|
||||
}
|
||||
if (typeof install.baseUrl === 'string') {
|
||||
settings.systemConfig.install.baseUrl = install.baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// Track smartRouting state and configuration changes
|
||||
@@ -647,7 +676,7 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
needsSync = (!wasSmartRoutingEnabled && isNowEnabled) || (isNowEnabled && hasConfigChanged);
|
||||
}
|
||||
|
||||
if (saveSettings(settings)) {
|
||||
if (saveSettings(settings, currentUser)) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: settings.systemConfig,
|
||||
|
||||
269
src/controllers/userController.ts
Normal file
269
src/controllers/userController.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
import {
|
||||
getAllUsers,
|
||||
getUserByUsername,
|
||||
createNewUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
getUserCount,
|
||||
getAdminCount,
|
||||
} from '../services/userService.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
|
||||
// Admin permission check middleware function
|
||||
const requireAdmin = (req: Request, res: Response): boolean => {
|
||||
const settings = loadSettings();
|
||||
if (settings.systemConfig?.routing?.skipAuth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const user = (req as any).user;
|
||||
if (!user || !user.isAdmin) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: 'Admin privileges required',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Get all users (admin only)
|
||||
export const getUsers = (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
|
||||
try {
|
||||
const users = getAllUsers().map(({ password: _, ...user }) => user); // Remove password from response
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: users,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get users information',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get a specific user by username (admin only)
|
||||
export const getUser = (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
|
||||
try {
|
||||
const { username } = req.params;
|
||||
if (!username) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Username is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const user = getUserByUsername(username);
|
||||
if (!user) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { password: _, ...userData } = user; // Remove password from response
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: userData,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get user information',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Create a new user (admin only)
|
||||
export const createUser = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
|
||||
try {
|
||||
const { username, password, isAdmin } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Username and password are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const newUser = await createNewUser(username, password, isAdmin || false);
|
||||
if (!newUser) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Failed to create user or username already exists',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { password: _, ...userData } = newUser; // Remove password from response
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: userData,
|
||||
message: 'User created successfully',
|
||||
};
|
||||
res.status(201).json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal server error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Update an existing user (admin only)
|
||||
export const updateExistingUser = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
|
||||
try {
|
||||
const { username } = req.params;
|
||||
const { isAdmin, newPassword } = req.body;
|
||||
|
||||
if (!username) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Username is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if trying to change admin status
|
||||
if (isAdmin !== undefined) {
|
||||
const currentUser = getUserByUsername(username);
|
||||
if (!currentUser) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent removing admin status from the last admin
|
||||
if (currentUser.isAdmin && !isAdmin && getAdminCount() === 1) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Cannot remove admin status from the last admin user',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (isAdmin !== undefined) updateData.isAdmin = isAdmin;
|
||||
if (newPassword) updateData.newPassword = newPassword;
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'At least one field (isAdmin or newPassword) is required to update',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedUser = await updateUser(username, updateData);
|
||||
if (!updatedUser) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'User not found or update failed',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { password: _, ...userData } = updatedUser; // Remove password from response
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: userData,
|
||||
message: 'User updated successfully',
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal server error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Delete a user (admin only)
|
||||
export const deleteExistingUser = (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
|
||||
try {
|
||||
const { username } = req.params;
|
||||
if (!username) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Username is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if trying to delete the current admin user
|
||||
const currentUser = (req as any).user;
|
||||
if (currentUser.username === username) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Cannot delete your own account',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const success = deleteUser(username);
|
||||
if (!success) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'User not found, failed to delete, or cannot delete the last admin',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'User deleted successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal server error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get user statistics (admin only)
|
||||
export const getUserStats = (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
|
||||
try {
|
||||
const totalUsers = getUserCount();
|
||||
const adminUsers = getAdminCount();
|
||||
const regularUsers = totalUsers - adminUsers;
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: {
|
||||
totalUsers,
|
||||
adminUsers,
|
||||
regularUsers,
|
||||
},
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get user statistics',
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,11 +1,68 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import defaultConfig from '../config/index.js';
|
||||
|
||||
// Default secret key - in production, use an environment variable
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this';
|
||||
|
||||
const validateBearerAuth = (req: Request, routingConfig: any): boolean => {
|
||||
if (!routingConfig.enableBearerAuth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return authHeader.substring(7) === routingConfig.bearerAuthKey;
|
||||
};
|
||||
|
||||
const readonlyAllowPaths = ['/tools/call/'];
|
||||
|
||||
const checkReadonly = (req: Request): boolean => {
|
||||
if (!defaultConfig.readonly) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const path of readonlyAllowPaths) {
|
||||
if (req.path.startsWith(defaultConfig.basePath + path)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return req.method === 'GET';
|
||||
};
|
||||
|
||||
// Middleware to authenticate JWT token
|
||||
export const auth = (req: Request, res: Response, next: NextFunction): void => {
|
||||
const t = (req as any).t;
|
||||
if (!checkReadonly(req)) {
|
||||
res.status(403).json({ success: false, message: t('api.errors.readonly') });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if authentication is disabled globally
|
||||
const routingConfig = loadSettings().systemConfig?.routing || {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
skipAuth: false,
|
||||
};
|
||||
|
||||
if (routingConfig.skipAuth) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if bearer auth is enabled and validate it
|
||||
if (validateBearerAuth(req, routingConfig)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get token from header or query parameter
|
||||
const headerToken = req.header('x-auth-token');
|
||||
const queryToken = req.query.token as string;
|
||||
@@ -20,11 +77,11 @@ export const auth = (req: Request, res: Response, next: NextFunction): void => {
|
||||
// Verify token
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
|
||||
// Add user from payload to request
|
||||
(req as any).user = (decoded as any).user;
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(401).json({ success: false, message: 'Token is not valid' });
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
41
src/middlewares/i18n.ts
Normal file
41
src/middlewares/i18n.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { getT } from '../utils/i18n.js';
|
||||
|
||||
/**
|
||||
* i18n middleware to detect user language and attach translation function to request
|
||||
*/
|
||||
export const i18nMiddleware = (req: Request, res: Response, next: NextFunction) => {
|
||||
// Detect language from various sources (prioritized)
|
||||
const acceptLanguage = req.headers['accept-language'];
|
||||
const customLanguageHeader = req.headers['x-language'] as string;
|
||||
const languageFromQuery = req.query.lang as string;
|
||||
|
||||
// Default to English
|
||||
let detectedLanguage = 'en';
|
||||
|
||||
// Priority order: query parameter > custom header > accept-language header
|
||||
if (languageFromQuery) {
|
||||
detectedLanguage = languageFromQuery;
|
||||
} else if (customLanguageHeader) {
|
||||
detectedLanguage = customLanguageHeader;
|
||||
} else if (acceptLanguage) {
|
||||
// Parse accept-language header and get primary language
|
||||
const primaryLanguage = acceptLanguage.split(',')[0].split('-')[0].trim();
|
||||
detectedLanguage = primaryLanguage;
|
||||
}
|
||||
|
||||
// Normalize language code (ensure we support it)
|
||||
const supportedLanguages = ['en', 'zh'];
|
||||
if (!supportedLanguages.includes(detectedLanguage)) {
|
||||
detectedLanguage = 'en'; // fallback to English
|
||||
}
|
||||
|
||||
// Set language in request (using any type to avoid TypeScript issues)
|
||||
(req as any).language = detectedLanguage;
|
||||
|
||||
// Get translation function for the detected language
|
||||
const t = getT(detectedLanguage);
|
||||
(req as any).t = t;
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import { auth } from './auth.js';
|
||||
import { userContextMiddleware } from './userContext.js';
|
||||
import { i18nMiddleware } from './i18n.js';
|
||||
import { initializeDefaultUser } from '../models/User.js';
|
||||
import config from '../config/index.js';
|
||||
|
||||
@@ -17,6 +19,9 @@ export const errorHandler = (
|
||||
};
|
||||
|
||||
export const initMiddlewares = (app: express.Application): void => {
|
||||
// Apply i18n middleware first to detect language for all requests
|
||||
app.use(i18nMiddleware);
|
||||
|
||||
// Serve static files from the dynamically determined frontend path
|
||||
// Note: Static files will be handled by the server directly, not here
|
||||
|
||||
@@ -27,7 +32,13 @@ export const initMiddlewares = (app: express.Application): void => {
|
||||
if (
|
||||
req.path !== `${basePath}/sse` &&
|
||||
!req.path.startsWith(`${basePath}/sse/`) &&
|
||||
req.path !== `${basePath}/messages`
|
||||
req.path !== `${basePath}/messages` &&
|
||||
!req.path.match(
|
||||
new RegExp(`^${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/[^/]+/messages$`),
|
||||
) &&
|
||||
!req.path.match(
|
||||
new RegExp(`^${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/[^/]+/sse(/.*)?$`),
|
||||
)
|
||||
) {
|
||||
express.json()(req, res, next);
|
||||
} else {
|
||||
@@ -46,7 +57,15 @@ export const initMiddlewares = (app: express.Application): void => {
|
||||
if (req.path === '/auth/login' || req.path === '/auth/register') {
|
||||
next();
|
||||
} else {
|
||||
auth(req, res, next);
|
||||
// Apply authentication middleware first
|
||||
auth(req, res, (err) => {
|
||||
if (err) {
|
||||
next(err);
|
||||
} else {
|
||||
// Apply user context middleware after successful authentication
|
||||
userContextMiddleware(req, res, next);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
136
src/middlewares/userContext.ts
Normal file
136
src/middlewares/userContext.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { UserContextService } from '../services/userContextService.js';
|
||||
import { IUser } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* User context middleware
|
||||
* Sets user context after authentication middleware, allowing service layer to access current user information
|
||||
*/
|
||||
export const userContextMiddleware = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const currentUser = (req as any).user as IUser;
|
||||
|
||||
if (currentUser) {
|
||||
// Set user context
|
||||
const userContextService = UserContextService.getInstance();
|
||||
userContextService.setCurrentUser(currentUser);
|
||||
|
||||
// Clean up user context when response ends
|
||||
res.on('finish', () => {
|
||||
const userContextService = UserContextService.getInstance();
|
||||
userContextService.clearCurrentUser();
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Error in user context middleware:', error);
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* User context middleware for SSE/MCP endpoints
|
||||
* Extracts user from URL path parameter and sets user context
|
||||
*/
|
||||
export const sseUserContextMiddleware = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const userContextService = UserContextService.getInstance();
|
||||
const username = req.params.user;
|
||||
|
||||
if (username) {
|
||||
// For user-scoped routes, set the user context
|
||||
// Note: In a real implementation, you should validate the user exists
|
||||
// and has proper permissions
|
||||
const user: IUser = {
|
||||
username,
|
||||
password: '',
|
||||
isAdmin: false, // TODO: Should be retrieved from user database
|
||||
};
|
||||
|
||||
userContextService.setCurrentUser(user);
|
||||
|
||||
// Clean up user context when response ends
|
||||
res.on('finish', () => {
|
||||
userContextService.clearCurrentUser();
|
||||
});
|
||||
|
||||
// Also clean up on connection close for SSE
|
||||
res.on('close', () => {
|
||||
userContextService.clearCurrentUser();
|
||||
});
|
||||
|
||||
console.log(`User context set for SSE/MCP endpoint: ${username}`);
|
||||
} else {
|
||||
// For global routes, clear user context (admin access)
|
||||
userContextService.clearCurrentUser();
|
||||
console.log('Global SSE/MCP endpoint access - no user context');
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Error in SSE user context middleware:', error);
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extended data service that can directly access current user context
|
||||
*/
|
||||
export interface ContextAwareDataService {
|
||||
getCurrentUserFromContext(): Promise<IUser | null>;
|
||||
getUserDataFromContext(dataType: string): Promise<any>;
|
||||
isCurrentUserAdmin(): Promise<boolean>;
|
||||
}
|
||||
|
||||
export class ContextAwareDataServiceImpl implements ContextAwareDataService {
|
||||
private getUserContextService() {
|
||||
return UserContextService.getInstance();
|
||||
}
|
||||
|
||||
async getCurrentUserFromContext(): Promise<IUser | null> {
|
||||
const userContextService = this.getUserContextService();
|
||||
return userContextService.getCurrentUser();
|
||||
}
|
||||
|
||||
async getUserDataFromContext(dataType: string): Promise<any> {
|
||||
const userContextService = this.getUserContextService();
|
||||
const user = userContextService.getCurrentUser();
|
||||
|
||||
if (!user) {
|
||||
throw new Error('No user in context');
|
||||
}
|
||||
|
||||
console.log(`Getting ${dataType} data for user: ${user.username}`);
|
||||
|
||||
// Return different data based on user permissions
|
||||
if (user.isAdmin) {
|
||||
return {
|
||||
type: dataType,
|
||||
data: 'Admin level data from context',
|
||||
user: user.username,
|
||||
access: 'full',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: dataType,
|
||||
data: 'User level data from context',
|
||||
user: user.username,
|
||||
access: 'limited',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async isCurrentUserAdmin(): Promise<boolean> {
|
||||
const userContextService = this.getUserContextService();
|
||||
return userContextService.isAdmin();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { IUser, McpSettings } from '../types/index.js';
|
||||
import { IUser } from '../types/index.js';
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
|
||||
// Get all users
|
||||
@@ -29,38 +27,38 @@ const saveUsers = (users: IUser[]): void => {
|
||||
// Create a new user
|
||||
export const createUser = async (userData: IUser): Promise<IUser | null> => {
|
||||
const users = getUsers();
|
||||
|
||||
|
||||
// Check if username already exists
|
||||
if (users.some(user => user.username === userData.username)) {
|
||||
if (users.some((user) => user.username === userData.username)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Hash the password
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
const hashedPassword = await bcrypt.hash(userData.password, salt);
|
||||
|
||||
|
||||
const newUser = {
|
||||
username: userData.username,
|
||||
password: hashedPassword,
|
||||
isAdmin: userData.isAdmin || false
|
||||
isAdmin: userData.isAdmin || false,
|
||||
};
|
||||
|
||||
|
||||
users.push(newUser);
|
||||
saveUsers(users);
|
||||
|
||||
|
||||
return newUser;
|
||||
};
|
||||
|
||||
// Find user by username
|
||||
export const findUserByUsername = (username: string): IUser | undefined => {
|
||||
const users = getUsers();
|
||||
return users.find(user => user.username === username);
|
||||
return users.find((user) => user.username === username);
|
||||
};
|
||||
|
||||
// Verify user password
|
||||
export const verifyPassword = async (
|
||||
plainPassword: string,
|
||||
hashedPassword: string
|
||||
plainPassword: string,
|
||||
hashedPassword: string,
|
||||
): Promise<boolean> => {
|
||||
return await bcrypt.compare(plainPassword, hashedPassword);
|
||||
};
|
||||
@@ -68,36 +66,36 @@ export const verifyPassword = async (
|
||||
// Update user password
|
||||
export const updateUserPassword = async (
|
||||
username: string,
|
||||
newPassword: string
|
||||
newPassword: string,
|
||||
): Promise<boolean> => {
|
||||
const users = getUsers();
|
||||
const userIndex = users.findIndex(user => user.username === username);
|
||||
|
||||
const userIndex = users.findIndex((user) => user.username === username);
|
||||
|
||||
if (userIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Hash the new password
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
const hashedPassword = await bcrypt.hash(newPassword, salt);
|
||||
|
||||
|
||||
// Update the user's password
|
||||
users[userIndex].password = hashedPassword;
|
||||
saveUsers(users);
|
||||
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Initialize with default admin user if no users exist
|
||||
export const initializeDefaultUser = async (): Promise<void> => {
|
||||
const users = getUsers();
|
||||
|
||||
|
||||
if (users.length === 0) {
|
||||
await createUser({
|
||||
username: 'admin',
|
||||
password: 'admin123',
|
||||
isAdmin: true
|
||||
isAdmin: true,
|
||||
});
|
||||
console.log('Default admin user created');
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -22,7 +22,18 @@ import {
|
||||
removeServerFromExistingGroup,
|
||||
getGroupServers,
|
||||
updateGroupServersBatch,
|
||||
getGroupServerConfigs,
|
||||
getGroupServerConfig,
|
||||
updateGroupServerTools,
|
||||
} from '../controllers/groupController.js';
|
||||
import {
|
||||
getUsers,
|
||||
getUser,
|
||||
createUser,
|
||||
updateExistingUser,
|
||||
deleteExistingUser,
|
||||
getUserStats,
|
||||
} from '../controllers/userController.js';
|
||||
import {
|
||||
getAllMarketServers,
|
||||
getMarketServer,
|
||||
@@ -34,8 +45,9 @@ import {
|
||||
} from '../controllers/marketController.js';
|
||||
import { login, register, getCurrentUser, changePassword } from '../controllers/authController.js';
|
||||
import { getAllLogs, clearLogs, streamLogs } from '../controllers/logController.js';
|
||||
import { getRuntimeConfig } from '../controllers/configController.js';
|
||||
import { getRuntimeConfig, getPublicConfig } from '../controllers/configController.js';
|
||||
import { callTool } from '../controllers/toolController.js';
|
||||
import { uploadDxtFile, uploadMiddleware } from '../controllers/dxtController.js';
|
||||
import { auth } from '../middlewares/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -63,10 +75,25 @@ export const initRoutes = (app: express.Application): void => {
|
||||
router.get('/groups/:id/servers', getGroupServers);
|
||||
// New route for batch updating servers in a group
|
||||
router.put('/groups/:id/servers/batch', updateGroupServersBatch);
|
||||
// New routes for server configurations and tool management in groups
|
||||
router.get('/groups/:id/server-configs', getGroupServerConfigs);
|
||||
router.get('/groups/:id/server-configs/:serverName', getGroupServerConfig);
|
||||
router.put('/groups/:id/server-configs/:serverName/tools', updateGroupServerTools);
|
||||
|
||||
// User management routes (admin only)
|
||||
router.get('/users', getUsers);
|
||||
router.get('/users/:username', getUser);
|
||||
router.post('/users', createUser);
|
||||
router.put('/users/:username', updateExistingUser);
|
||||
router.delete('/users/:username', deleteExistingUser);
|
||||
router.get('/users-stats', getUserStats);
|
||||
|
||||
// Tool management routes
|
||||
router.post('/tools/call/:server', callTool);
|
||||
|
||||
// DXT upload routes
|
||||
router.post('/dxt/upload', uploadMiddleware, uploadDxtFile);
|
||||
|
||||
// Market routes
|
||||
router.get('/market/servers', getAllMarketServers);
|
||||
router.get('/market/servers/search', searchMarketServersByQuery);
|
||||
@@ -116,6 +143,9 @@ export const initRoutes = (app: express.Application): void => {
|
||||
// Runtime configuration endpoint (no auth required for frontend initialization)
|
||||
app.get(`${config.basePath}/config`, getRuntimeConfig);
|
||||
|
||||
// Public configuration endpoint (no auth required to check skipAuth setting)
|
||||
app.get(`${config.basePath}/public-config`, getPublicConfig);
|
||||
|
||||
app.use(`${config.basePath}/api`, router);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import express from 'express';
|
||||
import config from './config/index.js';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import fs from 'fs';
|
||||
import { initUpstreamServers } from './services/mcpService.js';
|
||||
import { initUpstreamServers, connected } from './services/mcpService.js';
|
||||
import { initMiddlewares } from './middlewares/index.js';
|
||||
import { initRoutes } from './routes/index.js';
|
||||
import { initI18n } from './utils/i18n.js';
|
||||
import {
|
||||
handleSseConnection,
|
||||
handleSseMessage,
|
||||
@@ -13,10 +13,10 @@ import {
|
||||
handleMcpOtherRequest,
|
||||
} from './services/sseService.js';
|
||||
import { initializeDefaultUser } from './models/User.js';
|
||||
import { sseUserContextMiddleware } from './middlewares/userContext.js';
|
||||
|
||||
// Get the directory name in ESM
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
// Get the current working directory (will be project root in most cases)
|
||||
const currentFileDir = process.cwd() + '/src';
|
||||
|
||||
export class AppServer {
|
||||
private app: express.Application;
|
||||
@@ -32,6 +32,10 @@ export class AppServer {
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
try {
|
||||
// Initialize i18n before other components
|
||||
await initI18n();
|
||||
console.log('i18n initialized successfully');
|
||||
|
||||
// Initialize default admin user if no users exist
|
||||
await initializeDefaultUser();
|
||||
|
||||
@@ -42,11 +46,52 @@ export class AppServer {
|
||||
initUpstreamServers()
|
||||
.then(() => {
|
||||
console.log('MCP server initialized successfully');
|
||||
this.app.get(`${this.basePath}/sse/:group?`, (req, res) => handleSseConnection(req, res));
|
||||
this.app.post(`${this.basePath}/messages`, handleSseMessage);
|
||||
this.app.post(`${this.basePath}/mcp/:group?`, handleMcpPostRequest);
|
||||
this.app.get(`${this.basePath}/mcp/:group?`, handleMcpOtherRequest);
|
||||
this.app.delete(`${this.basePath}/mcp/:group?`, handleMcpOtherRequest);
|
||||
|
||||
// Original routes (global and group-based)
|
||||
this.app.get(`${this.basePath}/sse/:group?`, sseUserContextMiddleware, (req, res) =>
|
||||
handleSseConnection(req, res),
|
||||
);
|
||||
this.app.post(`${this.basePath}/messages`, sseUserContextMiddleware, handleSseMessage);
|
||||
this.app.post(
|
||||
`${this.basePath}/mcp/:group?`,
|
||||
sseUserContextMiddleware,
|
||||
handleMcpPostRequest,
|
||||
);
|
||||
this.app.get(
|
||||
`${this.basePath}/mcp/:group?`,
|
||||
sseUserContextMiddleware,
|
||||
handleMcpOtherRequest,
|
||||
);
|
||||
this.app.delete(
|
||||
`${this.basePath}/mcp/:group?`,
|
||||
sseUserContextMiddleware,
|
||||
handleMcpOtherRequest,
|
||||
);
|
||||
|
||||
// User-scoped routes with user context middleware
|
||||
this.app.get(`${this.basePath}/:user/sse/:group?`, sseUserContextMiddleware, (req, res) =>
|
||||
handleSseConnection(req, res),
|
||||
);
|
||||
this.app.post(
|
||||
`${this.basePath}/:user/messages`,
|
||||
sseUserContextMiddleware,
|
||||
handleSseMessage,
|
||||
);
|
||||
this.app.post(
|
||||
`${this.basePath}/:user/mcp/:group?`,
|
||||
sseUserContextMiddleware,
|
||||
handleMcpPostRequest,
|
||||
);
|
||||
this.app.get(
|
||||
`${this.basePath}/:user/mcp/:group?`,
|
||||
sseUserContextMiddleware,
|
||||
handleMcpOtherRequest,
|
||||
);
|
||||
this.app.delete(
|
||||
`${this.basePath}/:user/mcp/:group?`,
|
||||
sseUserContextMiddleware,
|
||||
handleMcpOtherRequest,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error initializing MCP server:', error);
|
||||
@@ -108,6 +153,10 @@ export class AppServer {
|
||||
});
|
||||
}
|
||||
|
||||
connected(): boolean {
|
||||
return connected();
|
||||
}
|
||||
|
||||
getApp(): express.Application {
|
||||
return this.app;
|
||||
}
|
||||
@@ -119,7 +168,7 @@ export class AppServer {
|
||||
|
||||
if (debug) {
|
||||
console.log('DEBUG: Current directory:', process.cwd());
|
||||
console.log('DEBUG: Script directory:', __dirname);
|
||||
console.log('DEBUG: Script directory:', currentFileDir);
|
||||
}
|
||||
|
||||
// First, find the package root directory
|
||||
@@ -159,13 +208,13 @@ export class AppServer {
|
||||
// Possible locations for package.json
|
||||
const possibleRoots = [
|
||||
// Standard npm package location
|
||||
path.resolve(__dirname, '..', '..'),
|
||||
path.resolve(currentFileDir, '..', '..'),
|
||||
// Current working directory
|
||||
process.cwd(),
|
||||
// When running from dist directory
|
||||
path.resolve(__dirname, '..'),
|
||||
path.resolve(currentFileDir, '..'),
|
||||
// When installed via npx
|
||||
path.resolve(__dirname, '..', '..', '..'),
|
||||
path.resolve(currentFileDir, '..', '..', '..'),
|
||||
];
|
||||
|
||||
// Special handling for npx
|
||||
|
||||
102
src/services/__tests__/schema-cleanup.test.ts
Normal file
102
src/services/__tests__/schema-cleanup.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
describe('Schema Cleanup Tests', () => {
|
||||
describe('cleanInputSchema functionality', () => {
|
||||
// Helper function to simulate the cleanInputSchema behavior
|
||||
const cleanInputSchema = (schema: any): any => {
|
||||
if (!schema || typeof schema !== 'object') {
|
||||
return schema;
|
||||
}
|
||||
|
||||
const cleanedSchema = { ...schema };
|
||||
delete cleanedSchema.$schema;
|
||||
|
||||
return cleanedSchema;
|
||||
};
|
||||
|
||||
test('should remove $schema field from inputSchema', () => {
|
||||
const schemaWithDollarSchema = {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Test property',
|
||||
},
|
||||
},
|
||||
required: ['name'],
|
||||
};
|
||||
|
||||
const cleanedSchema = cleanInputSchema(schemaWithDollarSchema);
|
||||
|
||||
expect(cleanedSchema).not.toHaveProperty('$schema');
|
||||
expect(cleanedSchema.type).toBe('object');
|
||||
expect(cleanedSchema.properties).toEqual({
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Test property',
|
||||
},
|
||||
});
|
||||
expect(cleanedSchema.required).toEqual(['name']);
|
||||
});
|
||||
|
||||
test('should handle null and undefined schemas', () => {
|
||||
expect(cleanInputSchema(null)).toBe(null);
|
||||
expect(cleanInputSchema(undefined)).toBe(undefined);
|
||||
});
|
||||
|
||||
test('should handle non-object schemas', () => {
|
||||
expect(cleanInputSchema('string')).toBe('string');
|
||||
expect(cleanInputSchema(42)).toBe(42);
|
||||
expect(cleanInputSchema(true)).toBe(true);
|
||||
});
|
||||
|
||||
test('should preserve other properties while removing $schema', () => {
|
||||
const complexSchema = {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
type: 'object',
|
||||
title: 'Test Schema',
|
||||
description: 'A test schema',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
age: { type: 'number' },
|
||||
},
|
||||
required: ['name'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const cleanedSchema = cleanInputSchema(complexSchema);
|
||||
|
||||
expect(cleanedSchema).not.toHaveProperty('$schema');
|
||||
expect(cleanedSchema.type).toBe('object');
|
||||
expect(cleanedSchema.title).toBe('Test Schema');
|
||||
expect(cleanedSchema.description).toBe('A test schema');
|
||||
expect(cleanedSchema.properties).toEqual({
|
||||
name: { type: 'string' },
|
||||
age: { type: 'number' },
|
||||
});
|
||||
expect(cleanedSchema.required).toEqual(['name']);
|
||||
expect(cleanedSchema.additionalProperties).toBe(false);
|
||||
});
|
||||
|
||||
test('should handle schemas without $schema field', () => {
|
||||
const schemaWithoutDollarSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
},
|
||||
};
|
||||
|
||||
const cleanedSchema = cleanInputSchema(schemaWithoutDollarSchema);
|
||||
|
||||
expect(cleanedSchema).toEqual(schemaWithoutDollarSchema);
|
||||
expect(cleanedSchema).not.toHaveProperty('$schema');
|
||||
});
|
||||
|
||||
test('should handle empty objects', () => {
|
||||
const emptySchema = {};
|
||||
const cleanedSchema = cleanInputSchema(emptySchema);
|
||||
|
||||
expect(cleanedSchema).toEqual({});
|
||||
expect(cleanedSchema).not.toHaveProperty('$schema');
|
||||
});
|
||||
});
|
||||
});
|
||||
13
src/services/dataService.test.ts
Normal file
13
src/services/dataService.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { DataService } from './dataService.js';
|
||||
import { getDataService } from './services.js';
|
||||
import './services.js';
|
||||
|
||||
describe('DataService', () => {
|
||||
test('should get default implementation and call foo method', async () => {
|
||||
const dataService: DataService = await getDataService();
|
||||
const consoleSpy = jest.spyOn(console, 'log');
|
||||
dataService.foo();
|
||||
expect(consoleSpy).toHaveBeenCalledWith('default implementation');
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
31
src/services/dataService.ts
Normal file
31
src/services/dataService.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { IUser, McpSettings } from '../types/index.js';
|
||||
|
||||
export interface DataService {
|
||||
foo(): void;
|
||||
filterData(data: any[], user?: IUser): any[];
|
||||
filterSettings(settings: McpSettings, user?: IUser): McpSettings;
|
||||
mergeSettings(all: McpSettings, newSettings: McpSettings, user?: IUser): McpSettings;
|
||||
getPermissions(user: IUser): string[];
|
||||
}
|
||||
|
||||
export class DataServiceImpl implements DataService {
|
||||
foo() {
|
||||
console.log('default implementation');
|
||||
}
|
||||
|
||||
filterData(data: any[], _user?: IUser): any[] {
|
||||
return data;
|
||||
}
|
||||
|
||||
filterSettings(settings: McpSettings, _user?: IUser): McpSettings {
|
||||
return settings;
|
||||
}
|
||||
|
||||
mergeSettings(all: McpSettings, newSettings: McpSettings, _user?: IUser): McpSettings {
|
||||
return newSettings;
|
||||
}
|
||||
|
||||
getPermissions(_user: IUser): string[] {
|
||||
return ['*'];
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,28 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { IGroup, McpSettings } from '../types/index.js';
|
||||
import { IGroup, IGroupServerConfig } from '../types/index.js';
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
import { notifyToolChanged } from './mcpService.js';
|
||||
import { getDataService } from './services.js';
|
||||
|
||||
// Helper function to normalize group servers configuration
|
||||
const normalizeGroupServers = (servers: string[] | IGroupServerConfig[]): IGroupServerConfig[] => {
|
||||
return servers.map((server) => {
|
||||
if (typeof server === 'string') {
|
||||
// Backward compatibility: string format means all tools
|
||||
return { name: server, tools: 'all' };
|
||||
}
|
||||
// New format: ensure tools defaults to 'all' if not specified
|
||||
return { name: server.name, tools: server.tools || 'all' };
|
||||
});
|
||||
};
|
||||
|
||||
// Get all groups
|
||||
export const getAllGroups = (): IGroup[] => {
|
||||
const settings = loadSettings();
|
||||
return settings.groups || [];
|
||||
const dataService = getDataService();
|
||||
return dataService.filterData
|
||||
? dataService.filterData(settings.groups || [])
|
||||
: settings.groups || [];
|
||||
};
|
||||
|
||||
// Get group by ID or name
|
||||
@@ -28,7 +44,8 @@ export const getGroupByIdOrName = (key: string): IGroup | undefined => {
|
||||
export const createGroup = (
|
||||
name: string,
|
||||
description?: string,
|
||||
servers: string[] = [],
|
||||
servers: string[] | IGroupServerConfig[] = [],
|
||||
owner?: string,
|
||||
): IGroup | null => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
@@ -39,14 +56,18 @@ export const createGroup = (
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter out non-existent servers
|
||||
const validServers = servers.filter((serverName) => settings.mcpServers[serverName]);
|
||||
// Normalize servers configuration and filter out non-existent servers
|
||||
const normalizedServers = normalizeGroupServers(servers);
|
||||
const validServers: IGroupServerConfig[] = normalizedServers.filter(
|
||||
(serverConfig) => settings.mcpServers[serverConfig.name],
|
||||
);
|
||||
|
||||
const newGroup: IGroup = {
|
||||
id: uuidv4(),
|
||||
name,
|
||||
description,
|
||||
servers: validServers,
|
||||
owner: owner || 'admin',
|
||||
};
|
||||
|
||||
// Initialize groups array if it doesn't exist
|
||||
@@ -85,9 +106,12 @@ export const updateGroup = (id: string, data: Partial<IGroup>): IGroup | null =>
|
||||
return null;
|
||||
}
|
||||
|
||||
// If servers array is provided, validate server existence
|
||||
// If servers array is provided, validate server existence and normalize format
|
||||
if (data.servers) {
|
||||
data.servers = data.servers.filter((serverName) => settings.mcpServers[serverName]);
|
||||
const normalizedServers = normalizeGroupServers(data.servers);
|
||||
data.servers = normalizedServers.filter(
|
||||
(serverConfig) => settings.mcpServers[serverConfig.name],
|
||||
);
|
||||
}
|
||||
|
||||
const updatedGroup = {
|
||||
@@ -110,7 +134,11 @@ export const updateGroup = (id: string, data: Partial<IGroup>): IGroup | null =>
|
||||
};
|
||||
|
||||
// Update servers in a group (batch update)
|
||||
export const updateGroupServers = (groupId: string, servers: string[]): IGroup | null => {
|
||||
// Update group servers (maintaining backward compatibility)
|
||||
export const updateGroupServers = (
|
||||
groupId: string,
|
||||
servers: string[] | IGroupServerConfig[],
|
||||
): IGroup | null => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.groups) {
|
||||
@@ -122,8 +150,11 @@ export const updateGroupServers = (groupId: string, servers: string[]): IGroup |
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter out non-existent servers
|
||||
const validServers = servers.filter((serverName) => settings.mcpServers[serverName]);
|
||||
// Normalize and filter out non-existent servers
|
||||
const normalizedServers = normalizeGroupServers(servers);
|
||||
const validServers = normalizedServers.filter(
|
||||
(serverConfig) => settings.mcpServers[serverConfig.name],
|
||||
);
|
||||
|
||||
settings.groups[groupIndex].servers = validServers;
|
||||
|
||||
@@ -180,10 +211,12 @@ export const addServerToGroup = (groupId: string, serverName: string): IGroup |
|
||||
}
|
||||
|
||||
const group = settings.groups[groupIndex];
|
||||
const normalizedServers = normalizeGroupServers(group.servers);
|
||||
|
||||
// Add server to group if not already in it
|
||||
if (!group.servers.includes(serverName)) {
|
||||
group.servers.push(serverName);
|
||||
if (!normalizedServers.some((server) => server.name === serverName)) {
|
||||
normalizedServers.push({ name: serverName, tools: 'all' });
|
||||
group.servers = normalizedServers;
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
@@ -212,7 +245,8 @@ export const removeServerFromGroup = (groupId: string, serverName: string): IGro
|
||||
}
|
||||
|
||||
const group = settings.groups[groupIndex];
|
||||
group.servers = group.servers.filter((name) => name !== serverName);
|
||||
const normalizedServers = normalizeGroupServers(group.servers);
|
||||
group.servers = normalizedServers.filter((server) => server.name !== serverName);
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
@@ -228,5 +262,71 @@ export const removeServerFromGroup = (groupId: string, serverName: string): IGro
|
||||
// Get all servers in a group
|
||||
export const getServersInGroup = (groupId: string): string[] => {
|
||||
const group = getGroupByIdOrName(groupId);
|
||||
return group ? group.servers : [];
|
||||
if (!group) return [];
|
||||
const normalizedServers = normalizeGroupServers(group.servers);
|
||||
return normalizedServers.map((server) => server.name);
|
||||
};
|
||||
|
||||
// Get server configuration from group (including tool selection)
|
||||
export const getServerConfigInGroup = (
|
||||
groupId: string,
|
||||
serverName: string,
|
||||
): IGroupServerConfig | undefined => {
|
||||
const group = getGroupByIdOrName(groupId);
|
||||
if (!group) return undefined;
|
||||
const normalizedServers = normalizeGroupServers(group.servers);
|
||||
return normalizedServers.find((server) => server.name === serverName);
|
||||
};
|
||||
|
||||
// Get all server configurations in a group
|
||||
export const getServerConfigsInGroup = (groupId: string): IGroupServerConfig[] => {
|
||||
const group = getGroupByIdOrName(groupId);
|
||||
if (!group) return [];
|
||||
return normalizeGroupServers(group.servers);
|
||||
};
|
||||
|
||||
// Update tools selection for a specific server in a group
|
||||
export const updateServerToolsInGroup = (
|
||||
groupId: string,
|
||||
serverName: string,
|
||||
tools: string[] | 'all',
|
||||
): IGroup | null => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.groups) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const groupIndex = settings.groups.findIndex((group) => group.id === groupId);
|
||||
if (groupIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify server exists
|
||||
if (!settings.mcpServers[serverName]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const group = settings.groups[groupIndex];
|
||||
const normalizedServers = normalizeGroupServers(group.servers);
|
||||
|
||||
const serverIndex = normalizedServers.findIndex((server) => server.name === serverName);
|
||||
if (serverIndex === -1) {
|
||||
return null; // Server not in group
|
||||
}
|
||||
|
||||
// Update the tools configuration for the server
|
||||
normalizedServers[serverIndex].tools = tools;
|
||||
group.servers = normalizedServers;
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
notifyToolChanged();
|
||||
return group;
|
||||
} catch (error) {
|
||||
console.error(`Failed to update tools for server ${serverName} in group ${groupId}:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// filepath: /Users/sunmeng/code/github/mcphub/src/services/logService.ts
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as os from 'os';
|
||||
import * as process from 'process';
|
||||
@@ -157,7 +156,7 @@ class LogService {
|
||||
|
||||
if (sourcePidMatch) {
|
||||
// If we have a 'source-processId' format in the second bracket
|
||||
const [_, source, extractedProcessId] = sourcePidMatch;
|
||||
const [_, source, _extractedProcessId] = sourcePidMatch;
|
||||
return {
|
||||
text: remainingText.trim(),
|
||||
source: source.trim(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user