refactor frontend code

This commit is contained in:
samanhappy
2025-04-09 11:29:35 +08:00
parent aada2d29de
commit b1d8b1a825
27 changed files with 1822 additions and 787 deletions

BIN
frontend/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!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">
</head>
<body class="bg-gray-100">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
};

156
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,156 @@
import { useState, useEffect } from 'react'
import { Server, ApiResponse } from './types'
import ServerCard from './components/ServerCard'
import AddServerForm from './components/AddServerForm'
import EditServerForm from './components/EditServerForm'
function App() {
const [servers, setServers] = useState<Server[]>([])
const [error, setError] = useState<string | null>(null)
const [refreshKey, setRefreshKey] = useState(0)
const [editingServer, setEditingServer] = useState<Server | null>(null)
useEffect(() => {
const fetchServers = async () => {
try {
const response = await fetch('/api/servers')
const data = await response.json()
// 处理API响应中的包装对象提取data字段
if (data && data.success && Array.isArray(data.data)) {
setServers(data.data)
} else if (data && Array.isArray(data)) {
// 兼容性处理如果API直接返回数组
setServers(data)
} else {
// 如果数据格式不符合预期,设置为空数组
console.error('Invalid server data format:', data)
setServers([])
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
}
}
fetchServers()
// Poll for updates every 5 seconds
const interval = setInterval(fetchServers, 5000)
return () => clearInterval(interval)
}, [refreshKey])
const handleServerAdd = () => {
setRefreshKey(prevKey => prevKey + 1)
}
const handleServerEdit = (server: Server) => {
// Fetch settings to get the full server config before editing
fetch(`/api/settings`)
.then(response => response.json())
.then((settingsData: ApiResponse<{ mcpServers: Record<string, any> }>) => {
if (
settingsData &&
settingsData.success &&
settingsData.data &&
settingsData.data.mcpServers &&
settingsData.data.mcpServers[server.name]
) {
const serverConfig = settingsData.data.mcpServers[server.name]
const fullServerData = {
name: server.name,
status: server.status,
tools: server.tools || [],
config: serverConfig,
}
console.log('Editing server with config:', fullServerData)
setEditingServer(fullServerData)
} else {
console.error('Failed to get server config from settings:', settingsData)
setError(`Could not find configuration data for ${server.name}`)
}
})
.catch(err => {
console.error('Error fetching server settings:', err)
setError(err instanceof Error ? err.message : String(err))
})
}
const handleEditComplete = () => {
setEditingServer(null)
setRefreshKey(prevKey => prevKey + 1)
}
const handleServerRemove = async (serverName: string) => {
try {
const response = await fetch(`/api/servers/${serverName}`, {
method: 'DELETE',
})
const result = await response.json()
if (!response.ok) {
setError(result.message || `Failed to delete server ${serverName}`)
return
}
setRefreshKey(prevKey => prevKey + 1)
} catch (err) {
setError('Error: ' + (err instanceof Error ? err.message : String(err)))
}
}
if (error) {
return (
<div className="min-h-screen bg-red-50 p-8">
<div className="max-w-3xl mx-auto">
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-red-600 text-xl font-semibold">Error</h2>
<p className="text-gray-600 mt-2">{error}</p>
<button
onClick={() => setError(null)}
className="mt-4 bg-red-100 text-red-800 py-1 px-3 rounded hover:bg-red-200"
>
Close
</button>
</div>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-100 p-8">
<div className="max-w-3xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">MCP Hub Dashboard</h1>
<AddServerForm onAdd={handleServerAdd} />
</div>
{servers.length === 0 ? (
<div className="bg-white shadow rounded-lg p-6">
<p className="text-gray-600">No MCP servers available</p>
</div>
) : (
<div className="space-y-6">
{servers.map((server, index) => (
<ServerCard
key={index}
server={server}
onRemove={handleServerRemove}
onEdit={handleServerEdit}
/>
))}
</div>
)}
{editingServer && (
<EditServerForm
server={editingServer}
onEdit={handleEditComplete}
onCancel={() => setEditingServer(null)}
/>
)}
</div>
</div>
)
}
export default App

View File

@@ -0,0 +1,55 @@
import { useState } from 'react'
import ServerForm from './ServerForm'
interface AddServerFormProps {
onAdd: () => void
}
const AddServerForm = ({ onAdd }: AddServerFormProps) => {
const [modalVisible, setModalVisible] = useState(false)
const toggleModal = () => {
setModalVisible(!modalVisible)
}
const handleSubmit = async (payload: any) => {
try {
const response = await fetch('/api/servers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const result = await response.json()
if (!response.ok) {
alert(result.message || 'Failed to add server')
return
}
setModalVisible(false)
onAdd()
} catch (err) {
alert(`Error: ${err instanceof Error ? err.message : String(err)}`)
}
}
return (
<div>
<button
onClick={toggleModal}
className="w-full bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded"
>
Add New Server
</button>
{modalVisible && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<ServerForm onSubmit={handleSubmit} onCancel={toggleModal} modalTitle="Add New Server" />
</div>
)}
</div>
)
}
export default AddServerForm

View File

@@ -0,0 +1,44 @@
import { Server } from '@/types'
import ServerForm from './ServerForm'
interface EditServerFormProps {
server: Server
onEdit: () => void
onCancel: () => void
}
const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => {
const handleSubmit = async (payload: any) => {
try {
const response = await fetch(`/api/servers/${server.name}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const result = await response.json()
if (!response.ok) {
alert(result.message || 'Failed to update server')
return
}
onEdit()
} catch (err) {
alert(`Error: ${err instanceof Error ? err.message : String(err)}`)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<ServerForm
onSubmit={handleSubmit}
onCancel={onCancel}
initialData={server}
modalTitle={`Edit Server: ${server.name}`}
/>
</div>
)
}
export default EditServerForm

View File

@@ -0,0 +1,83 @@
import { useState } from 'react'
import { Server } from '@/types'
import { ChevronDown, ChevronRight } from '@/components/icons/LucideIcons'
import Badge from '@/components/ui/Badge'
import ToolCard from '@/components/ui/ToolCard'
import DeleteDialog from '@/components/ui/DeleteDialog'
interface ServerCardProps {
server: Server
onRemove: (serverName: string) => void
onEdit: (server: Server) => void
}
const ServerCard = ({ server, onRemove, onEdit }: ServerCardProps) => {
const [isExpanded, setIsExpanded] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation()
setShowDeleteDialog(true)
}
const handleEdit = (e: React.MouseEvent) => {
e.stopPropagation()
onEdit(server)
}
const handleConfirmDelete = () => {
onRemove(server.name)
setShowDeleteDialog(false)
}
return (
<div className="bg-white shadow rounded-lg p-6 mb-6">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center space-x-3">
<h2 className="text-xl font-semibold text-gray-900">{server.name}</h2>
<Badge status={server.status} />
</div>
<div className="flex space-x-2">
<button
onClick={handleEdit}
className="px-3 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm"
>
Edit
</button>
<button
onClick={handleRemove}
className="px-3 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm"
>
Delete
</button>
<button className="text-gray-400 hover:text-gray-600">
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button>
</div>
</div>
<DeleteDialog
isOpen={showDeleteDialog}
onClose={() => setShowDeleteDialog(false)}
onConfirm={handleConfirmDelete}
serverName={server.name}
/>
{isExpanded && server.tools && (
<div className="mt-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Available Tools</h3>
<div className="space-y-4">
{server.tools.map((tool, index) => (
<ToolCard key={index} tool={tool} />
))}
</div>
</div>
)}
</div>
)
}
export default ServerCard

View File

@@ -0,0 +1,269 @@
import { useState } from 'react'
import { Server, EnvVar, ServerFormData } from '@/types'
interface ServerFormProps {
onSubmit: (payload: any) => void
onCancel: () => void
initialData?: Server | null
modalTitle: string
}
const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle }: ServerFormProps) => {
const [serverType, setServerType] = useState<'sse' | 'stdio'>(
initialData && initialData.config && initialData.config.url ? 'sse' : 'stdio',
)
const [formData, setFormData] = useState<ServerFormData>({
name: (initialData && initialData.name) || '',
url: (initialData && initialData.config && initialData.config.url) || '',
command: (initialData && initialData.config && initialData.config.command) || '',
arguments:
initialData && initialData.config && initialData.config.args
? Array.isArray(initialData.config.args)
? initialData.config.args.join(' ')
: String(initialData.config.args)
: '',
args: (initialData && initialData.config && initialData.config.args) || [],
})
const [envVars, setEnvVars] = useState<EnvVar[]>(
initialData && initialData.config && initialData.config.env
? Object.entries(initialData.config.env).map(([key, value]) => ({ key, value }))
: [],
)
const [error, setError] = useState<string | null>(null)
const isEdit = !!initialData
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target
setFormData({ ...formData, [name]: value })
}
// Transform space-separated arguments string into array
const handleArgsChange = (value: string) => {
let args = value.split(' ').filter((arg) => arg.trim() !== '')
setFormData({ ...formData, arguments: value, args })
}
const handleEnvVarChange = (index: number, field: 'key' | 'value', value: string) => {
const newEnvVars = [...envVars]
newEnvVars[index][field] = value
setEnvVars(newEnvVars)
}
const addEnvVar = () => {
setEnvVars([...envVars, { key: '', value: '' }])
}
const removeEnvVar = (index: number) => {
const newEnvVars = [...envVars]
newEnvVars.splice(index, 1)
setEnvVars(newEnvVars)
}
// Submit handler for server configuration
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
try {
const env: Record<string, string> = {}
envVars.forEach(({ key, value }) => {
if (key.trim()) {
env[key.trim()] = value
}
})
const payload = {
name: formData.name,
config:
serverType === 'sse'
? { url: formData.url }
: {
command: formData.command,
args: formData.args,
env: Object.keys(env).length > 0 ? env : undefined,
},
}
onSubmit(payload)
} catch (err) {
setError(`Error: ${err instanceof Error ? err.message : String(err)}`)
}
}
return (
<div className="bg-white shadow rounded-lg p-6 w-full max-w-xl max-h-screen overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-900">{modalTitle}</h2>
<button onClick={onCancel} className="text-gray-500 hover:text-gray-700">
</button>
</div>
{error && <div className="bg-red-50 text-red-700 p-3 rounded mb-4">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="name">
Server Name
</label>
<input
type="text"
name="name"
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"
placeholder="e.g.: time-mcp"
required
disabled={isEdit}
/>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">Server Type</label>
<div className="flex space-x-4">
<div>
<input
type="radio"
id="command"
name="serverType"
value="command"
checked={serverType === 'stdio'}
onChange={() => setServerType('stdio')}
className="mr-1"
/>
<label htmlFor="command">stdio</label>
</div>
<div>
<input
type="radio"
id="url"
name="serverType"
value="url"
checked={serverType === 'sse'}
onChange={() => setServerType('sse')}
className="mr-1"
/>
<label htmlFor="url">sse</label>
</div>
</div>
</div>
{serverType === 'sse' ? (
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="url">
Server URL
</label>
<input
type="url"
name="url"
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"
placeholder="e.g.: http://localhost:3000/sse"
required={serverType === 'sse'}
/>
</div>
) : (
<>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="command">
Command
</label>
<input
type="text"
name="command"
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"
placeholder="e.g.: npx"
required={serverType === 'stdio'}
/>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="arguments">
Arguments
</label>
<input
type="text"
name="arguments"
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"
placeholder="e.g.: -y time-mcp"
required={serverType === 'stdio'}
/>
</div>
<div className="mb-4">
<div className="flex justify-between items-center mb-2">
<label className="block text-gray-700 text-sm font-bold">
Environment Variables
</label>
<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"
>
+ Add
</button>
</div>
{envVars.map((envVar, index) => (
<div key={index} className="flex items-center mb-2">
<div className="flex items-center space-x-2 flex-grow">
<input
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"
placeholder="key"
/>
<span className="flex items-center">:</span>
<input
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"
placeholder="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"
>
- Remove
</button>
</div>
))}
</div>
</>
)}
<div className="flex justify-end mt-6">
<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"
>
Cancel
</button>
<button
type="submit"
className="bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded"
>
{isEdit ? 'Save Changes' : 'Add Server'}
</button>
</div>
</form>
</div>
)
}
export default ServerForm

View File

@@ -0,0 +1,10 @@
import { ChevronDown, ChevronRight } from 'lucide-react'
export { ChevronDown, ChevronRight }
const LucideIcons = {
ChevronDown,
ChevronRight,
}
export default LucideIcons

View File

@@ -0,0 +1,23 @@
import { ServerStatus } from '@/types'
interface BadgeProps {
status: ServerStatus
}
const Badge = ({ status }: BadgeProps) => {
const colors = {
connecting: 'bg-yellow-100 text-yellow-800',
connected: 'bg-green-100 text-green-800',
disconnected: 'bg-red-100 text-red-800',
}
return (
<span
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${colors[status]}`}
>
{status}
</span>
)
}
export default Badge

View File

@@ -0,0 +1,37 @@
interface DeleteDialogProps {
isOpen: boolean
onClose: () => void
onConfirm: () => void
serverName: string
}
const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName }: DeleteDialogProps) => {
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div className="bg-white shadow rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-medium text-gray-900 mb-4">Delete Server</h3>
<p className="text-gray-700">
Are you sure you want to delete server <strong>{serverName}</strong>? This action cannot be undone.
</p>
<div className="flex justify-end mt-6">
<button
onClick={onClose}
className="bg-gray-300 hover:bg-gray-400 text-gray-800 font-medium py-2 px-4 rounded mr-2"
>
Cancel
</button>
<button
onClick={onConfirm}
className="bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded"
>
Delete
</button>
</div>
</div>
</div>
)
}
export default DeleteDialog

