Refactor server management UI (#9)

This commit is contained in:
samanhappy
2025-04-15 19:02:38 +08:00
committed by GitHub
parent 6af13f85d4
commit d94a58ebca
15 changed files with 964 additions and 425 deletions

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

View File

@@ -1,398 +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 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;
}
// Update the UI immediately to reflect the change
setRefreshKey(prevKey => prevKey + 1);
} catch (err) {
console.error('Error toggling server:', err);
setError(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}
onToggle={handleServerToggle}
/>
))}
</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;

View File

@@ -63,12 +63,6 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
>
{t('server.edit')}
</button>
<button
onClick={handleRemove}
className="px-3 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm"
>
{t('server.delete')}
</button>
<div className="flex items-center">
<button
onClick={handleToggle}
@@ -89,6 +83,12 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
}
</button>
</div>
<button
onClick={handleRemove}
className="px-3 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm"
>
{t('server.delete')}
</button>
<button className="text-gray-400 hover:text-gray-600">
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button>

View 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;

View 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;

View 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;

View 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
};
};

View 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;

View File

@@ -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,9 +53,14 @@
"envVars": "Environment Variables",
"key": "key",
"value": "value",
"enabled": "Enabled",
"enable": "Enable",
"disable": "Disable",
"remove": "Remove"
"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",
@@ -72,6 +79,30 @@
"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"
}
}
}

View File

@@ -7,7 +7,9 @@
"loading": "加载中...",
"logout": "退出登录",
"profile": "个人资料",
"changePassword": "修改密码"
"changePassword": "修改密码",
"toggleSidebar": "切换侧边栏",
"welcomeUser": "欢迎, {{username}}"
},
"auth": {
"login": "登录",
@@ -51,9 +53,14 @@
"envVars": "环境变量",
"key": "键",
"value": "值",
"enabled": "已启用",
"enable": "启用",
"disable": "禁用",
"remove": "移除"
"remove": "移除",
"toggleError": "切换服务器 {{serverName}} 状态失败",
"alreadyExists": "服务器 {{serverName}} 已经存在",
"invalidData": "提供的服务器数据无效",
"notFound": "找不到服务器 {{serverName}}"
},
"status": {
"online": "在线",
@@ -72,6 +79,30 @@
"common": {
"processing": "处理中...",
"save": "保存",
"cancel": "取消"
"cancel": "取消",
"refresh": "刷新"
},
"nav": {
"dashboard": "仪表盘",
"servers": "服务器",
"settings": "设置",
"changePassword": "修改密码"
},
"pages": {
"dashboard": {
"title": "仪表盘",
"totalServers": "服务器总数",
"onlineServers": "在线服务器",
"offlineServers": "离线服务器",
"connectingServers": "连接中服务",
"recentServers": "最近的服务器"
},
"servers": {
"title": "服务器管理"
},
"settings": {
"title": "设置",
"language": "语言"
}
}
}

View File

@@ -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;

View 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;

View 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;

View 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;