mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 10:49:35 -05:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d0d622bd8 | ||
|
|
ab50c7e9eb | ||
|
|
e507bea2e3 | ||
|
|
0f00ad7200 | ||
|
|
b0b0c93337 | ||
|
|
20fd355b87 | ||
|
|
4388084704 | ||
|
|
fe2535461d | ||
|
|
985598e529 | ||
|
|
b2b6d0588b | ||
|
|
64628ee3ed | ||
|
|
66d4142039 | ||
|
|
cf72295f99 | ||
|
|
89f85c73ff | ||
|
|
adabf1d92b |
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: .
|
||||
|
||||
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/
|
||||
@@ -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
|
||||
```
|
||||
|
||||
或使用默认配置运行:
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -50,7 +50,7 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
|
||||
}
|
||||
|
||||
const result = await createGroup(formData.name, formData.description, formData.servers)
|
||||
|
||||
|
||||
if (!result) {
|
||||
setError(t('groups.createError'))
|
||||
setIsSubmitting(false)
|
||||
@@ -69,7 +69,7 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('groups.addNew')}</h2>
|
||||
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
|
||||
{error}
|
||||
@@ -87,7 +87,7 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
|
||||
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"
|
||||
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={t('groups.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
@@ -109,14 +109,14 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
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"
|
||||
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"
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 btn-primary"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? t('common.submitting') : t('common.create')}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ServerForm from './ServerForm'
|
||||
import { getApiUrl } from '../utils/runtime';
|
||||
import { getApiUrl } from '../utils/runtime'
|
||||
import { detectVariables } from '../utils/variableDetection'
|
||||
|
||||
interface AddServerFormProps {
|
||||
onAdd: () => void
|
||||
@@ -11,13 +12,26 @@ 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');
|
||||
@@ -65,11 +79,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 +121,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">
|
||||
|
||||
413
frontend/src/components/DxtUploadForm.tsx
Normal file
413
frontend/src/components/DxtUploadForm.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(getApiUrl('/dxt/upload'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
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);
|
||||
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
|
||||
// First, check if server exists
|
||||
if (!forceOverride) {
|
||||
const checkResponse = await fetch(getApiUrl('/servers'), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (checkResponse.ok) {
|
||||
const checkResult = await checkResponse.json();
|
||||
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
|
||||
const method = forceOverride ? 'PUT' : 'POST';
|
||||
const url = forceOverride ? getApiUrl(`/servers/${encodeURIComponent(serverName)}`) : getApiUrl('/servers');
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: serverName,
|
||||
config: serverConfig,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || `HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -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,7 +55,7 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
|
||||
description: formData.description,
|
||||
servers: formData.servers
|
||||
})
|
||||
|
||||
|
||||
if (!result) {
|
||||
setError(t('groups.updateError'))
|
||||
setIsSubmitting(false)
|
||||
@@ -86,7 +74,7 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('groups.edit')}</h2>
|
||||
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
|
||||
{error}
|
||||
@@ -104,7 +92,7 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
|
||||
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"
|
||||
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={t('groups.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
@@ -126,14 +114,14 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
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"
|
||||
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"
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 btn-primary"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? t('common.submitting') : t('common.save')}
|
||||
|
||||
@@ -68,7 +68,7 @@ const GroupCard = ({
|
||||
const groupServers = servers.filter(server => group.servers.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">
|
||||
@@ -89,7 +89,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
|
||||
@@ -121,7 +121,7 @@ const GroupCard = ({
|
||||
>
|
||||
<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'
|
||||
server.status === 'connecting' ? 'bg-yellow-500' : 'bg-red-500'
|
||||
}`}></span>
|
||||
</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;
|
||||
@@ -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,7 +608,7 @@ 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>
|
||||
@@ -624,9 +624,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) => (
|
||||
@@ -636,7 +636,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>
|
||||
@@ -644,16 +644,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>
|
||||
))}
|
||||
@@ -671,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'}
|
||||
/>
|
||||
@@ -685,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) => (
|
||||
@@ -697,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>
|
||||
@@ -705,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>
|
||||
))}
|
||||
@@ -732,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'}
|
||||
/>
|
||||
@@ -747,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'}
|
||||
/>
|
||||
@@ -761,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) => (
|
||||
@@ -773,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>
|
||||
@@ -781,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>
|
||||
))}
|
||||
@@ -802,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">
|
||||
@@ -814,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">
|
||||
@@ -825,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"
|
||||
@@ -842,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"
|
||||
/>
|
||||
@@ -873,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>
|
||||
|
||||
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,6 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import ThemeSwitch from '@/components/ui/ThemeSwitch';
|
||||
import GitHubIcon from '@/components/icons/GitHubIcon';
|
||||
import SponsorIcon from '@/components/icons/SponsorIcon';
|
||||
@@ -15,13 +14,12 @@ interface HeaderProps {
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { auth } = useAuth();
|
||||
const [sponsorDialogOpen, setSponsorDialogOpen] = useState(false);
|
||||
const [wechatDialogOpen, setWechatDialogOpen] = useState(false);
|
||||
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
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();
|
||||
@@ -9,7 +9,7 @@ const ThemeSwitch: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex bg-gray-200 dark:bg-gray-700 rounded-lg p-1">
|
||||
<div className="flex bg-gray-100 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'
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -73,7 +73,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 +90,7 @@ 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 py-1 z-50">
|
||||
<button
|
||||
onClick={handleSettingsClick}
|
||||
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
|
||||
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,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;
|
||||
}
|
||||
|
||||
@@ -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,23 @@
|
||||
"cancel": "Cancel",
|
||||
"refresh": "Refresh",
|
||||
"create": "Create",
|
||||
"creating": "Creating...",
|
||||
"update": "Update",
|
||||
"updating": "Updating...",
|
||||
"submitting": "Submitting...",
|
||||
"delete": "Delete",
|
||||
"remove": "Remove",
|
||||
"copy": "Copy",
|
||||
"copySuccess": "Copied to clipboard",
|
||||
"copyFailed": "Copy failed",
|
||||
"close": "Close"
|
||||
"close": "Close",
|
||||
"confirm": "Confirm"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"servers": "Servers",
|
||||
"groups": "Groups",
|
||||
"users": "Users",
|
||||
"settings": "Settings",
|
||||
"changePassword": "Change Password",
|
||||
"market": "Market",
|
||||
@@ -200,6 +210,9 @@
|
||||
"groups": {
|
||||
"title": "Group Management"
|
||||
},
|
||||
"users": {
|
||||
"title": "User Management"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"language": "Language",
|
||||
@@ -296,7 +309,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",
|
||||
@@ -366,5 +381,66 @@
|
||||
"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."
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,11 @@
|
||||
"argumentsPlaceholder": "请输入参数",
|
||||
"errorDetails": "错误详情",
|
||||
"viewErrorDetails": "查看错误详情",
|
||||
"confirmVariables": "确认变量配置",
|
||||
"variablesDetected": "检测到配置中包含变量,请确认这些变量是否已正确配置:",
|
||||
"detectedVariables": "检测到的变量",
|
||||
"confirmVariablesMessage": "请确保这些变量在运行环境中已正确定义。是否继续添加服务器?",
|
||||
"confirmAndAdd": "确认并添加",
|
||||
"openapi": {
|
||||
"inputMode": "输入模式",
|
||||
"inputModeUrl": "规范 URL",
|
||||
@@ -169,13 +174,17 @@
|
||||
"cancel": "取消",
|
||||
"refresh": "刷新",
|
||||
"create": "创建",
|
||||
"creating": "创建中...",
|
||||
"update": "更新",
|
||||
"updating": "更新中...",
|
||||
"submitting": "提交中...",
|
||||
"delete": "删除",
|
||||
"remove": "移除",
|
||||
"copy": "复制",
|
||||
"copySuccess": "已复制到剪贴板",
|
||||
"copyFailed": "复制失败",
|
||||
"close": "关闭"
|
||||
"close": "关闭",
|
||||
"confirm": "确认"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "仪表盘",
|
||||
@@ -183,6 +192,7 @@
|
||||
"settings": "设置",
|
||||
"changePassword": "修改密码",
|
||||
"groups": "分组",
|
||||
"users": "用户",
|
||||
"market": "市场",
|
||||
"logs": "日志"
|
||||
},
|
||||
@@ -211,6 +221,9 @@
|
||||
"groups": {
|
||||
"title": "分组管理"
|
||||
},
|
||||
"users": {
|
||||
"title": "用户管理"
|
||||
},
|
||||
"market": {
|
||||
"title": "服务器市场 - (数据来源于 mcpm.sh)"
|
||||
},
|
||||
@@ -297,12 +310,14 @@
|
||||
"tagFilterError": "按标签筛选服务器失败",
|
||||
"noInstallationMethod": "该服务器没有可用的安装方法",
|
||||
"showing": "显示 {{from}}-{{to}}/{{total}} 个服务器",
|
||||
"perPage": "每页显示"
|
||||
"perPage": "每页显示",
|
||||
"confirmVariablesMessage": "请确保这些变量在运行环境中已正确定义。是否继续安装服务器?",
|
||||
"confirmAndInstall": "确认并安装"
|
||||
},
|
||||
"tool": {
|
||||
"run": "运行",
|
||||
"running": "运行中...",
|
||||
"runTool": "运行工具",
|
||||
"runTool": "运行",
|
||||
"cancel": "取消",
|
||||
"noDescription": "无描述信息",
|
||||
"inputSchema": "输入模式:",
|
||||
@@ -368,5 +383,66 @@
|
||||
"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}}' 吗?此操作无法撤消。"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -26,7 +26,7 @@ const LoginPage: React.FC = () => {
|
||||
}
|
||||
|
||||
const success = await login(username, password);
|
||||
|
||||
|
||||
if (success) {
|
||||
navigate('/');
|
||||
} else {
|
||||
@@ -40,18 +40,18 @@ 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="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">
|
||||
<ThemeSwitch />
|
||||
</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 +62,7 @@ const LoginPage: React.FC = () => {
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 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 +78,7 @@ const LoginPage: React.FC = () => {
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 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 +87,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,7 +100,7 @@ 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>
|
||||
@@ -91,7 +108,7 @@ const ServersPage: React.FC = () => {
|
||||
</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 +120,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 +130,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 +155,13 @@ const ServersPage: React.FC = () => {
|
||||
onCancel={() => setEditingServer(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDxtUpload && (
|
||||
<DxtUploadForm
|
||||
onSuccess={handleDxtUploadSuccess}
|
||||
onCancel={() => setShowDxtUpload(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,8 @@ 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();
|
||||
@@ -203,23 +205,23 @@ const SettingsPage: React.FC = () => {
|
||||
<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="bg-white shadow rounded-lg py-4 px-6 mb-6 page-card">
|
||||
<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'
|
||||
className={`px-3 py-1.5 rounded-md transition-all duration-200 text-sm ${currentLanguage.startsWith('en')
|
||||
? 'bg-blue-500 text-white btn-primary'
|
||||
: 'bg-blue-100 text-blue-800 hover:bg-blue-200 btn-secondary'
|
||||
}`}
|
||||
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'
|
||||
className={`px-3 py-1.5 rounded-md transition-all duration-200 text-sm ${currentLanguage.startsWith('zh')
|
||||
? 'bg-blue-500 text-white btn-primary'
|
||||
: 'bg-blue-100 text-blue-800 hover:bg-blue-200 btn-secondary'
|
||||
}`}
|
||||
onClick={() => handleLanguageChange('zh')}
|
||||
>
|
||||
@@ -230,129 +232,131 @@ const SettingsPage: React.FC = () => {
|
||||
</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 +396,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,86 +434,90 @@ const SettingsPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.skipAuth')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.skipAuthDescription')}</p>
|
||||
<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>
|
||||
<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.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;
|
||||
@@ -210,6 +210,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 {
|
||||
|
||||
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);
|
||||
};
|
||||
@@ -12,6 +12,7 @@ module.exports = {
|
||||
'ts-jest',
|
||||
{
|
||||
useESM: true,
|
||||
tsconfig: './tsconfig.test.json',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -37,8 +38,10 @@ module.exports = {
|
||||
},
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!(@modelcontextprotocol|other-esm-packages)/)'],
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
testTimeout: 10000,
|
||||
testTimeout: 30000,
|
||||
verbose: true,
|
||||
};
|
||||
|
||||
10
package.json
10
package.json
@@ -47,7 +47,10 @@
|
||||
"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",
|
||||
@@ -55,6 +58,7 @@
|
||||
"express": "^4.21.2",
|
||||
"express-validator": "^7.2.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.1",
|
||||
"openai": "^4.103.0",
|
||||
"openapi-types": "^12.1.3",
|
||||
"pg": "^8.16.0",
|
||||
@@ -68,6 +72,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",
|
||||
@@ -114,5 +120,5 @@
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.11.0+sha256.a69e9cb077da419d47d18f1dd52e207245b29cac6e076acedbeb8be3b1a67bd7"
|
||||
}
|
||||
"packageManager": "pnpm@10.12.4+sha256.cadfd9e6c9fcc2cb76fe7c0779a5250b632898aea5f53d833a73690c77a778d9"
|
||||
}
|
||||
|
||||
1506
pnpm-lock.yaml
generated
1506
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'
|
||||
@@ -3,6 +3,8 @@ import fs from 'fs';
|
||||
import { McpSettings } 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();
|
||||
|
||||
@@ -15,6 +17,8 @@ const defaultConfig = {
|
||||
mcpHubVersion: getPackageVersion(),
|
||||
};
|
||||
|
||||
const dataService: DataService = getDataService();
|
||||
|
||||
// Settings cache
|
||||
let settingsCache: McpSettings | null = null;
|
||||
|
||||
@@ -22,7 +26,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 +53,18 @@ export const loadSettings = (): McpSettings => {
|
||||
}
|
||||
};
|
||||
|
||||
export const loadSettings = (): McpSettings => {
|
||||
return dataService.filterSettings!(loadOriginalSettings());
|
||||
};
|
||||
|
||||
export const saveSettings = (settings: McpSettings): boolean => {
|
||||
const settingsPath = getSettingsPath();
|
||||
try {
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
|
||||
const mergedSettings = dataService.mergeSettings!(loadOriginalSettings(), settings);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(mergedSettings, null, 2), 'utf8');
|
||||
|
||||
// Update cache after successful save
|
||||
settingsCache = settings;
|
||||
settingsCache = mergedSettings;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
||||
@@ -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';
|
||||
@@ -21,7 +30,7 @@ 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' });
|
||||
return;
|
||||
@@ -29,7 +38,7 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
||||
|
||||
// Verify password
|
||||
const isPasswordValid = await verifyPassword(password, user.password);
|
||||
|
||||
|
||||
if (!isPasswordValid) {
|
||||
res.status(401).json({ success: false, message: 'Invalid credentials' });
|
||||
return;
|
||||
@@ -39,26 +48,22 @@ 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,
|
||||
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' });
|
||||
@@ -79,7 +84,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 +94,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 +121,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 +151,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 +159,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 +167,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 +178,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' });
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
151
src/controllers/dxtController.ts
Normal file
151
src/controllers/dxtController.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
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);
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -75,7 +75,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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
263
src/controllers/userController.ts
Normal file
263
src/controllers/userController.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
import {
|
||||
getAllUsers,
|
||||
getUserByUsername,
|
||||
createNewUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
getUserCount,
|
||||
getAdminCount,
|
||||
} from '../services/userService.js';
|
||||
|
||||
// Admin permission check middleware function
|
||||
const requireAdmin = (req: Request, res: Response): boolean => {
|
||||
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,5 +1,6 @@
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import { auth } from './auth.js';
|
||||
import { userContextMiddleware } from './userContext.js';
|
||||
import { initializeDefaultUser } from '../models/User.js';
|
||||
import config from '../config/index.js';
|
||||
|
||||
@@ -27,7 +28,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 +53,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();
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,14 @@ import {
|
||||
getGroupServers,
|
||||
updateGroupServersBatch,
|
||||
} from '../controllers/groupController.js';
|
||||
import {
|
||||
getUsers,
|
||||
getUser,
|
||||
createUser,
|
||||
updateExistingUser,
|
||||
deleteExistingUser,
|
||||
getUserStats,
|
||||
} from '../controllers/userController.js';
|
||||
import {
|
||||
getAllMarketServers,
|
||||
getMarketServer,
|
||||
@@ -36,6 +44,7 @@ import { login, register, getCurrentUser, changePassword } from '../controllers/
|
||||
import { getAllLogs, clearLogs, streamLogs } from '../controllers/logController.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();
|
||||
@@ -64,9 +73,20 @@ export const initRoutes = (app: express.Application): void => {
|
||||
// New route for batch updating servers in a group
|
||||
router.put('/groups/:id/servers/batch', updateGroupServersBatch);
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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 {
|
||||
@@ -13,10 +12,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;
|
||||
@@ -42,11 +41,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 +148,10 @@ export class AppServer {
|
||||
});
|
||||
}
|
||||
|
||||
connected(): boolean {
|
||||
return connected();
|
||||
}
|
||||
|
||||
getApp(): express.Application {
|
||||
return this.app;
|
||||
}
|
||||
@@ -119,7 +163,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 +203,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
|
||||
|
||||
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[]): any[];
|
||||
filterSettings(settings: McpSettings): McpSettings;
|
||||
mergeSettings(all: McpSettings, newSettings: McpSettings): McpSettings;
|
||||
getPermissions(user: IUser): string[];
|
||||
}
|
||||
|
||||
export class DataServiceImpl implements DataService {
|
||||
foo() {
|
||||
console.log('default implementation');
|
||||
}
|
||||
|
||||
filterData(data: any[]): any[] {
|
||||
return data;
|
||||
}
|
||||
|
||||
filterSettings(settings: McpSettings): McpSettings {
|
||||
return settings;
|
||||
}
|
||||
|
||||
mergeSettings(all: McpSettings, newSettings: McpSettings): McpSettings {
|
||||
return newSettings;
|
||||
}
|
||||
|
||||
getPermissions(_user: IUser): string[] {
|
||||
return ['*'];
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,15 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { IGroup } from '../types/index.js';
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
import { notifyToolChanged } from './mcpService.js';
|
||||
import { getDataService } from './services.js';
|
||||
|
||||
// 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
|
||||
@@ -29,6 +33,7 @@ export const createGroup = (
|
||||
name: string,
|
||||
description?: string,
|
||||
servers: string[] = [],
|
||||
owner?: string,
|
||||
): IGroup | null => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
@@ -47,6 +52,7 @@ export const createGroup = (
|
||||
name,
|
||||
description,
|
||||
servers: validServers,
|
||||
owner: owner || 'admin',
|
||||
};
|
||||
|
||||
// Initialize groups array if it doesn't exist
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getGroup } from './sseService.js';
|
||||
import { getServersInGroup } from './groupService.js';
|
||||
import { saveToolsAsVectorEmbeddings, searchToolsByVector } from './vectorSearchService.js';
|
||||
import { OpenAPIClient } from '../clients/openapi.js';
|
||||
import { getDataService } from './services.js';
|
||||
|
||||
const servers: { [sessionId: string]: Server } = {};
|
||||
|
||||
@@ -101,6 +102,214 @@ export const syncToolEmbedding = async (serverName: string, toolName: string) =>
|
||||
// Store all server information
|
||||
let serverInfos: ServerInfo[] = [];
|
||||
|
||||
// Returns true if all servers are connected
|
||||
export const connected = (): boolean => {
|
||||
return serverInfos.every((serverInfo) => serverInfo.status === 'connected');
|
||||
};
|
||||
|
||||
// Global cleanup function to close all connections
|
||||
export const cleanupAllServers = (): void => {
|
||||
for (const serverInfo of serverInfos) {
|
||||
try {
|
||||
if (serverInfo.client) {
|
||||
serverInfo.client.close();
|
||||
}
|
||||
if (serverInfo.transport) {
|
||||
serverInfo.transport.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Error closing server ${serverInfo.name}:`, error);
|
||||
}
|
||||
}
|
||||
serverInfos = [];
|
||||
|
||||
// Clear session servers as well
|
||||
Object.keys(servers).forEach((sessionId) => {
|
||||
delete servers[sessionId];
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to create transport based on server configuration
|
||||
const createTransportFromConfig = (name: string, conf: ServerConfig): any => {
|
||||
let transport;
|
||||
|
||||
if (conf.type === 'streamable-http') {
|
||||
const options: any = {};
|
||||
if (conf.headers && Object.keys(conf.headers).length > 0) {
|
||||
options.requestInit = {
|
||||
headers: conf.headers,
|
||||
};
|
||||
}
|
||||
transport = new StreamableHTTPClientTransport(new URL(conf.url || ''), options);
|
||||
} else if (conf.url) {
|
||||
// SSE transport
|
||||
const options: any = {};
|
||||
if (conf.headers && Object.keys(conf.headers).length > 0) {
|
||||
options.eventSourceInit = {
|
||||
headers: conf.headers,
|
||||
};
|
||||
options.requestInit = {
|
||||
headers: conf.headers,
|
||||
};
|
||||
}
|
||||
transport = new SSEClientTransport(new URL(conf.url), options);
|
||||
} else if (conf.command && conf.args) {
|
||||
// Stdio transport
|
||||
const env: Record<string, string> = {
|
||||
...(process.env as Record<string, string>),
|
||||
...replaceEnvVars(conf.env || {}),
|
||||
};
|
||||
env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
|
||||
|
||||
const settings = loadSettings();
|
||||
// Add UV_DEFAULT_INDEX and npm_config_registry if needed
|
||||
if (
|
||||
settings.systemConfig?.install?.pythonIndexUrl &&
|
||||
(conf.command === 'uvx' || conf.command === 'uv' || conf.command === 'python')
|
||||
) {
|
||||
env['UV_DEFAULT_INDEX'] = settings.systemConfig.install.pythonIndexUrl;
|
||||
}
|
||||
|
||||
if (
|
||||
settings.systemConfig?.install?.npmRegistry &&
|
||||
(conf.command === 'npm' ||
|
||||
conf.command === 'npx' ||
|
||||
conf.command === 'pnpm' ||
|
||||
conf.command === 'yarn' ||
|
||||
conf.command === 'node')
|
||||
) {
|
||||
env['npm_config_registry'] = settings.systemConfig.install.npmRegistry;
|
||||
}
|
||||
|
||||
transport = new StdioClientTransport({
|
||||
command: conf.command,
|
||||
args: conf.args,
|
||||
env: env,
|
||||
stderr: 'pipe',
|
||||
});
|
||||
transport.stderr?.on('data', (data) => {
|
||||
console.log(`[${name}] [child] ${data}`);
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Unable to create transport for server: ${name}`);
|
||||
}
|
||||
|
||||
return transport;
|
||||
};
|
||||
|
||||
// Helper function to handle client.callTool with reconnection logic
|
||||
const callToolWithReconnect = async (
|
||||
serverInfo: ServerInfo,
|
||||
toolParams: any,
|
||||
options?: any,
|
||||
maxRetries: number = 1,
|
||||
): Promise<any> => {
|
||||
if (!serverInfo.client) {
|
||||
throw new Error(`Client not found for server: ${serverInfo.name}`);
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const result = await serverInfo.client.callTool(toolParams, undefined, options || {});
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
// Check if error message starts with "Error POSTing to endpoint (HTTP 40"
|
||||
const isHttp40xError = error?.message?.startsWith?.('Error POSTing to endpoint (HTTP 40');
|
||||
// Only retry for StreamableHTTPClientTransport
|
||||
const isStreamableHttp = serverInfo.transport instanceof StreamableHTTPClientTransport;
|
||||
|
||||
if (isHttp40xError && attempt < maxRetries && serverInfo.transport && isStreamableHttp) {
|
||||
console.warn(
|
||||
`HTTP 40x error detected for StreamableHTTP server ${serverInfo.name}, attempting reconnection (attempt ${attempt + 1}/${maxRetries + 1})`,
|
||||
);
|
||||
|
||||
try {
|
||||
// Close existing connection
|
||||
if (serverInfo.keepAliveIntervalId) {
|
||||
clearInterval(serverInfo.keepAliveIntervalId);
|
||||
serverInfo.keepAliveIntervalId = undefined;
|
||||
}
|
||||
|
||||
serverInfo.client.close();
|
||||
serverInfo.transport.close();
|
||||
|
||||
// Get server configuration to recreate transport
|
||||
const settings = loadSettings();
|
||||
const conf = settings.mcpServers[serverInfo.name];
|
||||
if (!conf) {
|
||||
throw new Error(`Server configuration not found for: ${serverInfo.name}`);
|
||||
}
|
||||
|
||||
// Recreate transport using helper function
|
||||
const newTransport = createTransportFromConfig(serverInfo.name, conf);
|
||||
|
||||
// Create new client
|
||||
const client = new Client(
|
||||
{
|
||||
name: `mcp-client-${serverInfo.name}`,
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
prompts: {},
|
||||
resources: {},
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Reconnect with new transport
|
||||
await client.connect(newTransport, serverInfo.options || {});
|
||||
|
||||
// Update server info with new client and transport
|
||||
serverInfo.client = client;
|
||||
serverInfo.transport = newTransport;
|
||||
serverInfo.status = 'connected';
|
||||
|
||||
// Reload tools list after reconnection
|
||||
try {
|
||||
const tools = await client.listTools({}, serverInfo.options || {});
|
||||
serverInfo.tools = tools.tools.map((tool) => ({
|
||||
name: `${serverInfo.name}-${tool.name}`,
|
||||
description: tool.description || '',
|
||||
inputSchema: tool.inputSchema || {},
|
||||
}));
|
||||
|
||||
// Save tools as vector embeddings for search
|
||||
saveToolsAsVectorEmbeddings(serverInfo.name, serverInfo.tools);
|
||||
} catch (listToolsError) {
|
||||
console.warn(
|
||||
`Failed to reload tools after reconnection for server ${serverInfo.name}:`,
|
||||
listToolsError,
|
||||
);
|
||||
// Continue anyway, as the connection might still work for the current tool
|
||||
}
|
||||
|
||||
console.log(`Successfully reconnected to server: ${serverInfo.name}`);
|
||||
|
||||
// Continue to next attempt
|
||||
continue;
|
||||
} catch (reconnectError) {
|
||||
console.error(`Failed to reconnect to server ${serverInfo.name}:`, reconnectError);
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = `Failed to reconnect: ${reconnectError}`;
|
||||
|
||||
// If this was the last attempt, throw the original error
|
||||
if (attempt === maxRetries) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Not an HTTP 40x error or no more retries, throw the original error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This should not be reached, but just in case
|
||||
throw new Error('Unexpected error in callToolWithReconnect');
|
||||
};
|
||||
|
||||
// Initialize MCP server clients
|
||||
export const initializeClientsFromSettings = async (isInit: boolean): Promise<ServerInfo[]> => {
|
||||
const settings = loadSettings();
|
||||
@@ -113,6 +322,7 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
|
||||
console.log(`Skipping disabled server: ${name}`);
|
||||
serverInfos.push({
|
||||
name,
|
||||
owner: conf.owner,
|
||||
status: 'disconnected',
|
||||
error: null,
|
||||
tools: [],
|
||||
@@ -146,6 +356,7 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
|
||||
);
|
||||
serverInfos.push({
|
||||
name,
|
||||
owner: conf.owner,
|
||||
status: 'disconnected',
|
||||
error: 'Missing OpenAPI specification URL or schema',
|
||||
tools: [],
|
||||
@@ -154,21 +365,22 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create server info first and keep reference to it
|
||||
const serverInfo: ServerInfo = {
|
||||
name,
|
||||
owner: conf.owner,
|
||||
status: 'connecting',
|
||||
error: null,
|
||||
tools: [],
|
||||
createTime: Date.now(),
|
||||
enabled: conf.enabled === undefined ? true : conf.enabled,
|
||||
};
|
||||
serverInfos.push(serverInfo);
|
||||
|
||||
try {
|
||||
// Create OpenAPI client instance
|
||||
openApiClient = new OpenAPIClient(conf);
|
||||
|
||||
// Add server with connecting status first
|
||||
const serverInfo: ServerInfo = {
|
||||
name,
|
||||
status: 'connecting',
|
||||
error: null,
|
||||
tools: [],
|
||||
createTime: Date.now(),
|
||||
enabled: conf.enabled === undefined ? true : conf.enabled,
|
||||
};
|
||||
serverInfos.push(serverInfo);
|
||||
|
||||
console.log(`Initializing OpenAPI server: ${name}...`);
|
||||
|
||||
// Perform async initialization
|
||||
@@ -197,91 +409,13 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
|
||||
} catch (error) {
|
||||
console.error(`Failed to initialize OpenAPI server ${name}:`, error);
|
||||
|
||||
// Find and update the server info if it was already added
|
||||
const existingServerIndex = serverInfos.findIndex((s) => s.name === name);
|
||||
if (existingServerIndex !== -1) {
|
||||
serverInfos[existingServerIndex].status = 'disconnected';
|
||||
serverInfos[existingServerIndex].error = `Failed to initialize OpenAPI server: ${error}`;
|
||||
} else {
|
||||
// Add new server info with error status
|
||||
serverInfos.push({
|
||||
name,
|
||||
status: 'disconnected',
|
||||
error: `Failed to initialize OpenAPI server: ${error}`,
|
||||
tools: [],
|
||||
createTime: Date.now(),
|
||||
});
|
||||
}
|
||||
// Update the already pushed server info with error status
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = `Failed to initialize OpenAPI server: ${error}`;
|
||||
continue;
|
||||
}
|
||||
} else if (conf.type === 'streamable-http') {
|
||||
const options: any = {};
|
||||
if (conf.headers && Object.keys(conf.headers).length > 0) {
|
||||
options.requestInit = {
|
||||
headers: conf.headers,
|
||||
};
|
||||
}
|
||||
transport = new StreamableHTTPClientTransport(new URL(conf.url || ''), options);
|
||||
} else if (conf.url) {
|
||||
// Default to SSE only when 'conf.type' is not specified and 'conf.url' is available
|
||||
const options: any = {};
|
||||
if (conf.headers && Object.keys(conf.headers).length > 0) {
|
||||
options.eventSourceInit = {
|
||||
headers: conf.headers,
|
||||
};
|
||||
options.requestInit = {
|
||||
headers: conf.headers,
|
||||
};
|
||||
}
|
||||
transport = new SSEClientTransport(new URL(conf.url), options);
|
||||
} else if (conf.command && conf.args) {
|
||||
// If type is stdio or if command and args are provided without type
|
||||
const env: Record<string, string> = {
|
||||
...(process.env as Record<string, string>), // Inherit all environment variables from parent process
|
||||
...replaceEnvVars(conf.env || {}), // Override with configured env vars
|
||||
};
|
||||
env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
|
||||
|
||||
// Add UV_DEFAULT_INDEX from settings if available (for Python packages)
|
||||
const settings = loadSettings(); // Add UV_DEFAULT_INDEX from settings if available (for Python packages)
|
||||
if (
|
||||
settings.systemConfig?.install?.pythonIndexUrl &&
|
||||
(conf.command === 'uvx' || conf.command === 'uv' || conf.command === 'python')
|
||||
) {
|
||||
env['UV_DEFAULT_INDEX'] = settings.systemConfig.install.pythonIndexUrl;
|
||||
}
|
||||
|
||||
// Add npm_config_registry from settings if available (for NPM packages)
|
||||
if (
|
||||
settings.systemConfig?.install?.npmRegistry &&
|
||||
(conf.command === 'npm' ||
|
||||
conf.command === 'npx' ||
|
||||
conf.command === 'pnpm' ||
|
||||
conf.command === 'yarn' ||
|
||||
conf.command === 'node')
|
||||
) {
|
||||
env['npm_config_registry'] = settings.systemConfig.install.npmRegistry;
|
||||
}
|
||||
|
||||
transport = new StdioClientTransport({
|
||||
command: conf.command,
|
||||
args: conf.args,
|
||||
env: env,
|
||||
stderr: 'pipe',
|
||||
});
|
||||
transport.stderr?.on('data', (data) => {
|
||||
console.log(`[${name}] [child] ${data}`);
|
||||
});
|
||||
} else {
|
||||
console.warn(`Skipping server '${name}': missing required configuration`);
|
||||
serverInfos.push({
|
||||
name,
|
||||
status: 'disconnected',
|
||||
error: 'Missing required configuration',
|
||||
tools: [],
|
||||
createTime: Date.now(),
|
||||
});
|
||||
continue;
|
||||
transport = createTransportFromConfig(name, conf);
|
||||
}
|
||||
|
||||
const client = new Client(
|
||||
@@ -312,6 +446,20 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
|
||||
maxTotalTimeout: serverRequestOptions.maxTotalTimeout,
|
||||
};
|
||||
|
||||
// Create server info first and keep reference to it
|
||||
const serverInfo: ServerInfo = {
|
||||
name,
|
||||
owner: conf.owner,
|
||||
status: 'connecting',
|
||||
error: null,
|
||||
tools: [],
|
||||
client,
|
||||
transport,
|
||||
options: requestOptions,
|
||||
createTime: Date.now(),
|
||||
};
|
||||
serverInfos.push(serverInfo);
|
||||
|
||||
client
|
||||
.connect(transport, initRequestOptions || requestOptions)
|
||||
.then(() => {
|
||||
@@ -320,11 +468,6 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
|
||||
.listTools({}, initRequestOptions || requestOptions)
|
||||
.then((tools) => {
|
||||
console.log(`Successfully listed ${tools.tools.length} tools for server: ${name}`);
|
||||
const serverInfo = getServerByName(name);
|
||||
if (!serverInfo) {
|
||||
console.warn(`Server info not found for server: ${name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
serverInfo.tools = tools.tools.map((tool) => ({
|
||||
name: `${name}-${tool.name}`,
|
||||
@@ -344,33 +487,17 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
|
||||
console.error(
|
||||
`Failed to list tools for server ${name} by error: ${error} with stack: ${error.stack}`,
|
||||
);
|
||||
const serverInfo = getServerByName(name);
|
||||
if (serverInfo) {
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = `Failed to list tools: ${error.stack} `;
|
||||
}
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = `Failed to list tools: ${error.stack} `;
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to connect client for server ${name} by error: ${error} with stack: ${error.stack}`,
|
||||
);
|
||||
const serverInfo = getServerByName(name);
|
||||
if (serverInfo) {
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = `Failed to connect: ${error.stack} `;
|
||||
}
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = `Failed to connect: ${error.stack} `;
|
||||
});
|
||||
serverInfos.push({
|
||||
name,
|
||||
status: 'connecting',
|
||||
error: null,
|
||||
tools: [],
|
||||
client,
|
||||
transport,
|
||||
options: requestOptions,
|
||||
createTime: Date.now(),
|
||||
});
|
||||
console.log(`Initialized client for server: ${name}`);
|
||||
}
|
||||
|
||||
@@ -385,7 +512,11 @@ export const registerAllTools = async (isInit: boolean): Promise<void> => {
|
||||
// Get all server information
|
||||
export const getServersInfo = (): Omit<ServerInfo, 'client' | 'transport'>[] => {
|
||||
const settings = loadSettings();
|
||||
const infos = serverInfos.map(({ name, status, tools, createTime, error }) => {
|
||||
const dataService = getDataService();
|
||||
const filterServerInfos: ServerInfo[] = dataService.filterData
|
||||
? dataService.filterData(serverInfos)
|
||||
: serverInfos;
|
||||
const infos = filterServerInfos.map(({ name, status, tools, createTime, error }) => {
|
||||
const serverConfig = settings.mcpServers[name];
|
||||
const enabled = serverConfig ? serverConfig.enabled !== false : true;
|
||||
|
||||
@@ -513,6 +644,42 @@ export const updateMcpServer = async (
|
||||
}
|
||||
};
|
||||
|
||||
// Add or update server (supports overriding existing servers for DXT)
|
||||
export const addOrUpdateServer = async (
|
||||
name: string,
|
||||
config: ServerConfig,
|
||||
allowOverride: boolean = false,
|
||||
): Promise<{ success: boolean; message?: string }> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
const exists = !!settings.mcpServers[name];
|
||||
|
||||
if (exists && !allowOverride) {
|
||||
return { success: false, message: 'Server name already exists' };
|
||||
}
|
||||
|
||||
// If overriding and this is a DXT server (stdio type with file paths),
|
||||
// we might want to clean up old files in the future
|
||||
if (exists && config.type === 'stdio') {
|
||||
// Close existing server connections
|
||||
closeServer(name);
|
||||
// Remove from server infos
|
||||
serverInfos = serverInfos.filter((serverInfo) => serverInfo.name !== name);
|
||||
}
|
||||
|
||||
settings.mcpServers[name] = config;
|
||||
if (!saveSettings(settings)) {
|
||||
return { success: false, message: 'Failed to save settings' };
|
||||
}
|
||||
|
||||
const action = exists ? 'updated' : 'added';
|
||||
return { success: true, message: `Server ${action} successfully` };
|
||||
} catch (error) {
|
||||
console.error(`Failed to add/update server: ${name}`, error);
|
||||
return { success: false, message: 'Failed to add/update server' };
|
||||
}
|
||||
};
|
||||
|
||||
// Close server client and transport
|
||||
function closeServer(name: string) {
|
||||
const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
|
||||
@@ -643,13 +810,15 @@ Available servers: ${serversList}`;
|
||||
};
|
||||
}
|
||||
|
||||
const allServerInfos = serverInfos.filter((serverInfo) => {
|
||||
if (serverInfo.enabled === false) return false;
|
||||
if (!group) return true;
|
||||
const serversInGroup = getServersInGroup(group);
|
||||
if (!serversInGroup || serversInGroup.length === 0) return serverInfo.name === group;
|
||||
return serversInGroup.includes(serverInfo.name);
|
||||
});
|
||||
const allServerInfos = getDataService()
|
||||
.filterData(serverInfos)
|
||||
.filter((serverInfo) => {
|
||||
if (serverInfo.enabled === false) return false;
|
||||
if (!group) return true;
|
||||
const serversInGroup = getServersInGroup(group);
|
||||
if (!serversInGroup || serversInGroup.length === 0) return serverInfo.name === group;
|
||||
return serversInGroup.includes(serverInfo.name);
|
||||
});
|
||||
|
||||
const allTools = [];
|
||||
for (const serverInfo of allServerInfos) {
|
||||
@@ -866,12 +1035,12 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
toolName = toolName.startsWith(`${targetServerInfo.name}-`)
|
||||
? toolName.replace(`${targetServerInfo.name}-`, '')
|
||||
: toolName;
|
||||
const result = await client.callTool(
|
||||
const result = await callToolWithReconnect(
|
||||
targetServerInfo,
|
||||
{
|
||||
name: toolName,
|
||||
arguments: finalArgs,
|
||||
},
|
||||
undefined,
|
||||
targetServerInfo.options || {},
|
||||
);
|
||||
|
||||
@@ -921,7 +1090,11 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
request.params.name = request.params.name.startsWith(`${serverInfo.name}-`)
|
||||
? request.params.name.replace(`${serverInfo.name}-`, '')
|
||||
: request.params.name;
|
||||
const result = await client.callTool(request.params, undefined, serverInfo.options || {});
|
||||
const result = await callToolWithReconnect(
|
||||
serverInfo,
|
||||
request.params,
|
||||
serverInfo.options || {},
|
||||
);
|
||||
console.log(`Tool call result: ${JSON.stringify(result)}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
|
||||
45
src/services/registry.ts
Normal file
45
src/services/registry.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { createRequire } from 'module';
|
||||
import { join } from 'path';
|
||||
|
||||
type Class<T> = new (...args: any[]) => T;
|
||||
|
||||
interface Service<T> {
|
||||
defaultImpl: Class<T>;
|
||||
override?: Class<T>;
|
||||
}
|
||||
|
||||
const registry = new Map<string, Service<any>>();
|
||||
const instances = new Map<string, unknown>();
|
||||
|
||||
export function registerService<T>(key: string, entry: Service<T>) {
|
||||
// Try to load override immediately during registration
|
||||
const overridePath = join(process.cwd(), 'src', 'services', key + 'x.ts');
|
||||
try {
|
||||
const require = createRequire(process.cwd());
|
||||
const mod = require(overridePath);
|
||||
const override = mod[key.charAt(0).toUpperCase() + key.slice(1) + 'x'];
|
||||
if (typeof override === 'function') {
|
||||
entry.override = override;
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently ignore if override doesn't exist
|
||||
}
|
||||
|
||||
registry.set(key, entry);
|
||||
}
|
||||
|
||||
export function getService<T>(key: string): T {
|
||||
if (instances.has(key)) {
|
||||
return instances.get(key) as T;
|
||||
}
|
||||
|
||||
const entry = registry.get(key);
|
||||
if (!entry) throw new Error(`Service not registered for key: ${key.toString()}`);
|
||||
|
||||
// Use override if available, otherwise use default
|
||||
const Impl = entry.override || entry.defaultImpl;
|
||||
|
||||
const instance = new Impl();
|
||||
instances.set(key, instance);
|
||||
return instance;
|
||||
}
|
||||
10
src/services/services.ts
Normal file
10
src/services/services.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { registerService, getService } from './registry.js';
|
||||
import { DataService, DataServiceImpl } from './dataService.js';
|
||||
|
||||
registerService('dataService', {
|
||||
defaultImpl: DataServiceImpl,
|
||||
});
|
||||
|
||||
export function getDataService(): DataService {
|
||||
return getService<DataService>('dataService');
|
||||
}
|
||||
482
src/services/sseService.test.ts
Normal file
482
src/services/sseService.test.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { jest } from '@jest/globals';
|
||||
import {
|
||||
handleSseConnection,
|
||||
handleSseMessage,
|
||||
handleMcpPostRequest,
|
||||
handleMcpOtherRequest,
|
||||
getGroup,
|
||||
getConnectionCount,
|
||||
} from './sseService.js';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('./mcpService.js', () => ({
|
||||
deleteMcpServer: jest.fn(),
|
||||
getMcpServer: jest.fn(() => ({
|
||||
connect: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../config/index.js', () => {
|
||||
const config = {
|
||||
basePath: '/test',
|
||||
};
|
||||
return {
|
||||
__esModule: true,
|
||||
default: config,
|
||||
loadSettings: jest.fn(() => ({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('./userContextService.js', () => ({
|
||||
UserContextService: {
|
||||
getInstance: jest.fn(() => ({
|
||||
getCurrentUser: jest.fn(() => ({ username: 'testuser' })),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@modelcontextprotocol/sdk/server/sse.js', () => ({
|
||||
SSEServerTransport: jest.fn().mockImplementation((_path, _res) => ({
|
||||
sessionId: 'test-session-id',
|
||||
connect: jest.fn(),
|
||||
handlePostMessage: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({
|
||||
StreamableHTTPServerTransport: jest.fn().mockImplementation(() => ({
|
||||
sessionId: 'test-session-id',
|
||||
connect: jest.fn(),
|
||||
handleRequest: jest.fn(),
|
||||
onclose: null,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('@modelcontextprotocol/sdk/types.js', () => ({
|
||||
isInitializeRequest: jest.fn(() => true),
|
||||
}));
|
||||
|
||||
// Import mocked modules
|
||||
import { getMcpServer } from './mcpService.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { UserContextService } from './userContextService.js';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
|
||||
// Mock Express Request and Response
|
||||
const createMockRequest = (overrides: Partial<Request> = {}): Request =>
|
||||
({
|
||||
headers: {},
|
||||
params: {},
|
||||
query: {},
|
||||
body: {},
|
||||
...overrides,
|
||||
}) as Request;
|
||||
|
||||
const createMockResponse = (): Response => {
|
||||
const res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
on: jest.fn(),
|
||||
} as unknown as Response;
|
||||
return res;
|
||||
};
|
||||
|
||||
describe('sseService', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Reset settings cache
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('bearer authentication', () => {
|
||||
it('should pass when bearer auth is disabled', async () => {
|
||||
const req = createMockRequest({
|
||||
params: { group: 'test-group' },
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleSseConnection(req, res);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalledWith(401);
|
||||
expect(SSEServerTransport).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 401 when bearer auth is enabled but no authorization header', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const req = createMockRequest();
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleSseConnection(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.send).toHaveBeenCalledWith('Bearer authentication required or invalid token');
|
||||
});
|
||||
|
||||
it('should return 401 when bearer auth is enabled with invalid token', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const req = createMockRequest({
|
||||
headers: { authorization: 'Bearer invalid-token' },
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleSseConnection(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.send).toHaveBeenCalledWith('Bearer authentication required or invalid token');
|
||||
});
|
||||
|
||||
it('should pass when bearer auth is enabled with valid token', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const req = createMockRequest({
|
||||
headers: { authorization: 'Bearer test-key' },
|
||||
params: { group: 'test-group' },
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleSseConnection(req, res);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalledWith(401);
|
||||
expect(SSEServerTransport).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGroup', () => {
|
||||
it('should return empty string for non-existent session', () => {
|
||||
const result = getGroup('non-existent-session');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should return group for existing session', () => {
|
||||
// This would need to be tested after a connection is established
|
||||
// For now, testing the default behavior
|
||||
const result = getGroup('test-session');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConnectionCount', () => {
|
||||
it('should return current number of connections', () => {
|
||||
const count = getConnectionCount();
|
||||
// The count may be > 0 due to previous tests since transports is module-level
|
||||
expect(typeof count).toBe('number');
|
||||
expect(count).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleSseConnection', () => {
|
||||
it('should reject global routes when disabled', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: false,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const req = createMockRequest(); // No group in params
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleSseConnection(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
'Global routes are disabled. Please specify a group ID.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should create SSE transport for valid request', async () => {
|
||||
const req = createMockRequest({
|
||||
params: { group: 'test-group' },
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleSseConnection(req, res);
|
||||
|
||||
expect(SSEServerTransport).toHaveBeenCalledWith('/test/testuser/messages', res);
|
||||
expect(getMcpServer).toHaveBeenCalledWith('test-session-id', 'test-group');
|
||||
});
|
||||
|
||||
it('should handle user context correctly', async () => {
|
||||
const mockGetCurrentUser = jest.fn(() => ({ username: 'testuser2' }));
|
||||
(UserContextService.getInstance as jest.MockedFunction<any>).mockReturnValue({
|
||||
getCurrentUser: mockGetCurrentUser,
|
||||
});
|
||||
|
||||
const req = createMockRequest({
|
||||
params: { group: 'test-group' },
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleSseConnection(req, res);
|
||||
|
||||
expect(mockGetCurrentUser).toHaveBeenCalled();
|
||||
expect(SSEServerTransport).toHaveBeenCalledWith('/test/testuser2/messages', res);
|
||||
});
|
||||
|
||||
it('should handle anonymous user correctly', async () => {
|
||||
const mockGetCurrentUser = jest.fn(() => null);
|
||||
(UserContextService.getInstance as jest.MockedFunction<any>).mockReturnValue({
|
||||
getCurrentUser: mockGetCurrentUser,
|
||||
});
|
||||
|
||||
const req = createMockRequest({
|
||||
params: { group: 'test-group' },
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleSseConnection(req, res);
|
||||
|
||||
expect(mockGetCurrentUser).toHaveBeenCalled();
|
||||
expect(SSEServerTransport).toHaveBeenCalledWith('/test/messages', res);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleSseMessage', () => {
|
||||
it('should return 400 when sessionId is missing', async () => {
|
||||
const req = createMockRequest({
|
||||
query: {}, // No sessionId
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleSseMessage(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.send).toHaveBeenCalledWith('Missing sessionId parameter');
|
||||
});
|
||||
|
||||
it('should return 404 when transport not found', async () => {
|
||||
const req = createMockRequest({
|
||||
query: { sessionId: 'non-existent-session' },
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleSseMessage(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.send).toHaveBeenCalledWith('No transport found for sessionId');
|
||||
});
|
||||
|
||||
it('should return 401 when bearer auth fails', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const req = createMockRequest({
|
||||
query: { sessionId: 'test-session' },
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleSseMessage(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.send).toHaveBeenCalledWith('Bearer authentication required or invalid token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleMcpPostRequest', () => {
|
||||
it('should reject global routes when disabled', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: false,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const req = createMockRequest({
|
||||
params: {}, // No group
|
||||
body: { method: 'initialize' },
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleMcpPostRequest(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
'Global routes are disabled. Please specify a group ID.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should create new transport for initialize request without sessionId', async () => {
|
||||
const req = createMockRequest({
|
||||
params: { group: 'test-group' },
|
||||
body: { method: 'initialize' },
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleMcpPostRequest(req, res);
|
||||
|
||||
expect(StreamableHTTPServerTransport).toHaveBeenCalled();
|
||||
expect(getMcpServer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error for invalid session', async () => {
|
||||
const req = createMockRequest({
|
||||
params: { group: 'test-group' },
|
||||
headers: { 'mcp-session-id': 'invalid-session' },
|
||||
body: { method: 'someMethod' },
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleMcpPostRequest(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32000,
|
||||
message: 'Bad Request: No valid session ID provided',
|
||||
},
|
||||
id: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 401 when bearer auth fails', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const req = createMockRequest({
|
||||
params: { group: 'test-group' },
|
||||
body: { method: 'initialize' },
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleMcpPostRequest(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.send).toHaveBeenCalledWith('Bearer authentication required or invalid token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleMcpOtherRequest', () => {
|
||||
it('should return 400 for missing session ID', async () => {
|
||||
const req = createMockRequest({
|
||||
headers: {}, // No mcp-session-id
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleMcpOtherRequest(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.send).toHaveBeenCalledWith('Invalid or missing session ID');
|
||||
});
|
||||
|
||||
it('should return 400 for invalid session ID', async () => {
|
||||
const req = createMockRequest({
|
||||
headers: { 'mcp-session-id': 'invalid-session' },
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleMcpOtherRequest(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.send).toHaveBeenCalledWith('Invalid or missing session ID');
|
||||
});
|
||||
|
||||
it('should return 401 when bearer auth fails', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const req = createMockRequest({
|
||||
headers: { 'mcp-session-id': 'test-session' },
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleMcpOtherRequest(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.send).toHaveBeenCalledWith('Bearer authentication required or invalid token');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { deleteMcpServer, getMcpServer } from './mcpService.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import config from '../config/index.js';
|
||||
import { UserContextService } from './userContextService.js';
|
||||
|
||||
const transports: { [sessionId: string]: { transport: Transport; group: string } } = {};
|
||||
|
||||
@@ -38,8 +39,14 @@ const validateBearerAuth = (req: Request): boolean => {
|
||||
};
|
||||
|
||||
export const handleSseConnection = async (req: Request, res: Response): Promise<void> => {
|
||||
// Check bearer auth
|
||||
// User context is now set by sseUserContextMiddleware
|
||||
const userContextService = UserContextService.getInstance();
|
||||
const currentUser = userContextService.getCurrentUser();
|
||||
const username = currentUser?.username;
|
||||
|
||||
// Check bearer auth using filtered settings
|
||||
if (!validateBearerAuth(req)) {
|
||||
console.warn('Bearer authentication failed or not provided');
|
||||
res.status(401).send('Bearer authentication required or invalid token');
|
||||
return;
|
||||
}
|
||||
@@ -55,11 +62,25 @@ export const handleSseConnection = async (req: Request, res: Response): Promise<
|
||||
|
||||
// Check if this is a global route (no group) and if it's allowed
|
||||
if (!group && !routingConfig.enableGlobalRoute) {
|
||||
console.warn('Global routes are disabled, group ID is required');
|
||||
res.status(403).send('Global routes are disabled. Please specify a group ID.');
|
||||
return;
|
||||
}
|
||||
|
||||
const transport = new SSEServerTransport(`${config.basePath}/messages`, res);
|
||||
// For user-scoped routes, validate that the user has access to the requested group
|
||||
if (username && group) {
|
||||
// Additional validation can be added here to check if user has access to the group
|
||||
console.log(`User ${username} accessing group: ${group}`);
|
||||
}
|
||||
|
||||
// Construct the appropriate messages path based on user context
|
||||
const messagesPath = username
|
||||
? `${config.basePath}/${username}/messages`
|
||||
: `${config.basePath}/messages`;
|
||||
|
||||
console.log(`Creating SSE transport with messages path: ${messagesPath}`);
|
||||
|
||||
const transport = new SSEServerTransport(messagesPath, res);
|
||||
transports[transport.sessionId] = { transport, group: group };
|
||||
|
||||
res.on('close', () => {
|
||||
@@ -69,13 +90,18 @@ export const handleSseConnection = async (req: Request, res: Response): Promise<
|
||||
});
|
||||
|
||||
console.log(
|
||||
`New SSE connection established: ${transport.sessionId} with group: ${group || 'global'}`,
|
||||
`New SSE connection established: ${transport.sessionId} with group: ${group || 'global'}${username ? ` for user: ${username}` : ''}`,
|
||||
);
|
||||
await getMcpServer(transport.sessionId, group).connect(transport);
|
||||
};
|
||||
|
||||
export const handleSseMessage = async (req: Request, res: Response): Promise<void> => {
|
||||
// Check bearer auth
|
||||
// User context is now set by sseUserContextMiddleware
|
||||
const userContextService = UserContextService.getInstance();
|
||||
const currentUser = userContextService.getCurrentUser();
|
||||
const username = currentUser?.username;
|
||||
|
||||
// Check bearer auth using filtered settings
|
||||
if (!validateBearerAuth(req)) {
|
||||
res.status(401).send('Bearer authentication required or invalid token');
|
||||
return;
|
||||
@@ -101,24 +127,31 @@ export const handleSseMessage = async (req: Request, res: Response): Promise<voi
|
||||
const { transport, group } = transportData;
|
||||
req.params.group = group;
|
||||
req.query.group = group;
|
||||
console.log(`Received message for sessionId: ${sessionId} in group: ${group}`);
|
||||
console.log(`Received message for sessionId: ${sessionId} in group: ${group}${username ? ` for user: ${username}` : ''}`);
|
||||
|
||||
await (transport as SSEServerTransport).handlePostMessage(req, res);
|
||||
};
|
||||
|
||||
export const handleMcpPostRequest = async (req: Request, res: Response): Promise<void> => {
|
||||
// User context is now set by sseUserContextMiddleware
|
||||
const userContextService = UserContextService.getInstance();
|
||||
const currentUser = userContextService.getCurrentUser();
|
||||
const username = currentUser?.username;
|
||||
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
const group = req.params.group;
|
||||
const body = req.body;
|
||||
console.log(
|
||||
`Handling MCP post request for sessionId: ${sessionId} and group: ${group} with body: ${JSON.stringify(body)}`,
|
||||
`Handling MCP post request for sessionId: ${sessionId} and group: ${group}${username ? ` for user: ${username}` : ''} with body: ${JSON.stringify(body)}`,
|
||||
);
|
||||
// Check bearer auth
|
||||
|
||||
// Check bearer auth using filtered settings
|
||||
if (!validateBearerAuth(req)) {
|
||||
res.status(401).send('Bearer authentication required or invalid token');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get filtered settings based on user context (after setting user context)
|
||||
const settings = loadSettings();
|
||||
const routingConfig = settings.systemConfig?.routing || {
|
||||
enableGlobalRoute: true,
|
||||
@@ -150,7 +183,7 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
||||
}
|
||||
};
|
||||
|
||||
console.log(`MCP connection established: ${transport.sessionId}`);
|
||||
console.log(`MCP connection established: ${transport.sessionId}${username ? ` for user: ${username}` : ''}`);
|
||||
await getMcpServer(transport.sessionId, group).connect(transport);
|
||||
} else {
|
||||
res.status(400).json({
|
||||
@@ -169,8 +202,14 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
||||
};
|
||||
|
||||
export const handleMcpOtherRequest = async (req: Request, res: Response) => {
|
||||
console.log('Handling MCP other request');
|
||||
// Check bearer auth
|
||||
// User context is now set by sseUserContextMiddleware
|
||||
const userContextService = UserContextService.getInstance();
|
||||
const currentUser = userContextService.getCurrentUser();
|
||||
const username = currentUser?.username;
|
||||
|
||||
console.log(`Handling MCP other request${username ? ` for user: ${username}` : ''}`);
|
||||
|
||||
// Check bearer auth using filtered settings
|
||||
if (!validateBearerAuth(req)) {
|
||||
res.status(401).send('Bearer authentication required or invalid token');
|
||||
return;
|
||||
|
||||
59
src/services/userContextService.ts
Normal file
59
src/services/userContextService.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { IUser } from '../types/index.js';
|
||||
|
||||
// User context storage
|
||||
class UserContext {
|
||||
private static instance: UserContext;
|
||||
private currentUser: IUser | null = null;
|
||||
|
||||
static getInstance(): UserContext {
|
||||
if (!UserContext.instance) {
|
||||
UserContext.instance = new UserContext();
|
||||
}
|
||||
return UserContext.instance;
|
||||
}
|
||||
|
||||
setUser(user: IUser): void {
|
||||
this.currentUser = user;
|
||||
}
|
||||
|
||||
getUser(): IUser | null {
|
||||
return this.currentUser;
|
||||
}
|
||||
|
||||
clearUser(): void {
|
||||
this.currentUser = null;
|
||||
}
|
||||
}
|
||||
|
||||
export class UserContextService {
|
||||
private static instance: UserContextService;
|
||||
private userContext = UserContext.getInstance();
|
||||
|
||||
static getInstance(): UserContextService {
|
||||
if (!UserContextService.instance) {
|
||||
UserContextService.instance = new UserContextService();
|
||||
}
|
||||
return UserContextService.instance;
|
||||
}
|
||||
|
||||
getCurrentUser(): IUser | null {
|
||||
return this.userContext.getUser();
|
||||
}
|
||||
|
||||
setCurrentUser(user: IUser): void {
|
||||
this.userContext.setUser(user);
|
||||
}
|
||||
|
||||
clearCurrentUser(): void {
|
||||
this.userContext.clearUser();
|
||||
}
|
||||
|
||||
isAdmin(): boolean {
|
||||
const user = this.getCurrentUser();
|
||||
return user?.isAdmin || false;
|
||||
}
|
||||
|
||||
hasUser(): boolean {
|
||||
return this.getCurrentUser() !== null;
|
||||
}
|
||||
}
|
||||
126
src/services/userService.ts
Normal file
126
src/services/userService.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { IUser } from '../types/index.js';
|
||||
import { getUsers, createUser, findUserByUsername } from '../models/User.js';
|
||||
import { saveSettings, loadSettings } from '../config/index.js';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
// Get all users
|
||||
export const getAllUsers = (): IUser[] => {
|
||||
return getUsers();
|
||||
};
|
||||
|
||||
// Get user by username
|
||||
export const getUserByUsername = (username: string): IUser | undefined => {
|
||||
return findUserByUsername(username);
|
||||
};
|
||||
|
||||
// Create a new user
|
||||
export const createNewUser = async (
|
||||
username: string,
|
||||
password: string,
|
||||
isAdmin: boolean = false,
|
||||
): Promise<IUser | null> => {
|
||||
try {
|
||||
const existingUser = findUserByUsername(username);
|
||||
if (existingUser) {
|
||||
return null; // User already exists
|
||||
}
|
||||
|
||||
const userData: IUser = {
|
||||
username,
|
||||
password,
|
||||
isAdmin,
|
||||
};
|
||||
|
||||
return await createUser(userData);
|
||||
} catch (error) {
|
||||
console.error('Failed to create user:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Update user information
|
||||
export const updateUser = async (
|
||||
username: string,
|
||||
data: { isAdmin?: boolean; newPassword?: string },
|
||||
): Promise<IUser | null> => {
|
||||
try {
|
||||
const users = getUsers();
|
||||
const userIndex = users.findIndex((user) => user.username === username);
|
||||
|
||||
if (userIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = users[userIndex];
|
||||
|
||||
// Update admin status if provided
|
||||
if (data.isAdmin !== undefined) {
|
||||
user.isAdmin = data.isAdmin;
|
||||
}
|
||||
|
||||
// Update password if provided
|
||||
if (data.newPassword) {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
user.password = await bcrypt.hash(data.newPassword, salt);
|
||||
}
|
||||
|
||||
// Save users array back to settings
|
||||
const { saveSettings, loadSettings } = await import('../config/index.js');
|
||||
const settings = loadSettings();
|
||||
settings.users = users;
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
console.error('Failed to update user:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Delete a user
|
||||
export const deleteUser = (username: string): boolean => {
|
||||
try {
|
||||
// Cannot delete the last admin user
|
||||
const users = getUsers();
|
||||
const adminUsers = users.filter((user) => user.isAdmin);
|
||||
const userToDelete = users.find((user) => user.username === username);
|
||||
|
||||
if (userToDelete?.isAdmin && adminUsers.length === 1) {
|
||||
return false; // Cannot delete the last admin
|
||||
}
|
||||
|
||||
const filteredUsers = users.filter((user) => user.username !== username);
|
||||
|
||||
if (filteredUsers.length === users.length) {
|
||||
return false; // User not found
|
||||
}
|
||||
|
||||
// Save filtered users back to settings
|
||||
const settings = loadSettings();
|
||||
settings.users = filteredUsers;
|
||||
|
||||
return saveSettings(settings);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete user:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if user has admin permissions
|
||||
export const isUserAdmin = (username: string): boolean => {
|
||||
const user = findUserByUsername(username);
|
||||
return user?.isAdmin || false;
|
||||
};
|
||||
|
||||
// Get user count
|
||||
export const getUserCount = (): number => {
|
||||
return getUsers().length;
|
||||
};
|
||||
|
||||
// Get admin count
|
||||
export const getAdminCount = (): number => {
|
||||
return getUsers().filter((user) => user.isAdmin).length;
|
||||
};
|
||||
@@ -18,6 +18,7 @@ export interface IGroup {
|
||||
name: string; // Display name of the group
|
||||
description?: string; // Optional description of the group
|
||||
servers: string[]; // Array of server names that belong to this group
|
||||
owner?: string; // Owner of the group, defaults to 'admin' user
|
||||
}
|
||||
|
||||
// Market server types
|
||||
@@ -74,6 +75,30 @@ export interface MarketServer {
|
||||
is_official?: boolean;
|
||||
}
|
||||
|
||||
export interface SystemConfig {
|
||||
routing?: {
|
||||
enableGlobalRoute?: boolean; // Controls whether the /sse endpoint without group is enabled
|
||||
enableGroupNameRoute?: boolean; // Controls whether group routing by name is allowed
|
||||
enableBearerAuth?: boolean; // Controls whether bearer auth is enabled for group routes
|
||||
bearerAuthKey?: string; // The bearer auth key to validate against
|
||||
skipAuth?: boolean; // Controls whether authentication is required for frontend and API access
|
||||
};
|
||||
install?: {
|
||||
pythonIndexUrl?: string; // Python package repository URL (UV_DEFAULT_INDEX)
|
||||
npmRegistry?: string; // NPM registry URL (npm_config_registry)
|
||||
};
|
||||
smartRouting?: SmartRoutingConfig;
|
||||
}
|
||||
|
||||
export interface UserConfig {
|
||||
routing?: {
|
||||
enableGlobalRoute?: boolean; // Controls whether the /sse endpoint without group is enabled
|
||||
enableGroupNameRoute?: boolean; // Controls whether group routing by name is allowed
|
||||
enableBearerAuth?: boolean; // Controls whether bearer auth is enabled for group routes
|
||||
bearerAuthKey?: string; // The bearer auth key to validate against
|
||||
};
|
||||
}
|
||||
|
||||
// Represents the settings for MCP servers
|
||||
export interface McpSettings {
|
||||
users?: IUser[]; // Array of user credentials and permissions
|
||||
@@ -81,21 +106,8 @@ export interface McpSettings {
|
||||
[key: string]: ServerConfig; // Key-value pairs of server names and their configurations
|
||||
};
|
||||
groups?: IGroup[]; // Array of server groups
|
||||
systemConfig?: {
|
||||
routing?: {
|
||||
enableGlobalRoute?: boolean; // Controls whether the /sse endpoint without group is enabled
|
||||
enableGroupNameRoute?: boolean; // Controls whether group routing by name is allowed
|
||||
enableBearerAuth?: boolean; // Controls whether bearer auth is enabled for group routes
|
||||
bearerAuthKey?: string; // The bearer auth key to validate against
|
||||
skipAuth?: boolean; // Controls whether authentication is required for frontend and API access
|
||||
};
|
||||
install?: {
|
||||
pythonIndexUrl?: string; // Python package repository URL (UV_DEFAULT_INDEX)
|
||||
npmRegistry?: string; // NPM registry URL (npm_config_registry)
|
||||
};
|
||||
smartRouting?: SmartRoutingConfig;
|
||||
// Add other system configuration sections here in the future
|
||||
};
|
||||
systemConfig?: SystemConfig; // System-wide configuration settings
|
||||
userConfigs?: Record<string, UserConfig>; // User-specific configurations
|
||||
}
|
||||
|
||||
// Configuration details for an individual server
|
||||
@@ -107,6 +119,7 @@ export interface ServerConfig {
|
||||
env?: Record<string, string>; // Environment variables
|
||||
headers?: Record<string, string>; // HTTP headers for SSE/streamable-http/openapi servers
|
||||
enabled?: boolean; // Flag to enable/disable the server
|
||||
owner?: string; // Owner of the server, defaults to 'admin' user
|
||||
keepAliveInterval?: number; // Keep-alive ping interval in milliseconds (default: 60000ms for SSE servers)
|
||||
tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions
|
||||
options?: Partial<Pick<RequestOptions, 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout'>>; // MCP request options configuration
|
||||
@@ -154,6 +167,7 @@ export interface OpenAPISecurityConfig {
|
||||
// Information about a server's status and tools
|
||||
export interface ServerInfo {
|
||||
name: string; // Unique name of the server
|
||||
owner?: string; // Owner of the server, defaults to 'admin' user
|
||||
status: 'connected' | 'connecting' | 'disconnected'; // Current connection status
|
||||
error: string | null; // Error message if any
|
||||
tools: ToolInfo[]; // List of tools available on the server
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
|
||||
// Get current file's directory
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
// Project root directory should be the parent directory of src
|
||||
const rootDir = dirname(dirname(__dirname));
|
||||
// Project root directory - use process.cwd() as a simpler alternative
|
||||
const rootDir = process.cwd();
|
||||
|
||||
/**
|
||||
* Find the path to a configuration file by checking multiple potential locations.
|
||||
@@ -24,7 +20,7 @@ export const getConfigFilePath = (filename: string, description = 'Configuration
|
||||
// Use path relative to the root directory
|
||||
path.join(rootDir, filename),
|
||||
// If installed with npx, may need to look one level up
|
||||
path.join(dirname(rootDir), filename)
|
||||
path.join(dirname(rootDir), filename),
|
||||
];
|
||||
|
||||
for (const filePath of potentialPaths) {
|
||||
@@ -38,6 +34,8 @@ export const getConfigFilePath = (filename: string, description = 'Configuration
|
||||
// even if the configuration file is missing. This fallback is particularly useful in
|
||||
// development environments or when the file is optional.
|
||||
const defaultPath = path.resolve(process.cwd(), filename);
|
||||
console.debug(`${description} file not found at any expected location, using default path: ${defaultPath}`);
|
||||
console.debug(
|
||||
`${description} file not found at any expected location, using default path: ${defaultPath}`,
|
||||
);
|
||||
return defaultPath;
|
||||
};
|
||||
};
|
||||
|
||||
72
src/utils/serialization.ts
Normal file
72
src/utils/serialization.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Utility functions for safe JSON serialization
|
||||
* Handles circular references and provides type-safe serialization
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates a JSON-safe copy of an object by removing circular references
|
||||
* Uses a replacer function with WeakSet to efficiently track visited objects
|
||||
*
|
||||
* @param obj - The object to make JSON-safe
|
||||
* @returns A new object that can be safely serialized to JSON
|
||||
*/
|
||||
export const createSafeJSON = <T>(obj: T): T => {
|
||||
const seen = new WeakSet();
|
||||
|
||||
return JSON.parse(
|
||||
JSON.stringify(obj, (key, value) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (seen.has(value)) {
|
||||
return '[Circular Reference]';
|
||||
}
|
||||
seen.add(value);
|
||||
}
|
||||
return value;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Safe JSON stringifier that handles circular references
|
||||
* Useful for logging or debugging purposes
|
||||
*
|
||||
* @param obj - The object to stringify
|
||||
* @param space - Number of spaces to use for indentation (optional)
|
||||
* @returns JSON string representation of the object
|
||||
*/
|
||||
export const safeStringify = (obj: any, space?: number): string => {
|
||||
const seen = new WeakSet();
|
||||
|
||||
return JSON.stringify(
|
||||
obj,
|
||||
(key, value) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (seen.has(value)) {
|
||||
return '[Circular Reference]';
|
||||
}
|
||||
seen.add(value);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
space,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes specific properties that might contain circular references
|
||||
* More targeted approach for known problematic properties
|
||||
*
|
||||
* @param obj - The object to clean
|
||||
* @param excludeProps - Array of property names to exclude
|
||||
* @returns A new object without the specified properties
|
||||
*/
|
||||
export const excludeCircularProps = <T extends Record<string, any>>(
|
||||
obj: T,
|
||||
excludeProps: string[],
|
||||
): Omit<T, keyof (typeof excludeProps)[number]> => {
|
||||
const result = { ...obj };
|
||||
excludeProps.forEach((prop) => {
|
||||
delete result[prop];
|
||||
});
|
||||
return result;
|
||||
};
|
||||
@@ -1,10 +1,5 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// Get the directory name in ESM
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
/**
|
||||
* Gets the package version from package.json
|
||||
@@ -12,7 +7,7 @@ const __dirname = path.dirname(__filename);
|
||||
*/
|
||||
export const getPackageVersion = (): string => {
|
||||
try {
|
||||
const packageJsonPath = path.resolve(__dirname, '../../package.json');
|
||||
const packageJsonPath = path.resolve(process.cwd(), 'package.json');
|
||||
const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf8');
|
||||
const packageJson = JSON.parse(packageJsonContent);
|
||||
return packageJson.version || 'dev';
|
||||
|
||||
465
tests/integration/sse-service-real-client.test.ts
Normal file
465
tests/integration/sse-service-real-client.test.ts
Normal file
@@ -0,0 +1,465 @@
|
||||
import { Server } from 'http';
|
||||
import { AppServer } from '../../src/server.js';
|
||||
import { TestServerHelper } from '../utils/testServerHelper.js';
|
||||
import * as mockSettings from '../utils/mockSettings.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { cleanupAllServers } from '../../src/services/mcpService.js';
|
||||
|
||||
describe('Real Client Transport Integration Tests', () => {
|
||||
let _appServer: AppServer;
|
||||
let httpServer: Server;
|
||||
let baseURL: string;
|
||||
let testServerHelper: TestServerHelper;
|
||||
|
||||
beforeAll(async () => {
|
||||
const settings = mockSettings.createMockSettings();
|
||||
testServerHelper = new TestServerHelper();
|
||||
const result = await testServerHelper.createTestServer(settings);
|
||||
|
||||
_appServer = result.appServer;
|
||||
httpServer = result.httpServer;
|
||||
baseURL = result.baseURL;
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up all MCP server connections first
|
||||
cleanupAllServers();
|
||||
|
||||
// Close the test server properly using the helper
|
||||
if (testServerHelper) {
|
||||
await testServerHelper.closeTestServer();
|
||||
} else if (httpServer) {
|
||||
// Fallback to direct close if helper is not available
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
// Wait a bit to ensure all async operations complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
});
|
||||
|
||||
describe('SSE Client Transport Tests', () => {
|
||||
it('should connect using real SSEClientTransport', async () => {
|
||||
const sseUrl = new URL(`${baseURL}/sse`);
|
||||
const options = {
|
||||
requestInit: {
|
||||
headers: {
|
||||
Authorization: 'Bearer test-auth-token-123',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const transport = new SSEClientTransport(sseUrl, options);
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'real-sse-test-client',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
resources: {},
|
||||
prompts: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let isConnected = false;
|
||||
let error: any = null;
|
||||
|
||||
try {
|
||||
await client.connect(transport, {});
|
||||
isConnected = true;
|
||||
console.log('SSE Client connected successfully');
|
||||
|
||||
// Test list tools
|
||||
const tools = await client.listTools({});
|
||||
console.log('Available tools (SSE):', JSON.stringify(tools, null, 2));
|
||||
|
||||
await client.close();
|
||||
console.log('SSE Client closed successfully');
|
||||
} catch (err) {
|
||||
error = err;
|
||||
console.error('SSE Client test failed:', err);
|
||||
|
||||
if (isConnected) {
|
||||
try {
|
||||
await client.close();
|
||||
} catch (closeErr) {
|
||||
console.error('Error closing client:', closeErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(error).toBeNull();
|
||||
expect(isConnected).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it('should connect using real SSEClientTransport with group', async () => {
|
||||
const testGroup = 'integration-test-group';
|
||||
const options = {
|
||||
requestInit: {
|
||||
headers: {
|
||||
Authorization: 'Bearer test-auth-token-123',
|
||||
},
|
||||
},
|
||||
};
|
||||
const sseUrl = new URL(`${baseURL}/sse/${testGroup}`);
|
||||
|
||||
const transport = new SSEClientTransport(sseUrl, options);
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'real-sse-group-test-client',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
resources: {},
|
||||
prompts: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let isConnected = false;
|
||||
let error: any = null;
|
||||
|
||||
try {
|
||||
await client.connect(transport, {});
|
||||
isConnected = true;
|
||||
|
||||
console.log(`SSE Client with group ${testGroup} connected successfully`);
|
||||
|
||||
// Test basic operations
|
||||
const tools = await client.listTools({});
|
||||
console.log('Available tools (SSE with group):', JSON.stringify(tools, null, 2));
|
||||
|
||||
await client.close();
|
||||
} catch (err) {
|
||||
error = err;
|
||||
console.error('SSE Client with group test failed:', err);
|
||||
|
||||
if (isConnected) {
|
||||
try {
|
||||
await client.close();
|
||||
} catch (closeErr) {
|
||||
console.error('Error closing client:', closeErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(error).toBeNull();
|
||||
expect(isConnected).toBe(true);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe('StreamableHTTP Client Transport Tests', () => {
|
||||
it('should connect using real StreamableHTTPClientTransport', async () => {
|
||||
const mcpUrl = new URL(`${baseURL}/mcp`);
|
||||
const options: any = {
|
||||
requestInit: {
|
||||
headers: {
|
||||
Authorization: `Bearer test-auth-token-123`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(mcpUrl, options);
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'real-http-test-client',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
resources: {},
|
||||
prompts: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let isConnected = false;
|
||||
let error: any = null;
|
||||
|
||||
try {
|
||||
await client.connect(transport, {});
|
||||
isConnected = true;
|
||||
console.log('HTTP Client connected successfully');
|
||||
|
||||
// Test list tools
|
||||
const tools = await client.listTools({});
|
||||
console.log('Available tools (HTTP):', JSON.stringify(tools, null, 2));
|
||||
|
||||
await client.close();
|
||||
console.log('HTTP Client closed successfully');
|
||||
} catch (err) {
|
||||
error = err;
|
||||
console.error('HTTP Client test failed:', err);
|
||||
|
||||
if (isConnected) {
|
||||
try {
|
||||
await client.close();
|
||||
} catch (closeErr) {
|
||||
console.error('Error closing client:', closeErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(error).toBeNull();
|
||||
expect(isConnected).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it('should connect using real StreamableHTTPClientTransport with group', async () => {
|
||||
const testGroup = 'integration-test-group';
|
||||
const mcpUrl = new URL(`${baseURL}/mcp/${testGroup}`);
|
||||
const options: any = {
|
||||
requestInit: {
|
||||
headers: {
|
||||
Authorization: `Bearer test-auth-token-123`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(mcpUrl, options);
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'real-http-group-test-client',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
resources: {},
|
||||
prompts: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let isConnected = false;
|
||||
let error: any = null;
|
||||
|
||||
try {
|
||||
await client.connect(transport, {});
|
||||
isConnected = true;
|
||||
|
||||
console.log(`HTTP Client with group ${testGroup} connected successfully`);
|
||||
|
||||
// Test basic operations
|
||||
const tools = await client.listTools({});
|
||||
console.log('Available tools (HTTP with group):', JSON.stringify(tools, null, 2));
|
||||
|
||||
await client.close();
|
||||
} catch (err) {
|
||||
error = err;
|
||||
console.error('HTTP Client with group test failed:', err);
|
||||
|
||||
if (isConnected) {
|
||||
try {
|
||||
await client.close();
|
||||
} catch (closeErr) {
|
||||
console.error('Error closing client:', closeErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(error).toBeNull();
|
||||
expect(isConnected).toBe(true);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe('Real Client Authentication Tests', () => {
|
||||
let _authAppServer: AppServer;
|
||||
let _authHttpServer: Server;
|
||||
let authBaseURL: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const authSettings = mockSettings.createMockSettingsWithAuth();
|
||||
const authTestServerHelper = new TestServerHelper();
|
||||
const authResult = await authTestServerHelper.createTestServer(authSettings);
|
||||
|
||||
_authAppServer = authResult.appServer;
|
||||
_authHttpServer = authResult.httpServer;
|
||||
authBaseURL = authResult.baseURL;
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (_authHttpServer) {
|
||||
_authHttpServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should fail to connect with SSEClientTransport without auth', async () => {
|
||||
const sseUrl = new URL(`${authBaseURL}/sse`);
|
||||
const options = {
|
||||
requestInit: {
|
||||
headers: {
|
||||
Authorization: 'Bearer test-auth-token-123',
|
||||
},
|
||||
},
|
||||
};
|
||||
const transport = new SSEClientTransport(sseUrl, options);
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'real-sse-test-client-no-auth',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
resources: {},
|
||||
prompts: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let error: any = null;
|
||||
|
||||
try {
|
||||
await client.connect(transport, {});
|
||||
|
||||
// Should not reach here due to auth failure
|
||||
await client.listTools({});
|
||||
|
||||
await client.close();
|
||||
} catch (err) {
|
||||
error = err;
|
||||
console.log('Expected auth error:', err);
|
||||
|
||||
try {
|
||||
await client.close();
|
||||
} catch (closeErr) {
|
||||
// Ignore close errors after connection failure
|
||||
}
|
||||
}
|
||||
|
||||
expect(error).toBeDefined();
|
||||
if (error) {
|
||||
expect(error.message).toContain('401');
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it('should connect with SSEClientTransport with valid auth', async () => {
|
||||
const sseUrl = new URL(`${authBaseURL}/sse`);
|
||||
|
||||
const options = {
|
||||
requestInit: {
|
||||
headers: {
|
||||
Authorization: 'Bearer test-auth-token-123',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const transport = new SSEClientTransport(sseUrl, options);
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'real-sse-auth-test-client',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
resources: {},
|
||||
prompts: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let isConnected = false;
|
||||
let error: any = null;
|
||||
|
||||
try {
|
||||
await client.connect(transport, {});
|
||||
isConnected = true;
|
||||
console.log('SSE Client with auth connected successfully');
|
||||
|
||||
// Test basic operations
|
||||
const tools = await client.listTools({});
|
||||
console.log('Available tools (SSE with auth):', JSON.stringify(tools, null, 2));
|
||||
|
||||
await client.close();
|
||||
} catch (err) {
|
||||
error = err;
|
||||
console.error('SSE Client with auth test failed:', err);
|
||||
|
||||
if (isConnected) {
|
||||
try {
|
||||
await client.close();
|
||||
} catch (closeErr) {
|
||||
console.error('Error closing client:', closeErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(error).toBeNull();
|
||||
expect(isConnected).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it('should connect with StreamableHTTPClientTransport with auth', async () => {
|
||||
const mcpUrl = new URL(`${authBaseURL}/mcp`);
|
||||
|
||||
const options = {
|
||||
requestInit: {
|
||||
headers: {
|
||||
Authorization: 'Bearer test-auth-token-123',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(mcpUrl, options);
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'real-http-auth-test-client',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
resources: {},
|
||||
prompts: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let isConnected = false;
|
||||
let error: any = null;
|
||||
|
||||
try {
|
||||
await client.connect(transport, {});
|
||||
isConnected = true;
|
||||
|
||||
console.log('HTTP Client with auth connected successfully');
|
||||
|
||||
// Test basic operations
|
||||
const tools = await client.listTools({});
|
||||
console.log('Available tools (HTTP with auth):', JSON.stringify(tools, null, 2));
|
||||
|
||||
await client.close();
|
||||
} catch (err) {
|
||||
error = err;
|
||||
console.error('HTTP Client with auth test failed:', err);
|
||||
|
||||
if (isConnected) {
|
||||
try {
|
||||
await client.close();
|
||||
} catch (closeErr) {
|
||||
console.error('Error closing client:', closeErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(error).toBeNull();
|
||||
expect(isConnected).toBe(true);
|
||||
}, 30000);
|
||||
});
|
||||
});
|
||||
107
tests/utils/mockSettings.ts
Normal file
107
tests/utils/mockSettings.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { McpSettings, ServerConfig, SystemConfig, IGroup, IUser } from '../../src/types/index.js';
|
||||
|
||||
/**
|
||||
* Creates mock MCP settings for testing
|
||||
* @param overrides Optional configuration overrides
|
||||
* @returns Mock McpSettings object
|
||||
*/
|
||||
export const createMockSettings = (overrides: Partial<McpSettings> = {}): McpSettings => {
|
||||
const defaultSettings: McpSettings = {
|
||||
mcpServers: {
|
||||
'test-server-1': {
|
||||
command: 'npx',
|
||||
args: ['-y', 'time-mcp'],
|
||||
env: {},
|
||||
enabled: true,
|
||||
keepAliveInterval: 30000,
|
||||
type: 'stdio',
|
||||
} as ServerConfig,
|
||||
},
|
||||
groups: [
|
||||
{
|
||||
name: 'integration-test-group',
|
||||
servers: ['test-server-1'],
|
||||
description: 'Test group for integration tests',
|
||||
owner: 'admin',
|
||||
} as IGroup,
|
||||
],
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-auth-token-123',
|
||||
},
|
||||
} as SystemConfig,
|
||||
users: [
|
||||
{
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
isAdmin: false,
|
||||
} as IUser,
|
||||
],
|
||||
};
|
||||
|
||||
return {
|
||||
...defaultSettings,
|
||||
...overrides,
|
||||
mcpServers: {
|
||||
...defaultSettings.mcpServers,
|
||||
...(overrides.mcpServers || {}),
|
||||
},
|
||||
groups: [...(defaultSettings.groups || []), ...(overrides.groups || [])],
|
||||
systemConfig: {
|
||||
...defaultSettings.systemConfig,
|
||||
...(overrides.systemConfig || {}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates mock settings with bearer authentication enabled
|
||||
*/
|
||||
export const createMockSettingsWithAuth = (bearerKey = 'test-auth-token-123'): McpSettings => {
|
||||
return createMockSettings({
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: bearerKey,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates mock settings with global routes disabled
|
||||
*/
|
||||
export const createMockSettingsNoGlobalRoutes = (): McpSettings => {
|
||||
return createMockSettings({
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: false,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock settings helper for specific test scenarios
|
||||
*/
|
||||
export const getMockSettingsForScenario = (
|
||||
scenario: 'auth' | 'no-global' | 'basic',
|
||||
): McpSettings => {
|
||||
switch (scenario) {
|
||||
case 'auth':
|
||||
return createMockSettingsWithAuth();
|
||||
case 'no-global':
|
||||
return createMockSettingsNoGlobalRoutes();
|
||||
case 'basic':
|
||||
default:
|
||||
return createMockSettings();
|
||||
}
|
||||
};
|
||||
176
tests/utils/testServerHelper.ts
Normal file
176
tests/utils/testServerHelper.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { Server } from 'http';
|
||||
import { AppServer } from '../../src/server.js';
|
||||
import { McpSettings } from '../../src/types/index.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { createMockSettings } from './mockSettings.js';
|
||||
import { clearSettingsCache } from '../../src/config/index.js';
|
||||
|
||||
/**
|
||||
* Test server helper class for managing AppServer instances during testing
|
||||
*/
|
||||
export class TestServerHelper {
|
||||
private appServer: AppServer | null = null;
|
||||
private httpServer: Server | null = null;
|
||||
private originalConfigPath: string | null = null;
|
||||
private testConfigPath: string | null = null;
|
||||
|
||||
/**
|
||||
* Creates and initializes a test server with mock settings
|
||||
* @param mockSettings Optional mock settings to use
|
||||
* @returns Object containing server instance and base URL
|
||||
*/
|
||||
async createTestServer(mockSettings?: McpSettings): Promise<{
|
||||
appServer: AppServer;
|
||||
httpServer: Server;
|
||||
baseURL: string;
|
||||
port: number;
|
||||
}> {
|
||||
// Use provided mock settings or create default ones
|
||||
const settings = mockSettings || createMockSettings();
|
||||
|
||||
// Create temporary config file for testing
|
||||
await this.setupTemporaryConfig(settings);
|
||||
|
||||
// Create and initialize AppServer
|
||||
this.appServer = new AppServer();
|
||||
await this.appServer.initialize();
|
||||
|
||||
// Wait for server connection with timeout
|
||||
const maxAttempts = 30;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
if (this.appServer.connected()) {
|
||||
console.log('Test server is ready');
|
||||
break;
|
||||
} else if (attempt === maxAttempts - 1) {
|
||||
throw new Error('Test server did not become ready in time');
|
||||
}
|
||||
console.log(`Waiting for test server to be ready... Attempt ${attempt + 1}/${maxAttempts}`);
|
||||
await delay(3000); // Short delay between checks
|
||||
}
|
||||
|
||||
// Start server on random available port
|
||||
const app = this.appServer.getApp();
|
||||
this.httpServer = app.listen(0);
|
||||
|
||||
const address = this.httpServer.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 3000;
|
||||
const baseURL = `http://localhost:${port}`;
|
||||
|
||||
return {
|
||||
appServer: this.appServer,
|
||||
httpServer: this.httpServer,
|
||||
baseURL,
|
||||
port,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the test server and cleans up temporary files
|
||||
*/
|
||||
async closeTestServer(): Promise<void> {
|
||||
if (this.httpServer) {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.httpServer!.close(() => resolve());
|
||||
});
|
||||
this.httpServer = null;
|
||||
}
|
||||
|
||||
this.appServer = null;
|
||||
|
||||
// Clean up temporary config file
|
||||
await this.cleanupTemporaryConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a temporary config file for testing
|
||||
* @param settings Mock settings to write to the config file
|
||||
*/
|
||||
private async setupTemporaryConfig(settings: McpSettings): Promise<void> {
|
||||
// Store original path if it exists
|
||||
this.originalConfigPath = process.env.MCPHUB_SETTING_PATH || null;
|
||||
|
||||
const configDir = path.join(process.cwd(), 'temp-test-config');
|
||||
|
||||
// Create temp config directory if it doesn't exist
|
||||
if (!fs.existsSync(configDir)) {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
}
|
||||
|
||||
this.testConfigPath = path.join(configDir, 'mcp_settings.json');
|
||||
|
||||
// Write mock settings to temporary file
|
||||
fs.writeFileSync(this.testConfigPath, JSON.stringify(settings, null, 2));
|
||||
|
||||
// Override the settings path for the test
|
||||
process.env.MCPHUB_SETTING_PATH = this.testConfigPath;
|
||||
|
||||
// Clear settings cache to force re-reading from the new config file
|
||||
clearSettingsCache();
|
||||
|
||||
console.log(`Set test config path: ${this.testConfigPath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up the temporary config file
|
||||
*/
|
||||
private async cleanupTemporaryConfig(): Promise<void> {
|
||||
if (this.testConfigPath && fs.existsSync(this.testConfigPath)) {
|
||||
fs.unlinkSync(this.testConfigPath);
|
||||
|
||||
// Try to remove the temp directory if empty
|
||||
const configDir = path.dirname(this.testConfigPath);
|
||||
try {
|
||||
fs.rmdirSync(configDir);
|
||||
} catch (error) {
|
||||
// Ignore error if directory is not empty
|
||||
}
|
||||
}
|
||||
|
||||
// Reset environment variable
|
||||
if (this.originalConfigPath !== null) {
|
||||
process.env.MCPHUB_SETTING_PATH = this.originalConfigPath;
|
||||
} else {
|
||||
delete process.env.MCPHUB_SETTING_PATH;
|
||||
}
|
||||
|
||||
this.testConfigPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for a server to be ready by attempting to connect
|
||||
* @param baseURL Base URL of the server
|
||||
* @param maxAttempts Maximum number of connection attempts
|
||||
* @param delay Delay between attempts in milliseconds
|
||||
*/
|
||||
export const waitForServerReady = async (
|
||||
baseURL: string,
|
||||
maxAttempts = 10,
|
||||
delay = 500,
|
||||
): Promise<void> => {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
try {
|
||||
const response = await fetch(`${baseURL}/health`);
|
||||
if (response.ok || response.status === 404) {
|
||||
return; // Server is responding
|
||||
}
|
||||
} catch (error) {
|
||||
// Server not ready yet
|
||||
}
|
||||
|
||||
if (i < maxAttempts - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Server at ${baseURL} not ready after ${maxAttempts} attempts`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a promise that resolves after the specified delay
|
||||
* @param ms Delay in milliseconds
|
||||
*/
|
||||
export const delay = (ms: number): Promise<void> => {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
};
|
||||
@@ -13,7 +13,8 @@
|
||||
"sourceMap": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"strictPropertyInitialization": false
|
||||
"strictPropertyInitialization": false,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "**/*.test.ts", "dist"]
|
||||
|
||||
12
tsconfig.test.json
Normal file
12
tsconfig.test.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"isolatedModules": true,
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": ["src/**/*", "tests/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user