View File

@@ -0,0 +1,38 @@
import { useState } from 'react'
import { Tool } from '@/types'
import { ChevronDown, ChevronRight } from '@/components/icons/LucideIcons'
interface ToolCardProps {
tool: Tool
}
const ToolCard = ({ tool }: ToolCardProps) => {
const [isExpanded, setIsExpanded] = useState(false)
return (
<div className="bg-white shadow rounded-lg p-4 mb-4">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<h3 className="text-lg font-medium text-gray-900">{tool.name}</h3>
<button className="text-gray-400 hover:text-gray-600">
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button>
</div>
{isExpanded && (
<div className="mt-4">
<p className="text-gray-600 mb-2">{tool.description || 'No description available'}</p>
<div className="bg-gray-50 rounded p-2">
<h4 className="text-sm font-medium text-gray-900 mb-2">Input Schema:</h4>
<pre className="text-xs text-gray-600 overflow-auto">
{JSON.stringify(tool.inputSchema, null, 2)}
</pre>
</div>
</div>
)}
</div>
)
}
export default ToolCard

20
frontend/src/index.css Normal file
View File

@@ -0,0 +1,20 @@
/* Use standard Tailwind directives */
@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',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.bg-custom-blue {
background-color: #4a90e2;
}
.text-custom-white {
color: #ffffff;
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,55 @@
// 服务器状态类型
export type ServerStatus = 'connecting' | 'connected' | 'disconnected';
// 工具输入模式类型
export interface ToolInputSchema {
type: string;
properties?: Record<string, any>;
required?: string[];
[key: string]: any;
}
// 工具类型
export interface Tool {
name: string;
description?: string;
inputSchema: ToolInputSchema;
}
// 服务器配置类型
export interface ServerConfig {
url?: string;
command?: string;
args?: string[] | string;
env?: Record<string, string>;
}
// 服务器类型
export interface Server {
name: string;
status: ServerStatus;
tools?: Tool[];
config?: ServerConfig;
}
// 环境变量类型
export interface EnvVar {
key: string;
value: string;
}
// 表单数据类型
export interface ServerFormData {
name: string;
url: string;
command: string;
arguments: string;
args: string[];
}
// API响应类型
export interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}

31
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
/* Paths */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

22
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import tailwindcss from '@tailwindcss/vite';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
});