mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 10:49:35 -05:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d94a58ebca | ||
|
|
6af13f85d4 | ||
|
|
f026e621a5 | ||
|
|
1f17780000 | ||
|
|
f94acb8bef | ||
|
|
51289b8ca8 | ||
|
|
2c4f9d1c24 | ||
|
|
2e1f73ef64 | ||
|
|
40d8792294 | ||
|
|
7f887a7031 | ||
|
|
5a2be4d4fe | ||
|
|
f6c5cde9ce | ||
|
|
1e6d10a0c3 | ||
|
|
1ea8c3c866 | ||
|
|
6b56a9a554 | ||
|
|
a1d2c679d6 | ||
|
|
fe10273fc3 | ||
|
|
649a454ba8 | ||
|
|
bc8be7578f | ||
|
|
c5a8cfd88f | ||
|
|
8905757c69 | ||
|
|
c896fef8ee | ||
|
|
09b8a478b9 |
17
.github/workflows/build.yml
vendored
17
.github/workflows/build.yml
vendored
@@ -2,12 +2,17 @@ name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
tags: ['v*.*.*']
|
||||
schedule:
|
||||
- cron: '0 23 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
variant: ${{ startsWith(github.ref, 'refs/tags/') && fromJSON('["base", "full"]') || fromJSON('["base"]') }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -28,9 +33,10 @@ jobs:
|
||||
with:
|
||||
images: samanhappy/mcphub
|
||||
tags: |
|
||||
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
type=raw,value=edge${{ matrix.variant == 'full' && '-full' || '' }},enable=${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
|
||||
type=semver,pattern={{version}}${{ matrix.variant == 'full' && '-full' || '' }},enable=${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
type=raw,value=latest${{ matrix.variant == 'full' && '-full' || '' }},enable=${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
@@ -40,3 +46,6 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64,linux/arm64
|
||||
build-args: |
|
||||
INSTALL_EXT=${{ matrix.variant == 'full' && 'true' || 'false' }}
|
||||
|
||||
19
Dockerfile
19
Dockerfile
@@ -1,4 +1,4 @@
|
||||
FROM python:3.12-slim-bookworm AS base
|
||||
FROM python:3.13-slim-bookworm AS base
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
|
||||
@@ -12,6 +12,21 @@ RUN npm install -g pnpm
|
||||
ARG REQUEST_TIMEOUT=60000
|
||||
ENV REQUEST_TIMEOUT=$REQUEST_TIMEOUT
|
||||
|
||||
ENV PNPM_HOME=/usr/local/share/pnpm
|
||||
ENV PATH=$PNPM_HOME:$PATH
|
||||
RUN mkdir -p $PNPM_HOME && \
|
||||
pnpm add -g @amap/amap-maps-mcp-server @playwright/mcp@latest tavily-mcp@latest @modelcontextprotocol/server-github @modelcontextprotocol/server-slack
|
||||
|
||||
ARG INSTALL_EXT=false
|
||||
RUN if [ "$INSTALL_EXT" = "true" ]; then \
|
||||
ARCH=$(uname -m); \
|
||||
if [ "$ARCH" = "x86_64" ]; then \
|
||||
npx -y playwright install --with-deps chrome; \
|
||||
else \
|
||||
echo "Skipping Chrome installation on non-amd64 architecture: $ARCH"; \
|
||||
fi; \
|
||||
fi
|
||||
|
||||
RUN uv tool install mcp-server-fetch
|
||||
ENV UV_PYTHON_INSTALL_MIRROR="http://mirrors.aliyun.com/pypi/simple/"
|
||||
|
||||
@@ -20,8 +35,6 @@ WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install
|
||||
|
||||
RUN pnpm install @amap/amap-maps-mcp-server @playwright/mcp@latest tavily-mcp@latest @modelcontextprotocol/server-github @modelcontextprotocol/server-slack
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm frontend:build && pnpm build
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 35 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 36 KiB |
@@ -1,369 +1,36 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { BrowserRouter as Router, Route, Routes, Navigate, useNavigate } from 'react-router-dom'
|
||||
import { Server, ApiResponse } from './types'
|
||||
import ServerCard from './components/ServerCard'
|
||||
import AddServerForm from './components/AddServerForm'
|
||||
import EditServerForm from './components/EditServerForm'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import ChangePasswordPage from './pages/ChangePasswordPage'
|
||||
import ProtectedRoute from './components/ProtectedRoute'
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
|
||||
// 配置选项
|
||||
const CONFIG = {
|
||||
// 初始化启动阶段的配置
|
||||
startup: {
|
||||
maxAttempts: 60, // 初始化阶段最大尝试次数
|
||||
pollingInterval: 3000 // 初始阶段轮询间隔 (3秒)
|
||||
},
|
||||
// 正常运行阶段的配置
|
||||
normal: {
|
||||
pollingInterval: 10000 // 正常运行时的轮询间隔 (10秒)
|
||||
}
|
||||
}
|
||||
|
||||
// Dashboard component that contains the main application
|
||||
const Dashboard = () => {
|
||||
const { t } = useTranslation()
|
||||
const [servers, setServers] = useState<Server[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
const [editingServer, setEditingServer] = useState<Server | null>(null)
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true)
|
||||
const [fetchAttempts, setFetchAttempts] = useState(0)
|
||||
const { auth, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// 轮询定时器引用
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
// 保存当前尝试次数,避免依赖循环
|
||||
const attemptsRef = useRef<number>(0)
|
||||
|
||||
// 清理定时器
|
||||
const clearTimer = () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
// 开始正常轮询
|
||||
const startNormalPolling = useCallback(() => {
|
||||
// 确保没有其他定时器在运行
|
||||
clearTimer()
|
||||
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/servers', {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
});
|
||||
const data = await response.json()
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data)
|
||||
} else if (data && Array.isArray(data)) {
|
||||
setServers(data)
|
||||
} else {
|
||||
console.error('Invalid server data format:', data)
|
||||
setServers([])
|
||||
}
|
||||
|
||||
// 重置错误状态
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error('Error fetching servers during normal polling:', err)
|
||||
|
||||
// 使用友好的错误消息
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'))
|
||||
} else if (err instanceof TypeError && (
|
||||
err.message.includes('NetworkError') ||
|
||||
err.message.includes('Failed to fetch')
|
||||
)) {
|
||||
setError(t('errors.serverConnection'))
|
||||
} else {
|
||||
setError(t('errors.serverFetch'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 立即执行一次
|
||||
fetchServers()
|
||||
|
||||
// 设置定期轮询
|
||||
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval)
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
// 重置尝试计数
|
||||
if (refreshKey > 0) {
|
||||
attemptsRef.current = 0;
|
||||
setFetchAttempts(0);
|
||||
}
|
||||
|
||||
// 初始化加载阶段的请求函数
|
||||
const fetchInitialData = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/servers', {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
});
|
||||
const data = await response.json()
|
||||
|
||||
// 处理API响应中的包装对象,提取data字段
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data)
|
||||
setIsInitialLoading(false)
|
||||
// 初始化成功,开始正常轮询
|
||||
startNormalPolling()
|
||||
return true
|
||||
} else if (data && Array.isArray(data)) {
|
||||
// 兼容性处理,如果API直接返回数组
|
||||
setServers(data)
|
||||
setIsInitialLoading(false)
|
||||
// 初始化成功,开始正常轮询
|
||||
startNormalPolling()
|
||||
return true
|
||||
} else {
|
||||
// 如果数据格式不符合预期,设置为空数组
|
||||
console.error('Invalid server data format:', data)
|
||||
setServers([])
|
||||
setIsInitialLoading(false)
|
||||
// 初始化成功但数据为空,开始正常轮询
|
||||
startNormalPolling()
|
||||
return true
|
||||
}
|
||||
} catch (err) {
|
||||
// 增加尝试次数计数,使用 ref 避免触发 effect 重新运行
|
||||
attemptsRef.current += 1;
|
||||
console.error(`Initial loading attempt ${attemptsRef.current} failed:`, err)
|
||||
|
||||
// 更新状态用于显示
|
||||
setFetchAttempts(attemptsRef.current)
|
||||
|
||||
// 设置适当的错误消息
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'))
|
||||
} else {
|
||||
setError(t('errors.initialStartup'))
|
||||
}
|
||||
|
||||
// 如果已超过最大尝试次数,放弃初始化并切换到正常轮询
|
||||
if (attemptsRef.current >= CONFIG.startup.maxAttempts) {
|
||||
console.log('Maximum startup attempts reached, switching to normal polling')
|
||||
setIsInitialLoading(false)
|
||||
// 清除初始化的轮询
|
||||
clearTimer()
|
||||
// 切换到正常轮询模式
|
||||
startNormalPolling()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时,根据当前状态设置适当的轮询
|
||||
if (isInitialLoading) {
|
||||
// 确保没有其他定时器在运行
|
||||
clearTimer()
|
||||
|
||||
// 立即执行一次初始请求
|
||||
fetchInitialData()
|
||||
|
||||
// 设置初始阶段的轮询间隔
|
||||
intervalRef.current = setInterval(fetchInitialData, CONFIG.startup.pollingInterval)
|
||||
console.log(`Started initial polling with interval: ${CONFIG.startup.pollingInterval}ms`)
|
||||
} else {
|
||||
// 已经初始化完成,开始正常轮询
|
||||
startNormalPolling()
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
clearTimer()
|
||||
}
|
||||
}, [refreshKey, t, isInitialLoading, startNormalPolling])
|
||||
|
||||
// 手动触发刷新
|
||||
const triggerRefresh = () => {
|
||||
// 清除当前的定时器
|
||||
clearTimer()
|
||||
|
||||
// 如果在初始化阶段,重置初始化状态
|
||||
if (isInitialLoading) {
|
||||
setIsInitialLoading(true)
|
||||
attemptsRef.current = 0
|
||||
setFetchAttempts(0)
|
||||
}
|
||||
|
||||
// refreshKey 的改变会触发 useEffect 再次运行
|
||||
setRefreshKey(prevKey => prevKey + 1)
|
||||
}
|
||||
|
||||
const handleServerAdd = () => {
|
||||
setRefreshKey(prevKey => prevKey + 1)
|
||||
}
|
||||
|
||||
const handleServerEdit = (server: Server) => {
|
||||
// Fetch settings to get the full server config before editing
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
fetch(`/api/settings`, {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
})
|
||||
.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(t('server.invalidConfig', { serverName: 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 token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(`/api/servers/${serverName}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
})
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setError(result.message || t('server.deleteError', { serverName }))
|
||||
return
|
||||
}
|
||||
|
||||
setRefreshKey(prevKey => prevKey + 1)
|
||||
} catch (err) {
|
||||
setError(t('errors.general') + ': ' + (err instanceof Error ? err.message : String(err)))
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 p-8">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-red-600 text-lg font-medium">{t('app.error')}</h3>
|
||||
<p className="text-gray-600 mt-1">{error}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="ml-4 text-gray-500 hover:text-gray-700"
|
||||
aria-label={t('app.closeButton')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 111.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">{t('app.title')}</h1>
|
||||
<div className="flex items-center">
|
||||
<AddServerForm onAdd={handleServerAdd} />
|
||||
<button
|
||||
onClick={() => navigate('/change-password')}
|
||||
className="ml-4 bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
|
||||
>
|
||||
{t('app.changePassword')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="ml-4 bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600"
|
||||
>
|
||||
{t('app.logout')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{servers.length === 0 ? (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<p className="text-gray-600">{t('app.noServers')}</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>
|
||||
)
|
||||
}
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import MainLayout from './layouts/MainLayout';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import DashboardPage from './pages/Dashboard';
|
||||
import ServersPage from './pages/ServersPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
{/* 公共路由 */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* 受保护的路由,使用 MainLayout 作为布局容器 */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/change-password" element={<ChangePasswordPage />} />
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/servers" element={<ServersPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* 未匹配的路由重定向到首页 */}
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
@@ -10,12 +10,14 @@ interface ServerCardProps {
|
||||
server: Server
|
||||
onRemove: (serverName: string) => void
|
||||
onEdit: (server: Server) => void
|
||||
onToggle?: (server: Server, enabled: boolean) => void
|
||||
}
|
||||
|
||||
const ServerCard = ({ server, onRemove, onEdit }: ServerCardProps) => {
|
||||
const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isToggling, setIsToggling] = useState(false)
|
||||
|
||||
const handleRemove = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
@@ -27,19 +29,31 @@ const ServerCard = ({ server, onRemove, onEdit }: ServerCardProps) => {
|
||||
onEdit(server)
|
||||
}
|
||||
|
||||
const handleToggle = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (isToggling || !onToggle) return
|
||||
|
||||
setIsToggling(true)
|
||||
try {
|
||||
await onToggle(server, !(server.enabled !== false))
|
||||
} finally {
|
||||
setIsToggling(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
onRemove(server.name)
|
||||
setShowDeleteDialog(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<div className={`bg-white shadow rounded-lg p-6 mb-6 ${server.enabled === false ? 'opacity-60' : ''}`}>
|
||||
<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>
|
||||
<h2 className={`text-xl font-semibold ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'}`}>{server.name}</h2>
|
||||
<Badge status={server.status} />
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
@@ -49,6 +63,26 @@ const ServerCard = ({ server, onRemove, onEdit }: ServerCardProps) => {
|
||||
>
|
||||
{t('server.edit')}
|
||||
</button>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className={`px-3 py-1 text-sm rounded transition-colors ${
|
||||
isToggling
|
||||
? 'bg-gray-200 text-gray-500'
|
||||
: server.enabled !== false
|
||||
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
||||
}`}
|
||||
disabled={isToggling}
|
||||
>
|
||||
{isToggling
|
||||
? t('common.processing')
|
||||
: server.enabled !== false
|
||||
? t('server.disable')
|
||||
: t('server.enable')
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
className="px-3 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm"
|
||||
@@ -70,7 +104,7 @@ const ServerCard = ({ server, onRemove, onEdit }: ServerCardProps) => {
|
||||
|
||||
{isExpanded && server.tools && (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">{t('server.tools')}</h3>
|
||||
<h3 className={`text-lg font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}>{t('server.tools')}</h3>
|
||||
<div className="space-y-4">
|
||||
{server.tools.map((tool, index) => (
|
||||
<ToolCard key={index} tool={tool} />
|
||||
|
||||
17
frontend/src/components/layout/Content.tsx
Normal file
17
frontend/src/components/layout/Content.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
interface ContentProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const Content: React.FC<ContentProps> = ({ children }) => {
|
||||
return (
|
||||
<main className="flex-1 p-6 overflow-auto">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default Content;
|
||||
61
frontend/src/components/layout/Header.tsx
Normal file
61
frontend/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
interface HeaderProps {
|
||||
onToggleSidebar: () => void;
|
||||
}
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { auth, logout } = useAuth();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="bg-white shadow-sm z-10">
|
||||
<div className="flex justify-between items-center px-4 py-3">
|
||||
<div className="flex items-center">
|
||||
{/* 侧边栏切换按钮 */}
|
||||
<button
|
||||
onClick={onToggleSidebar}
|
||||
className="p-2 rounded-md text-gray-500 hover:text-gray-900 hover:bg-gray-100 focus:outline-none"
|
||||
aria-label={t('app.toggleSidebar')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* 应用标题 */}
|
||||
<h1 className="ml-4 text-xl font-bold text-gray-900">{t('app.title')}</h1>
|
||||
</div>
|
||||
|
||||
{/* 用户信息和操作 */}
|
||||
<div className="flex items-center space-x-4">
|
||||
{auth.user && (
|
||||
<span className="text-sm text-gray-700">
|
||||
{t('app.welcomeUser', { username: auth.user.username })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-3 py-1.5 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm"
|
||||
>
|
||||
{t('app.logout')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
80
frontend/src/components/layout/Sidebar.tsx
Normal file
80
frontend/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
path: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
// 菜单项配置
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
path: '/',
|
||||
label: t('nav.dashboard'),
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z" />
|
||||
<path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/servers',
|
||||
label: t('nav.servers'),
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M2 5a2 2 0 012-2h12a2 2 0 012 2v2a2 2 0 01-2 2H4a2 2 0 01-2-2V5zm14 1a1 1 0 11-2 0 1 1 0 012 0zM2 13a2 2 0 012-2h12a2 2 0 012 2v2a2 2 0 01-2 2H4a2 2 0 01-2-2v-2zm14 1a1 1 0 11-2 0 1 1 0 012 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
label: t('nav.settings'),
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`bg-white shadow-sm transition-all duration-300 ease-in-out ${
|
||||
collapsed ? 'w-16' : 'w-64'
|
||||
}`}
|
||||
>
|
||||
<nav className="p-3 space-y-1">
|
||||
{menuItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center px-3 py-2 rounded-md transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`
|
||||
}
|
||||
end={item.path === '/'}
|
||||
>
|
||||
<span className="flex-shrink-0">{item.icon}</span>
|
||||
{!collapsed && <span className="ml-3">{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
306
frontend/src/hooks/useServerData.ts
Normal file
306
frontend/src/hooks/useServerData.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server, ApiResponse } from '@/types';
|
||||
|
||||
// 配置选项
|
||||
const CONFIG = {
|
||||
// 初始化启动阶段的配置
|
||||
startup: {
|
||||
maxAttempts: 60, // 初始化阶段最大尝试次数
|
||||
pollingInterval: 3000 // 初始阶段轮询间隔 (3秒)
|
||||
},
|
||||
// 正常运行阶段的配置
|
||||
normal: {
|
||||
pollingInterval: 10000 // 正常运行时的轮询间隔 (10秒)
|
||||
}
|
||||
};
|
||||
|
||||
export const useServerData = () => {
|
||||
const { t } = useTranslation();
|
||||
const [servers, setServers] = useState<Server[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const [fetchAttempts, setFetchAttempts] = useState(0);
|
||||
|
||||
// 轮询定时器引用
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
// 保存当前尝试次数,避免依赖循环
|
||||
const attemptsRef = useRef<number>(0);
|
||||
|
||||
// 清理定时器
|
||||
const clearTimer = () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 开始正常轮询
|
||||
const startNormalPolling = useCallback(() => {
|
||||
// 确保没有其他定时器在运行
|
||||
clearTimer();
|
||||
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/servers', {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
} else if (data && Array.isArray(data)) {
|
||||
setServers(data);
|
||||
} else {
|
||||
console.error('Invalid server data format:', data);
|
||||
setServers([]);
|
||||
}
|
||||
|
||||
// 重置错误状态
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching servers during normal polling:', err);
|
||||
|
||||
// 使用友好的错误消息
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else if (err instanceof TypeError && (
|
||||
err.message.includes('NetworkError') ||
|
||||
err.message.includes('Failed to fetch')
|
||||
)) {
|
||||
setError(t('errors.serverConnection'));
|
||||
} else {
|
||||
setError(t('errors.serverFetch'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 立即执行一次
|
||||
fetchServers();
|
||||
|
||||
// 设置定期轮询
|
||||
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
// 重置尝试计数
|
||||
if (refreshKey > 0) {
|
||||
attemptsRef.current = 0;
|
||||
setFetchAttempts(0);
|
||||
}
|
||||
|
||||
// 初始化加载阶段的请求函数
|
||||
const fetchInitialData = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/servers', {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
// 处理API响应中的包装对象,提取data字段
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
setIsInitialLoading(false);
|
||||
// 初始化成功,开始正常轮询
|
||||
startNormalPolling();
|
||||
return true;
|
||||
} else if (data && Array.isArray(data)) {
|
||||
// 兼容性处理,如果API直接返回数组
|
||||
setServers(data);
|
||||
setIsInitialLoading(false);
|
||||
// 初始化成功,开始正常轮询
|
||||
startNormalPolling();
|
||||
return true;
|
||||
} else {
|
||||
// 如果数据格式不符合预期,设置为空数组
|
||||
console.error('Invalid server data format:', data);
|
||||
setServers([]);
|
||||
setIsInitialLoading(false);
|
||||
// 初始化成功但数据为空,开始正常轮询
|
||||
startNormalPolling();
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
// 增加尝试次数计数,使用 ref 避免触发 effect 重新运行
|
||||
attemptsRef.current += 1;
|
||||
console.error(`Initial loading attempt ${attemptsRef.current} failed:`, err);
|
||||
|
||||
// 更新状态用于显示
|
||||
setFetchAttempts(attemptsRef.current);
|
||||
|
||||
// 设置适当的错误消息
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else {
|
||||
setError(t('errors.initialStartup'));
|
||||
}
|
||||
|
||||
// 如果已超过最大尝试次数,放弃初始化并切换到正常轮询
|
||||
if (attemptsRef.current >= CONFIG.startup.maxAttempts) {
|
||||
console.log('Maximum startup attempts reached, switching to normal polling');
|
||||
setIsInitialLoading(false);
|
||||
// 清除初始化的轮询
|
||||
clearTimer();
|
||||
// 切换到正常轮询模式
|
||||
startNormalPolling();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 组件挂载时,根据当前状态设置适当的轮询
|
||||
if (isInitialLoading) {
|
||||
// 确保没有其他定时器在运行
|
||||
clearTimer();
|
||||
|
||||
// 立即执行一次初始请求
|
||||
fetchInitialData();
|
||||
|
||||
// 设置初始阶段的轮询间隔
|
||||
intervalRef.current = setInterval(fetchInitialData, CONFIG.startup.pollingInterval);
|
||||
console.log(`Started initial polling with interval: ${CONFIG.startup.pollingInterval}ms`);
|
||||
} else {
|
||||
// 已经初始化完成,开始正常轮询
|
||||
startNormalPolling();
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
clearTimer();
|
||||
};
|
||||
}, [refreshKey, t, isInitialLoading, startNormalPolling]);
|
||||
|
||||
// 手动触发刷新
|
||||
const triggerRefresh = () => {
|
||||
// 清除当前的定时器
|
||||
clearTimer();
|
||||
|
||||
// 如果在初始化阶段,重置初始化状态
|
||||
if (isInitialLoading) {
|
||||
setIsInitialLoading(true);
|
||||
attemptsRef.current = 0;
|
||||
setFetchAttempts(0);
|
||||
}
|
||||
|
||||
// refreshKey 的改变会触发 useEffect 再次运行
|
||||
setRefreshKey(prevKey => prevKey + 1);
|
||||
};
|
||||
|
||||
// 服务器相关操作
|
||||
const handleServerAdd = () => {
|
||||
setRefreshKey(prevKey => prevKey + 1);
|
||||
};
|
||||
|
||||
const handleServerEdit = async (server: Server) => {
|
||||
try {
|
||||
// Fetch settings to get the full server config before editing
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(`/api/settings`, {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
});
|
||||
|
||||
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> = await response.json();
|
||||
|
||||
if (
|
||||
settingsData &&
|
||||
settingsData.success &&
|
||||
settingsData.data &&
|
||||
settingsData.data.mcpServers &&
|
||||
settingsData.data.mcpServers[server.name]
|
||||
) {
|
||||
const serverConfig = settingsData.data.mcpServers[server.name];
|
||||
return {
|
||||
name: server.name,
|
||||
status: server.status,
|
||||
tools: server.tools || [],
|
||||
config: serverConfig,
|
||||
};
|
||||
} else {
|
||||
console.error('Failed to get server config from settings:', settingsData);
|
||||
setError(t('server.invalidConfig', { serverName: server.name }));
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching server settings:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerRemove = async (serverName: string) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(`/api/servers/${serverName}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(result.message || t('server.deleteError', { serverName }));
|
||||
return false;
|
||||
}
|
||||
|
||||
setRefreshKey(prevKey => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(t('errors.general') + ': ' + (err instanceof Error ? err.message : String(err)));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerToggle = async (server: Server, enabled: boolean) => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(`/api/servers/${server.name}/toggle`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || ''
|
||||
},
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to toggle server:', result);
|
||||
setError(t('server.toggleError', { serverName: server.name }));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update the UI immediately to reflect the change
|
||||
setRefreshKey(prevKey => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error toggling server:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
servers,
|
||||
error,
|
||||
setError,
|
||||
isLoading: isInitialLoading,
|
||||
fetchAttempts,
|
||||
triggerRefresh,
|
||||
handleServerAdd,
|
||||
handleServerEdit,
|
||||
handleServerRemove,
|
||||
handleServerToggle
|
||||
};
|
||||
};
|
||||
33
frontend/src/layouts/MainLayout.tsx
Normal file
33
frontend/src/layouts/MainLayout.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Header from '@/components/layout/Header';
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
import Content from '@/components/layout/Content';
|
||||
|
||||
const MainLayout: React.FC = () => {
|
||||
// 控制侧边栏展开/折叠状态
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false);
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setSidebarCollapsed(!sidebarCollapsed);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-gray-100">
|
||||
{/* 顶部导航 */}
|
||||
<Header onToggleSidebar={toggleSidebar} />
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* 侧边导航 */}
|
||||
<Sidebar collapsed={sidebarCollapsed} />
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<Content>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainLayout;
|
||||
@@ -7,7 +7,9 @@
|
||||
"loading": "Loading...",
|
||||
"logout": "Logout",
|
||||
"profile": "Profile",
|
||||
"changePassword": "Change Password"
|
||||
"changePassword": "Change Password",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"welcomeUser": "Welcome, {{username}}"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
@@ -51,7 +53,14 @@
|
||||
"envVars": "Environment Variables",
|
||||
"key": "key",
|
||||
"value": "value",
|
||||
"remove": "Remove"
|
||||
"enabled": "Enabled",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"remove": "Remove",
|
||||
"toggleError": "Failed to toggle server {{serverName}}",
|
||||
"alreadyExists": "Server {{serverName}} already exists",
|
||||
"invalidData": "Invalid server data provided",
|
||||
"notFound": "Server {{serverName}} not found"
|
||||
},
|
||||
"status": {
|
||||
"online": "Online",
|
||||
@@ -68,7 +77,32 @@
|
||||
"initialStartup": "The server might be starting up. Please wait a moment as this process can take some time on first launch..."
|
||||
},
|
||||
"common": {
|
||||
"processing": "Processing...",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel"
|
||||
"cancel": "Cancel",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"servers": "Servers",
|
||||
"settings": "Settings",
|
||||
"changePassword": "Change Password"
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"totalServers": "Total Servers",
|
||||
"onlineServers": "Online Servers",
|
||||
"offlineServers": "Offline Servers",
|
||||
"connectingServers": "Connecting Servers",
|
||||
"recentServers": "Recent Servers"
|
||||
},
|
||||
"servers": {
|
||||
"title": "Servers Management"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"language": "Language"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,9 @@
|
||||
"loading": "加载中...",
|
||||
"logout": "退出登录",
|
||||
"profile": "个人资料",
|
||||
"changePassword": "修改密码"
|
||||
"changePassword": "修改密码",
|
||||
"toggleSidebar": "切换侧边栏",
|
||||
"welcomeUser": "欢迎, {{username}}"
|
||||
},
|
||||
"auth": {
|
||||
"login": "登录",
|
||||
@@ -51,7 +53,14 @@
|
||||
"envVars": "环境变量",
|
||||
"key": "键",
|
||||
"value": "值",
|
||||
"remove": "移除"
|
||||
"enabled": "已启用",
|
||||
"enable": "启用",
|
||||
"disable": "禁用",
|
||||
"remove": "移除",
|
||||
"toggleError": "切换服务器 {{serverName}} 状态失败",
|
||||
"alreadyExists": "服务器 {{serverName}} 已经存在",
|
||||
"invalidData": "提供的服务器数据无效",
|
||||
"notFound": "找不到服务器 {{serverName}}"
|
||||
},
|
||||
"status": {
|
||||
"online": "在线",
|
||||
@@ -68,7 +77,32 @@
|
||||
"initialStartup": "服务器可能正在启动中。首次启动可能需要一些时间,请耐心等候..."
|
||||
},
|
||||
"common": {
|
||||
"processing": "处理中...",
|
||||
"save": "保存",
|
||||
"cancel": "取消"
|
||||
"cancel": "取消",
|
||||
"refresh": "刷新"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "仪表盘",
|
||||
"servers": "服务器",
|
||||
"settings": "设置",
|
||||
"changePassword": "修改密码"
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"title": "仪表盘",
|
||||
"totalServers": "服务器总数",
|
||||
"onlineServers": "在线服务器",
|
||||
"offlineServers": "离线服务器",
|
||||
"connectingServers": "连接中服务",
|
||||
"recentServers": "最近的服务器"
|
||||
},
|
||||
"servers": {
|
||||
"title": "服务器管理"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
"language": "语言"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ChangePasswordForm from '../components/ChangePasswordForm';
|
||||
|
||||
const ChangePasswordPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSuccess = () => {
|
||||
setTimeout(() => {
|
||||
navigate('/');
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-md mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-6">{t('auth.changePassword')}</h1>
|
||||
<ChangePasswordForm onSuccess={handleSuccess} onCancel={handleCancel} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangePasswordPage;
|
||||
206
frontend/src/pages/Dashboard.tsx
Normal file
206
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useServerData } from '@/hooks/useServerData';
|
||||
import { ServerStatus } from '@/types';
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { servers, error, setError, isLoading } = useServerData();
|
||||
|
||||
// 计算服务器统计信息
|
||||
const serverStats = {
|
||||
total: servers.length,
|
||||
online: servers.filter(server => server.status === 'connected').length,
|
||||
offline: servers.filter(server => server.status === 'disconnected').length,
|
||||
connecting: servers.filter(server => server.status === 'connecting').length
|
||||
};
|
||||
|
||||
// Map status to translation keys
|
||||
const statusTranslations = {
|
||||
connected: 'status.online',
|
||||
disconnected: 'status.offline',
|
||||
connecting: 'status.connecting'
|
||||
}
|
||||
|
||||
// 计算各状态百分比(用于仪表板展示)
|
||||
const getStatusPercentage = (status: ServerStatus) => {
|
||||
if (servers.length === 0) return 0;
|
||||
return Math.round((servers.filter(server => server.status === status).length / servers.length) * 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-8">{t('pages.dashboard.title')}</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-red-600 text-lg font-medium">{t('app.error')}</h3>
|
||||
<p className="text-gray-600 mt-1">{error}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="ml-4 text-gray-500 hover:text-gray-700"
|
||||
aria-label={t('app.closeButton')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 111.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p className="text-gray-600">{t('app.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{/* 服务器总数 */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-blue-100 text-blue-800">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.totalServers')}</h2>
|
||||
<p className="text-3xl font-bold text-gray-900">{serverStats.total}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 在线服务器 */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-green-100 text-green-800">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.onlineServers')}</h2>
|
||||
<p className="text-3xl font-bold text-gray-900">{serverStats.online}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 h-2 bg-gray-200 rounded-full">
|
||||
<div
|
||||
className="h-full bg-green-500 rounded-full"
|
||||
style={{ width: `${getStatusPercentage('connected')}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 离线服务器 */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-red-100 text-red-800">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.offlineServers')}</h2>
|
||||
<p className="text-3xl font-bold text-gray-900">{serverStats.offline}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 h-2 bg-gray-200 rounded-full">
|
||||
<div
|
||||
className="h-full bg-red-500 rounded-full"
|
||||
style={{ width: `${getStatusPercentage('disconnected')}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 连接中服务器 */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-yellow-100 text-yellow-800">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.connectingServers')}</h2>
|
||||
<p className="text-3xl font-bold text-gray-900">{serverStats.connecting}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 h-2 bg-gray-200 rounded-full">
|
||||
<div
|
||||
className="h-full bg-yellow-500 rounded-full"
|
||||
style={{ width: `${getStatusPercentage('connecting')}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 最近活动列表 */}
|
||||
{servers.length > 0 && !isLoading && (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">{t('pages.dashboard.recentServers')}</h2>
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('server.name')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('server.status')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('server.tools')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('server.enabled')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{servers.slice(0, 5).map((server, index) => (
|
||||
<tr key={index}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{server.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${server.status === 'connected'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: server.status === 'disconnected'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{t(statusTranslations[server.status] || server.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{server.tools?.length || 0}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{server.enabled !== false ? (
|
||||
<span className="text-green-600">✓</span>
|
||||
) : (
|
||||
<span className="text-red-600">✗</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
111
frontend/src/pages/ServersPage.tsx
Normal file
111
frontend/src/pages/ServersPage.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server } from '@/types';
|
||||
import ServerCard from '@/components/ServerCard';
|
||||
import AddServerForm from '@/components/AddServerForm';
|
||||
import EditServerForm from '@/components/EditServerForm';
|
||||
import { useServerData } from '@/hooks/useServerData';
|
||||
|
||||
const ServersPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
servers,
|
||||
error,
|
||||
setError,
|
||||
isLoading,
|
||||
handleServerAdd,
|
||||
handleServerEdit,
|
||||
handleServerRemove,
|
||||
handleServerToggle
|
||||
} = useServerData();
|
||||
const [editingServer, setEditingServer] = useState<Server | null>(null);
|
||||
|
||||
const handleEditClick = async (server: Server) => {
|
||||
const fullServerData = await handleServerEdit(server);
|
||||
if (fullServerData) {
|
||||
setEditingServer(fullServerData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditComplete = () => {
|
||||
setEditingServer(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">{t('pages.servers.title')}</h1>
|
||||
<div className="flex space-x-4">
|
||||
<AddServerForm onAdd={handleServerAdd} />
|
||||
<button
|
||||
onClick={() => handleServerAdd()}
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{t('common.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-red-600 text-lg font-medium">{t('app.error')}</h3>
|
||||
<p className="text-gray-600 mt-1">{error}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="ml-4 text-gray-500 hover:text-gray-700"
|
||||
aria-label={t('app.closeButton')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 111.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p className="text-gray-600">{t('app.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<p className="text-gray-600">{t('app.noServers')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{servers.map((server, index) => (
|
||||
<ServerCard
|
||||
key={index}
|
||||
server={server}
|
||||
onRemove={handleServerRemove}
|
||||
onEdit={handleEditClick}
|
||||
onToggle={handleServerToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingServer && (
|
||||
<EditServerForm
|
||||
server={editingServer}
|
||||
onEdit={handleEditComplete}
|
||||
onCancel={() => setEditingServer(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServersPage;
|
||||
55
frontend/src/pages/SettingsPage.tsx
Normal file
55
frontend/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ChangePasswordForm from '@/components/ChangePasswordForm';
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handlePasswordChangeSuccess = () => {
|
||||
setTimeout(() => {
|
||||
navigate('/');
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-8">{t('pages.settings.title')}</h1>
|
||||
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('auth.changePassword')}</h2>
|
||||
<div className="max-w-lg">
|
||||
<ChangePasswordForm onSuccess={handlePasswordChangeSuccess} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 其他设置可以在这里添加 */}
|
||||
<div className="bg-white shadow rounded-lg p-6 mt-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('pages.settings.language')}</h2>
|
||||
<div className="flex space-x-4">
|
||||
<button
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200"
|
||||
onClick={() => {
|
||||
localStorage.setItem('i18nextLng', 'en');
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
English
|
||||
</button>
|
||||
<button
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200"
|
||||
onClick={() => {
|
||||
localStorage.setItem('i18nextLng', 'zh');
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
中文
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
||||
@@ -30,6 +30,7 @@ export interface Server {
|
||||
status: ServerStatus;
|
||||
tools?: Tool[];
|
||||
config?: ServerConfig;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
// 环境变量类型
|
||||
|
||||
@@ -70,6 +70,6 @@
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.2.6"
|
||||
"vite": "^5.4.18"
|
||||
}
|
||||
}
|
||||
190
pnpm-lock.yaml
generated
190
pnpm-lock.yaml
generated
@@ -22,7 +22,7 @@ importers:
|
||||
version: 0.0.4
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.3
|
||||
version: 4.1.3(vite@5.4.17(@types/node@20.17.28)(lightningcss@1.29.2))
|
||||
version: 4.1.3(vite@5.4.18(@types/node@20.17.28)(lightningcss@1.29.2))
|
||||
'@types/react':
|
||||
specifier: ^19.0.12
|
||||
version: 19.0.12
|
||||
@@ -119,7 +119,7 @@ importers:
|
||||
version: 6.21.0(eslint@8.57.1)(typescript@5.8.2)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^4.2.1
|
||||
version: 4.3.4(vite@5.4.17(@types/node@20.17.28)(lightningcss@1.29.2))
|
||||
version: 4.3.4(vite@5.4.18(@types/node@20.17.28)(lightningcss@1.29.2))
|
||||
concurrently:
|
||||
specifier: ^8.2.2
|
||||
version: 8.2.2
|
||||
@@ -145,8 +145,8 @@ importers:
|
||||
specifier: ^5.2.2
|
||||
version: 5.8.2
|
||||
vite:
|
||||
specifier: ^5.2.6
|
||||
version: 5.4.17(@types/node@20.17.28)(lightningcss@1.29.2)
|
||||
specifier: ^5.4.18
|
||||
version: 5.4.18(@types/node@20.17.28)(lightningcss@1.29.2)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -1068,103 +1068,103 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.39.0':
|
||||
resolution: {integrity: sha512-lGVys55Qb00Wvh8DMAocp5kIcaNzEFTmGhfFd88LfaogYTRKrdxgtlO5H6S49v2Nd8R2C6wLOal0qv6/kCkOwA==}
|
||||
'@rollup/rollup-android-arm-eabi@4.40.0':
|
||||
resolution: {integrity: sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg==}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-android-arm64@4.39.0':
|
||||
resolution: {integrity: sha512-It9+M1zE31KWfqh/0cJLrrsCPiF72PoJjIChLX+rEcujVRCb4NLQ5QzFkzIZW8Kn8FTbvGQBY5TkKBau3S8cCQ==}
|
||||
'@rollup/rollup-android-arm64@4.40.0':
|
||||
resolution: {integrity: sha512-PPA6aEEsTPRz+/4xxAmaoWDqh67N7wFbgFUJGMnanCFs0TV99M0M8QhhaSCks+n6EbQoFvLQgYOGXxlMGQe/6w==}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.39.0':
|
||||
resolution: {integrity: sha512-lXQnhpFDOKDXiGxsU9/l8UEGGM65comrQuZ+lDcGUx+9YQ9dKpF3rSEGepyeR5AHZ0b5RgiligsBhWZfSSQh8Q==}
|
||||
'@rollup/rollup-darwin-arm64@4.40.0':
|
||||
resolution: {integrity: sha512-GwYOcOakYHdfnjjKwqpTGgn5a6cUX7+Ra2HeNj/GdXvO2VJOOXCiYYlRFU4CubFM67EhbmzLOmACKEfvp3J1kQ==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.39.0':
|
||||
resolution: {integrity: sha512-mKXpNZLvtEbgu6WCkNij7CGycdw9cJi2k9v0noMb++Vab12GZjFgUXD69ilAbBh034Zwn95c2PNSz9xM7KYEAQ==}
|
||||
'@rollup/rollup-darwin-x64@4.40.0':
|
||||
resolution: {integrity: sha512-CoLEGJ+2eheqD9KBSxmma6ld01czS52Iw0e2qMZNpPDlf7Z9mj8xmMemxEucinev4LgHalDPczMyxzbq+Q+EtA==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.39.0':
|
||||
resolution: {integrity: sha512-jivRRlh2Lod/KvDZx2zUR+I4iBfHcu2V/BA2vasUtdtTN2Uk3jfcZczLa81ESHZHPHy4ih3T/W5rPFZ/hX7RtQ==}
|
||||
'@rollup/rollup-freebsd-arm64@4.40.0':
|
||||
resolution: {integrity: sha512-r7yGiS4HN/kibvESzmrOB/PxKMhPTlz+FcGvoUIKYoTyGd5toHp48g1uZy1o1xQvybwwpqpe010JrcGG2s5nkg==}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.39.0':
|
||||
resolution: {integrity: sha512-8RXIWvYIRK9nO+bhVz8DwLBepcptw633gv/QT4015CpJ0Ht8punmoHU/DuEd3iw9Hr8UwUV+t+VNNuZIWYeY7Q==}
|
||||
'@rollup/rollup-freebsd-x64@4.40.0':
|
||||
resolution: {integrity: sha512-mVDxzlf0oLzV3oZOr0SMJ0lSDd3xC4CmnWJ8Val8isp9jRGl5Dq//LLDSPFrasS7pSm6m5xAcKaw3sHXhBjoRw==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.39.0':
|
||||
resolution: {integrity: sha512-mz5POx5Zu58f2xAG5RaRRhp3IZDK7zXGk5sdEDj4o96HeaXhlUwmLFzNlc4hCQi5sGdR12VDgEUqVSHer0lI9g==}
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.40.0':
|
||||
resolution: {integrity: sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.39.0':
|
||||
resolution: {integrity: sha512-+YDwhM6gUAyakl0CD+bMFpdmwIoRDzZYaTWV3SDRBGkMU/VpIBYXXEvkEcTagw/7VVkL2vA29zU4UVy1mP0/Yw==}
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.40.0':
|
||||
resolution: {integrity: sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.39.0':
|
||||
resolution: {integrity: sha512-EKf7iF7aK36eEChvlgxGnk7pdJfzfQbNvGV/+l98iiMwU23MwvmV0Ty3pJ0p5WQfm3JRHOytSIqD9LB7Bq7xdQ==}
|
||||
'@rollup/rollup-linux-arm64-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.39.0':
|
||||
resolution: {integrity: sha512-vYanR6MtqC7Z2SNr8gzVnzUul09Wi1kZqJaek3KcIlI/wq5Xtq4ZPIZ0Mr/st/sv/NnaPwy/D4yXg5x0B3aUUA==}
|
||||
'@rollup/rollup-linux-arm64-musl@4.40.0':
|
||||
resolution: {integrity: sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.39.0':
|
||||
resolution: {integrity: sha512-NMRUT40+h0FBa5fb+cpxtZoGAggRem16ocVKIv5gDB5uLDgBIwrIsXlGqYbLwW8YyO3WVTk1FkFDjMETYlDqiw==}
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.39.0':
|
||||
resolution: {integrity: sha512-0pCNnmxgduJ3YRt+D+kJ6Ai/r+TaePu9ZLENl+ZDV/CdVczXl95CbIiwwswu4L+K7uOIGf6tMo2vm8uadRaICQ==}
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.39.0':
|
||||
resolution: {integrity: sha512-t7j5Zhr7S4bBtksT73bO6c3Qa2AV/HqiGlj9+KB3gNF5upcVkx+HLgxTm8DK4OkzsOYqbdqbLKwvGMhylJCPhQ==}
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.39.0':
|
||||
resolution: {integrity: sha512-m6cwI86IvQ7M93MQ2RF5SP8tUjD39Y7rjb1qjHgYh28uAPVU8+k/xYWvxRO3/tBN2pZkSMa5RjnPuUIbrwVxeA==}
|
||||
'@rollup/rollup-linux-riscv64-musl@4.40.0':
|
||||
resolution: {integrity: sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.39.0':
|
||||
resolution: {integrity: sha512-iRDJd2ebMunnk2rsSBYlsptCyuINvxUfGwOUldjv5M4tpa93K8tFMeYGpNk2+Nxl+OBJnBzy2/JCscGeO507kA==}
|
||||
'@rollup/rollup-linux-s390x-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.39.0':
|
||||
resolution: {integrity: sha512-t9jqYw27R6Lx0XKfEFe5vUeEJ5pF3SGIM6gTfONSMb7DuG6z6wfj2yjcoZxHg129veTqU7+wOhY6GX8wmf90dA==}
|
||||
'@rollup/rollup-linux-x64-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.39.0':
|
||||
resolution: {integrity: sha512-ThFdkrFDP55AIsIZDKSBWEt/JcWlCzydbZHinZ0F/r1h83qbGeenCt/G/wG2O0reuENDD2tawfAj2s8VK7Bugg==}
|
||||
'@rollup/rollup-linux-x64-musl@4.40.0':
|
||||
resolution: {integrity: sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.39.0':
|
||||
resolution: {integrity: sha512-jDrLm6yUtbOg2TYB3sBF3acUnAwsIksEYjLeHL+TJv9jg+TmTwdyjnDex27jqEMakNKf3RwwPahDIt7QXCSqRQ==}
|
||||
'@rollup/rollup-win32-arm64-msvc@4.40.0':
|
||||
resolution: {integrity: sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.39.0':
|
||||
resolution: {integrity: sha512-6w9uMuza+LbLCVoNKL5FSLE7yvYkq9laSd09bwS0tMjkwXrmib/4KmoJcrKhLWHvw19mwU+33ndC69T7weNNjQ==}
|
||||
'@rollup/rollup-win32-ia32-msvc@4.40.0':
|
||||
resolution: {integrity: sha512-+m03kvI2f5syIqHXCZLPVYplP8pQch9JHyXKZ3AGMKlg8dCyr2PKHjwRLiW53LTrN/Nc3EqHOKxUxzoSPdKddA==}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.39.0':
|
||||
resolution: {integrity: sha512-yAkUOkIKZlK5dl7u6dg897doBgLXmUHhIINM2c+sND3DZwnrdQkkSiDh7N75Ll4mM4dxSkYfXqU9fW3lLkMFug==}
|
||||
'@rollup/rollup-win32-x64-msvc@4.40.0':
|
||||
resolution: {integrity: sha512-lpPE1cLfP5oPzVjKMx10pgBmKELQnFJXHgvtHCtuJWOv8MxqdEIMNtgHgBFf7Ea2/7EuVwa9fodWUfXAlXZLZQ==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
@@ -3100,8 +3100,8 @@ packages:
|
||||
deprecated: Rimraf versions prior to v4 are no longer supported
|
||||
hasBin: true
|
||||
|
||||
rollup@4.39.0:
|
||||
resolution: {integrity: sha512-thI8kNc02yNvnmJp8dr3fNWJ9tCONDhp6TV35X6HkKGGs9E6q7YWCHbe5vKiTa7TAiNcFEmXKj3X/pG2b3ci0g==}
|
||||
rollup@4.40.0:
|
||||
resolution: {integrity: sha512-Noe455xmA96nnqH5piFtLobsGbCij7Tu+tb3c1vYjNbTkfzGqXqQXG3wJaYXkRZuQ0vEYN4bhwg7QnIrqB5B+w==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -3488,8 +3488,8 @@ packages:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
vite@5.4.17:
|
||||
resolution: {integrity: sha512-5+VqZryDj4wgCs55o9Lp+p8GE78TLVg0lasCH5xFZ4jacZjtqZa6JUw9/p0WeAojaOfncSM6v77InkFPGnvPvg==}
|
||||
vite@5.4.18:
|
||||
resolution: {integrity: sha512-1oDcnEp3lVyHCuQ2YFelM4Alm2o91xNoMncRm1U7S+JdYfYOvbiGZ3/CxGttrOu2M/KcGz7cRC2DoNUA6urmMA==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -4425,64 +4425,64 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.0.12
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.39.0':
|
||||
'@rollup/rollup-android-arm-eabi@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-android-arm64@4.39.0':
|
||||
'@rollup/rollup-android-arm64@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.39.0':
|
||||
'@rollup/rollup-darwin-arm64@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.39.0':
|
||||
'@rollup/rollup-darwin-x64@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.39.0':
|
||||
'@rollup/rollup-freebsd-arm64@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.39.0':
|
||||
'@rollup/rollup-freebsd-x64@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.39.0':
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.39.0':
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.39.0':
|
||||
'@rollup/rollup-linux-arm64-gnu@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.39.0':
|
||||
'@rollup/rollup-linux-arm64-musl@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.39.0':
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.39.0':
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.39.0':
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.39.0':
|
||||
'@rollup/rollup-linux-riscv64-musl@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.39.0':
|
||||
'@rollup/rollup-linux-s390x-gnu@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.39.0':
|
||||
'@rollup/rollup-linux-x64-gnu@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.39.0':
|
||||
'@rollup/rollup-linux-x64-musl@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.39.0':
|
||||
'@rollup/rollup-win32-arm64-msvc@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.39.0':
|
||||
'@rollup/rollup-win32-ia32-msvc@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.39.0':
|
||||
'@rollup/rollup-win32-x64-msvc@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@shadcn/ui@0.0.4':
|
||||
@@ -4574,12 +4574,12 @@ snapshots:
|
||||
postcss: 8.5.3
|
||||
tailwindcss: 4.1.3
|
||||
|
||||
'@tailwindcss/vite@4.1.3(vite@5.4.17(@types/node@20.17.28)(lightningcss@1.29.2))':
|
||||
'@tailwindcss/vite@4.1.3(vite@5.4.18(@types/node@20.17.28)(lightningcss@1.29.2))':
|
||||
dependencies:
|
||||
'@tailwindcss/node': 4.1.3
|
||||
'@tailwindcss/oxide': 4.1.3
|
||||
tailwindcss: 4.1.3
|
||||
vite: 5.4.17(@types/node@20.17.28)(lightningcss@1.29.2)
|
||||
vite: 5.4.18(@types/node@20.17.28)(lightningcss@1.29.2)
|
||||
|
||||
'@tsconfig/node10@1.0.11': {}
|
||||
|
||||
@@ -4802,14 +4802,14 @@ snapshots:
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
|
||||
'@vitejs/plugin-react@4.3.4(vite@5.4.17(@types/node@20.17.28)(lightningcss@1.29.2))':
|
||||
'@vitejs/plugin-react@4.3.4(vite@5.4.18(@types/node@20.17.28)(lightningcss@1.29.2))':
|
||||
dependencies:
|
||||
'@babel/core': 7.26.10
|
||||
'@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.10)
|
||||
'@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.10)
|
||||
'@types/babel__core': 7.20.5
|
||||
react-refresh: 0.14.2
|
||||
vite: 5.4.17(@types/node@20.17.28)(lightningcss@1.29.2)
|
||||
vite: 5.4.18(@types/node@20.17.28)(lightningcss@1.29.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -6701,30 +6701,30 @@ snapshots:
|
||||
dependencies:
|
||||
glob: 7.2.3
|
||||
|
||||
rollup@4.39.0:
|
||||
rollup@4.40.0:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.7
|
||||
optionalDependencies:
|
||||
'@rollup/rollup-android-arm-eabi': 4.39.0
|
||||
'@rollup/rollup-android-arm64': 4.39.0
|
||||
'@rollup/rollup-darwin-arm64': 4.39.0
|
||||
'@rollup/rollup-darwin-x64': 4.39.0
|
||||
'@rollup/rollup-freebsd-arm64': 4.39.0
|
||||
'@rollup/rollup-freebsd-x64': 4.39.0
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.39.0
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.39.0
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.39.0
|
||||
'@rollup/rollup-linux-arm64-musl': 4.39.0
|
||||
'@rollup/rollup-linux-loongarch64-gnu': 4.39.0
|
||||
'@rollup/rollup-linux-powerpc64le-gnu': 4.39.0
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.39.0
|
||||
'@rollup/rollup-linux-riscv64-musl': 4.39.0
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.39.0
|
||||
'@rollup/rollup-linux-x64-gnu': 4.39.0
|
||||
'@rollup/rollup-linux-x64-musl': 4.39.0
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.39.0
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.39.0
|
||||
'@rollup/rollup-win32-x64-msvc': 4.39.0
|
||||
'@rollup/rollup-android-arm-eabi': 4.40.0
|
||||
'@rollup/rollup-android-arm64': 4.40.0
|
||||
'@rollup/rollup-darwin-arm64': 4.40.0
|
||||
'@rollup/rollup-darwin-x64': 4.40.0
|
||||
'@rollup/rollup-freebsd-arm64': 4.40.0
|
||||
'@rollup/rollup-freebsd-x64': 4.40.0
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.40.0
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.40.0
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.40.0
|
||||
'@rollup/rollup-linux-arm64-musl': 4.40.0
|
||||
'@rollup/rollup-linux-loongarch64-gnu': 4.40.0
|
||||
'@rollup/rollup-linux-powerpc64le-gnu': 4.40.0
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.40.0
|
||||
'@rollup/rollup-linux-riscv64-musl': 4.40.0
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.40.0
|
||||
'@rollup/rollup-linux-x64-gnu': 4.40.0
|
||||
'@rollup/rollup-linux-x64-musl': 4.40.0
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.40.0
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.40.0
|
||||
'@rollup/rollup-win32-x64-msvc': 4.40.0
|
||||
fsevents: 2.3.3
|
||||
|
||||
router@2.2.0:
|
||||
@@ -7132,11 +7132,11 @@ snapshots:
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
vite@5.4.17(@types/node@20.17.28)(lightningcss@1.29.2):
|
||||
vite@5.4.18(@types/node@20.17.28)(lightningcss@1.29.2):
|
||||
dependencies:
|
||||
esbuild: 0.21.5
|
||||
postcss: 8.5.3
|
||||
rollup: 4.39.0
|
||||
rollup: 4.40.0
|
||||
optionalDependencies:
|
||||
'@types/node': 20.17.28
|
||||
fsevents: 2.3.3
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
removeServer,
|
||||
updateMcpServer,
|
||||
recreateMcpServer,
|
||||
toggleServerStatus,
|
||||
} from '../services/mcpService.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
|
||||
@@ -207,3 +208,46 @@ export const getServerConfig = (req: Request, res: Response): void => {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const toggleServer = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
const { enabled } = req.body;
|
||||
|
||||
if (!name) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server name is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof enabled !== 'boolean') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Enabled status must be a boolean',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await toggleServerStatus(name, enabled);
|
||||
|
||||
if (result.success) {
|
||||
recreateMcpServer();
|
||||
res.json({
|
||||
success: true,
|
||||
message: result.message || `Server ${enabled ? 'enabled' : 'disabled'} successfully`,
|
||||
});
|
||||
} else {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: result.message || 'Server not found or failed to toggle status',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal server error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createServer,
|
||||
updateServer,
|
||||
deleteServer,
|
||||
toggleServer,
|
||||
} from '../controllers/serverController.js';
|
||||
import {
|
||||
login,
|
||||
@@ -24,6 +25,7 @@ export const initRoutes = (app: express.Application): void => {
|
||||
router.post('/servers', createServer);
|
||||
router.put('/servers/:name', updateServer);
|
||||
router.delete('/servers/:name', deleteServer);
|
||||
router.post('/servers/:name/toggle', toggleServer);
|
||||
|
||||
// Auth routes (these will NOT be protected by auth middleware)
|
||||
app.post('/auth/login', [
|
||||
|
||||
@@ -44,12 +44,28 @@ export const initializeClientsFromSettings = (): ServerInfo[] => {
|
||||
serverInfos = [];
|
||||
|
||||
for (const [name, conf] of Object.entries(settings.mcpServers)) {
|
||||
// Skip disabled servers
|
||||
if (conf.enabled === false) {
|
||||
console.log(`Skipping disabled server: ${name}`);
|
||||
serverInfos.push({
|
||||
name,
|
||||
status: 'disconnected',
|
||||
tools: [],
|
||||
createTime: Date.now(),
|
||||
enabled: false
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if server is already connected
|
||||
const existingServer = existingServerInfos.find(
|
||||
(s) => s.name === name && s.status === 'connected',
|
||||
);
|
||||
if (existingServer) {
|
||||
serverInfos.push(existingServer);
|
||||
serverInfos.push({
|
||||
...existingServer,
|
||||
enabled: conf.enabled === undefined ? true : conf.enabled
|
||||
});
|
||||
console.log(`Server '${name}' is already connected.`);
|
||||
continue;
|
||||
}
|
||||
@@ -160,12 +176,23 @@ export const registerAllTools = async (server: McpServer, forceInit: boolean): P
|
||||
|
||||
// Get all server information
|
||||
export const getServersInfo = (): Omit<ServerInfo, 'client' | 'transport'>[] => {
|
||||
return serverInfos.map(({ name, status, tools, createTime }) => ({
|
||||
name,
|
||||
status,
|
||||
tools,
|
||||
createTime,
|
||||
}));
|
||||
const settings = loadSettings();
|
||||
const infos = serverInfos.map(({ name, status, tools, createTime }) => {
|
||||
const serverConfig = settings.mcpServers[name];
|
||||
const enabled = serverConfig ? (serverConfig.enabled !== false) : true;
|
||||
return {
|
||||
name,
|
||||
status,
|
||||
tools,
|
||||
createTime,
|
||||
enabled,
|
||||
};
|
||||
});
|
||||
infos.sort((a, b) => {
|
||||
if (a.enabled === b.enabled) return 0;
|
||||
return a.enabled ? -1 : 1;
|
||||
});
|
||||
return infos;
|
||||
};
|
||||
|
||||
// Get server information by name
|
||||
@@ -252,6 +279,51 @@ export const updateMcpServer = async (
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle server enabled status
|
||||
export const toggleServerStatus = async (
|
||||
name: string,
|
||||
enabled: boolean
|
||||
): Promise<{ success: boolean; message?: string }> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.mcpServers[name]) {
|
||||
return { success: false, message: 'Server not found' };
|
||||
}
|
||||
|
||||
// Update the enabled status in settings
|
||||
settings.mcpServers[name].enabled = enabled;
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return { success: false, message: 'Failed to save settings' };
|
||||
}
|
||||
|
||||
// If disabling, disconnect the server and remove from active servers
|
||||
if (!enabled) {
|
||||
const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
|
||||
if (serverInfo && serverInfo.client && serverInfo.transport) {
|
||||
serverInfo.client.close();
|
||||
serverInfo.transport.close();
|
||||
console.log(`Closed client and transport for server: ${name}`);
|
||||
}
|
||||
|
||||
// Update the server info to show as disconnected and disabled
|
||||
const index = serverInfos.findIndex(s => s.name === name);
|
||||
if (index !== -1) {
|
||||
serverInfos[index] = {
|
||||
...serverInfos[index],
|
||||
status: 'disconnected',
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, message: `Server ${enabled ? 'enabled' : 'disabled'} successfully` };
|
||||
} catch (error) {
|
||||
console.error(`Failed to toggle server status: ${name}`, error);
|
||||
return { success: false, message: 'Failed to toggle server status' };
|
||||
}
|
||||
};
|
||||
|
||||
// Create McpServer instance
|
||||
export const createMcpServer = (name: string, version: string): McpServer => {
|
||||
return new McpServer({ name, version });
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface ServerConfig {
|
||||
command?: string; // Command to execute for stdio-based servers
|
||||
args?: string[]; // Arguments for the command
|
||||
env?: Record<string, string>; // Environment variables
|
||||
enabled?: boolean; // Flag to enable/disable the server
|
||||
}
|
||||
|
||||
// Information about a server's status and tools
|
||||
@@ -33,6 +34,7 @@ export interface ServerInfo {
|
||||
client?: Client; // Client instance for communication
|
||||
transport?: SSEClientTransport | StdioClientTransport; // Transport mechanism used
|
||||
createTime: number; // Timestamp of when the server was created
|
||||
enabled?: boolean; // Flag to indicate if the server is enabled
|
||||
}
|
||||
|
||||
// Details about a tool available on the server
|
||||
|
||||
Reference in New Issue
Block a user