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:
BIN
frontend/favicon.ico
Normal file
BIN
frontend/favicon.ico
Normal file
Binary file not shown.
|
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user