mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
refactor frontend code
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,7 +4,7 @@
|
|||||||
.pnp.js
|
.pnp.js
|
||||||
|
|
||||||
# production
|
# production
|
||||||
/dist
|
dist
|
||||||
|
|
||||||
# env files
|
# env files
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ RUN pnpm install @amap/amap-maps-mcp-server @playwright/mcp@latest tavily-mcp@la
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN pnpm build
|
RUN pnpm frontend:build && pnpm build
|
||||||
|
|
||||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.1 KiB |
13
frontend/index.html
Normal file
13
frontend/index.html
Normal 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>
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
156
frontend/src/App.tsx
Normal file
156
frontend/src/App.tsx
Normal 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
|
||||||
55
frontend/src/components/AddServerForm.tsx
Normal file
55
frontend/src/components/AddServerForm.tsx
Normal 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
|
||||||
44
frontend/src/components/EditServerForm.tsx
Normal file
44
frontend/src/components/EditServerForm.tsx
Normal 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
|
||||||
83
frontend/src/components/ServerCard.tsx
Normal file
83
frontend/src/components/ServerCard.tsx
Normal 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
|
||||||
269
frontend/src/components/ServerForm.tsx
Normal file
269
frontend/src/components/ServerForm.tsx
Normal 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
|
||||||
10
frontend/src/components/icons/LucideIcons.tsx
Normal file
10
frontend/src/components/icons/LucideIcons.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||||
|
|
||||||
|
export { ChevronDown, ChevronRight }
|
||||||
|
|
||||||
|
const LucideIcons = {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LucideIcons
|
||||||
23
frontend/src/components/ui/Badge.tsx
Normal file
23
frontend/src/components/ui/Badge.tsx
Normal 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
|
||||||
37
frontend/src/components/ui/DeleteDialog.tsx
Normal file
37
frontend/src/components/ui/DeleteDialog.tsx
Normal 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
|
||||||
38
frontend/src/components/ui/ToolCard.tsx
Normal file
38
frontend/src/components/ui/ToolCard.tsx
Normal 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
20
frontend/src/index.css
Normal 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
10
frontend/src/main.tsx
Normal 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>,
|
||||||
|
)
|
||||||
55
frontend/src/types/index.ts
Normal file
55
frontend/src/types/index.ts
Normal 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
31
frontend/tsconfig.json
Normal 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" }]
|
||||||
|
}
|
||||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal 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
22
frontend/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
17
package.json
17
package.json
@@ -7,10 +7,14 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"dev": "tsx watch src/index.ts",
|
"backend:dev": "tsx watch src/index.ts",
|
||||||
"lint": "eslint . --ext .ts",
|
"lint": "eslint . --ext .ts",
|
||||||
"format": "prettier --write \"src/**/*.ts\"",
|
"format": "prettier --write \"src/**/*.ts\"",
|
||||||
"test": "jest"
|
"test": "jest",
|
||||||
|
"frontend:dev": "cd frontend && vite",
|
||||||
|
"frontend:build": "cd frontend && vite build",
|
||||||
|
"frontend:preview": "cd frontend && vite preview",
|
||||||
|
"dev": "concurrently \"pnpm backend:dev\" \"pnpm frontend:dev\""
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"typescript",
|
"typescript",
|
||||||
@@ -23,6 +27,7 @@
|
|||||||
"@radix-ui/react-accordion": "^1.2.3",
|
"@radix-ui/react-accordion": "^1.2.3",
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
"@shadcn/ui": "^0.0.4",
|
"@shadcn/ui": "^0.0.4",
|
||||||
|
"@tailwindcss/vite": "^4.1.3",
|
||||||
"@types/react": "^19.0.12",
|
"@types/react": "^19.0.12",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.0.4",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
@@ -41,17 +46,21 @@
|
|||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.1.3",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/jest": "^29.5.5",
|
"@types/jest": "^29.5.5",
|
||||||
"@types/node": "^20.8.2",
|
"@types/node": "^20.8.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
||||||
"@typescript-eslint/parser": "^6.7.4",
|
"@typescript-eslint/parser": "^6.7.4",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"concurrently": "^8.2.2",
|
||||||
"eslint": "^8.50.0",
|
"eslint": "^8.50.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"prettier": "^3.0.3",
|
"prettier": "^3.0.3",
|
||||||
"ts-jest": "^29.1.1",
|
"ts-jest": "^29.1.1",
|
||||||
"ts-node-dev": "^2.0.0",
|
"ts-node-dev": "^2.0.0",
|
||||||
"tsx": "^4.7.0",
|
"tsx": "^4.7.0",
|
||||||
"typescript": "^5.2.2"
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^5.2.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
925
pnpm-lock.yaml
generated
925
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,36 +0,0 @@
|
|||||||
// Reusable confirmation dialog component for server deletion
|
|
||||||
// Props:
|
|
||||||
// - isOpen: boolean - Controls dialog visibility
|
|
||||||
// - onClose: () => void - Handler for dialog dismissal
|
|
||||||
// - onConfirm: () => void - Handler for delete confirmation
|
|
||||||
// - serverName: string - Name of the server to be deleted
|
|
||||||
window.DeleteDialog = function DeleteDialog({ isOpen, onClose, onConfirm, serverName }) {
|
|
||||||
return (
|
|
||||||
<div className={`${isOpen ? 'block' : 'hidden'} fixed inset-0 bg-black bg-opacity-50 z-50`}>
|
|
||||||
<div className="fixed inset-0 flex items-center justify-center">
|
|
||||||
<div className="bg-white rounded-lg p-6 max-w-sm mx-auto">
|
|
||||||
<h3 className="text-lg font-medium leading-6 text-gray-900">Delete Server</h3>
|
|
||||||
<div className="mt-2">
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Are you sure you want to delete server {serverName}? This action cannot be undone.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 flex justify-end space-x-3">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="inline-flex justify-center px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 border border-gray-300 rounded-md hover:bg-gray-200"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onConfirm}
|
|
||||||
className="inline-flex justify-center px-4 py-2 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
// Lightweight implementation of Lucide icons without external dependencies
|
|
||||||
// Each icon component accepts:
|
|
||||||
// - size: number (default: 24) - Icon dimensions in pixels
|
|
||||||
// - className: string - Additional CSS classes
|
|
||||||
|
|
||||||
const ChevronDown = ({ size = 24, className = "" }) => (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width={size}
|
|
||||||
height={size}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className={`lucide lucide-chevron-down ${className}`}
|
|
||||||
>
|
|
||||||
<path d="m6 9 6 6 6-6"/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const ChevronRight = ({ size = 24, className = "" }) => (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width={size}
|
|
||||||
height={size}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className={`lucide lucide-chevron-right ${className}`}
|
|
||||||
>
|
|
||||||
<path d="m9 18 6-6-6-6"/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Export icons to global scope for use in other components
|
|
||||||
window.LucideIcons = {
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight
|
|
||||||
};
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<!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>
|
|
||||||
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
|
||||||
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
|
||||||
<script src="https://unpkg.com/babel-standalone@6/babel.min.js"></script>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-100">
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="text/babel" src="/components/LucideIcons.jsx"></script>
|
|
||||||
<script type="text/babel" src="/components/DeleteDialog.jsx"></script>
|
|
||||||
<script type="text/babel" src="/js/app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
677
public/js/app.js
677
public/js/app.js
@@ -1,677 +0,0 @@
|
|||||||
const { useState, useEffect, Fragment } = React;
|
|
||||||
const { ChevronDown, ChevronRight } = window.LucideIcons || {};
|
|
||||||
|
|
||||||
// Status badge component with predefined color schemes
|
|
||||||
function Badge({ status }) {
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Displays tool details with expandable input schema
|
|
||||||
function ToolCard({ tool }) {
|
|
||||||
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 ? (
|
|
||||||
<ChevronDown size={18} />
|
|
||||||
) : (
|
|
||||||
'▼'
|
|
||||||
)
|
|
||||||
) : ChevronRight ? (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete confirmation dialog component
|
|
||||||
function DeleteDialog({ isOpen, onClose, onConfirm, serverName }) {
|
|
||||||
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">Confirm Deletion</h3>
|
|
||||||
<p className="text-gray-700">
|
|
||||||
Are you sure you want to delete the 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main server card component for displaying server status and available tools
|
|
||||||
function ServerCard({ server, onRemove, onEdit }) {
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
|
||||||
|
|
||||||
const handleRemove = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setShowDeleteDialog(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = (e) => {
|
|
||||||
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 ? (
|
|
||||||
<ChevronDown size={18} />
|
|
||||||
) : (
|
|
||||||
'▼'
|
|
||||||
)
|
|
||||||
) : ChevronRight ? (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Form component for adding/editing MCP servers with stdio or SSE protocol support
|
|
||||||
function ServerForm({ onSubmit, onCancel, initialData = null, modalTitle }) {
|
|
||||||
const [serverType, setServerType] = useState(
|
|
||||||
initialData && initialData.config && initialData.config.url ? 'sse' : 'stdio',
|
|
||||||
);
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
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(
|
|
||||||
initialData && initialData.config && initialData.config.env
|
|
||||||
? Object.entries(initialData.config.env).map(([key, value]) => ({ key, value }))
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
const isEdit = !!initialData;
|
|
||||||
|
|
||||||
const handleInputChange = (e) => {
|
|
||||||
const { name, value } = e.target;
|
|
||||||
setFormData({ ...formData, [name]: value });
|
|
||||||
};
|
|
||||||
|
|
||||||
// Transform space-separated arguments string into array
|
|
||||||
const handleArgsChange = (value) => {
|
|
||||||
let args = value.split(' ').filter((arg) => arg.trim() !== '');
|
|
||||||
setFormData({ ...formData, arguments: value, args });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEnvVarChange = (index, field, value) => {
|
|
||||||
const newEnvVars = [...envVars];
|
|
||||||
newEnvVars[index][field] = value;
|
|
||||||
setEnvVars(newEnvVars);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addEnvVar = () => {
|
|
||||||
setEnvVars([...envVars, { key: '', value: '' }]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeEnvVar = (index) => {
|
|
||||||
const newEnvVars = [...envVars];
|
|
||||||
newEnvVars.splice(index, 1);
|
|
||||||
setEnvVars(newEnvVars);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Submit handler for server configuration
|
|
||||||
const handleSubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const env = {};
|
|
||||||
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.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
) : (
|
|
||||||
<Fragment>
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
- Del
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Fragment>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Form component for adding new MCP servers (wrapper around ServerForm)
|
|
||||||
function AddServerForm({ onAdd }) {
|
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
|
||||||
|
|
||||||
const toggleModal = () => {
|
|
||||||
setModalVisible(!modalVisible);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (payload) => {
|
|
||||||
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.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Form component for editing MCP servers (wrapper around ServerForm)
|
|
||||||
function EditServerForm({ server, onEdit, onCancel }) {
|
|
||||||
const handleSubmit = async (payload) => {
|
|
||||||
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.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Root application component managing server state and UI
|
|
||||||
function App() {
|
|
||||||
const [servers, setServers] = useState([]);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
|
||||||
const [editingServer, setEditingServer] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/api/servers')
|
|
||||||
.then((response) => response.json())
|
|
||||||
.then((data) => {
|
|
||||||
// 处理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.message));
|
|
||||||
|
|
||||||
// Poll for updates every 5 seconds
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
fetch('/api/servers')
|
|
||||||
.then((response) => response.json())
|
|
||||||
.then((data) => {
|
|
||||||
// 处理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.message));
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [refreshKey]);
|
|
||||||
|
|
||||||
const handleServerAdd = () => {
|
|
||||||
setRefreshKey((prevKey) => prevKey + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleServerEdit = (server) => {
|
|
||||||
// Fetch settings to get the full server config before editing
|
|
||||||
fetch(`/api/settings`)
|
|
||||||
.then((response) => response.json())
|
|
||||||
.then((settingsData) => {
|
|
||||||
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.message);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditComplete = () => {
|
|
||||||
setEditingServer(null);
|
|
||||||
setRefreshKey((prevKey) => prevKey + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleServerRemove = async (serverName) => {
|
|
||||||
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.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
||||||
root.render(<App />);
|
|
||||||
@@ -15,7 +15,7 @@ export const errorHandler = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const initMiddlewares = (app: express.Application): void => {
|
export const initMiddlewares = (app: express.Application): void => {
|
||||||
app.use(express.static('public'));
|
app.use(express.static('frontend/dist'));
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
if (req.path !== '/sse' && req.path !== '/messages') {
|
if (req.path !== '/sse' && req.path !== '/messages') {
|
||||||
@@ -26,7 +26,7 @@ export const initMiddlewares = (app: express.Application): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/', (_req: Request, res: Response) => {
|
app.get('/', (_req: Request, res: Response) => {
|
||||||
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
|
res.sendFile(path.join(process.cwd(), 'frontend', 'dist', 'index.html'));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|||||||
Reference in New Issue
Block a user