diff --git a/Dockerfile b/Dockerfile index 39b8faf..18588c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.13-slim-bookworm AS base COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ -RUN apt-get update && apt-get install -y curl gnupg \ +RUN apt-get update && apt-get install -y curl gnupg git \ && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && apt-get install -y nodejs \ && apt-get clean && rm -rf /var/lib/apt/lists/* diff --git a/frontend/src/components/MarketServerCard.tsx b/frontend/src/components/MarketServerCard.tsx index 697d5c7..ad04768 100644 --- a/frontend/src/components/MarketServerCard.tsx +++ b/frontend/src/components/MarketServerCard.tsx @@ -10,37 +10,37 @@ interface MarketServerCardProps { const MarketServerCard: React.FC = ({ server, onClick }) => { const { t } = useTranslation(); - // 智能计算要显示多少个标签,确保在单行内展示 + // Intelligently calculate how many tags to display to ensure they fit in a single line const getTagsToDisplay = () => { if (!server.tags || server.tags.length === 0) { return { tagsToShow: [], hasMore: false, moreCount: 0 }; } - // 估计卡片内单行可用宽度(以字符为单位) - const estimatedAvailableWidth = 30; // 估计一行可以容纳的字符数 + // Estimate available width in the card (in characters) + const estimatedAvailableWidth = 28; // Estimated number of characters that can fit in one line - // 计算标签和加号所需的字符空间(包括#号和间距) + // Calculate the character space needed for tags and plus sign (including # and spacing) const calculateTagWidth = (tag: string) => tag.length + 3; // +3 for # and spacing - // 循环确定能显示的最大标签数量 + // Loop to determine the maximum number of tags that can be displayed let totalWidth = 0; let i = 0; - // 首先对标签按长度排序,优先显示较短的标签 + // First, sort tags by length to prioritize displaying shorter tags const sortedTags = [...server.tags].sort((a, b) => a.length - b.length); - // 计算能够放入的标签数量 + // Calculate how many tags can fit for (i = 0; i < sortedTags.length; i++) { const tagWidth = calculateTagWidth(sortedTags[i]); - // 如果这个标签会使总宽度超出可用宽度,停止添加 + // If this tag would make the total width exceed available width, stop adding if (totalWidth + tagWidth > estimatedAvailableWidth) { break; } totalWidth += tagWidth; - // 如果这是最后一个标签但仍有空间,不需要显示"更多" + // If this is the last tag but there's still space, no need to show "more" if (i === sortedTags.length - 1) { return { tagsToShow: sortedTags, @@ -50,16 +50,16 @@ const MarketServerCard: React.FC = ({ server, onClick }) } } - // 如果没有足够空间显示任何标签,至少显示一个 + // If there's not enough space to display any tags, show at least one if (i === 0 && sortedTags.length > 0) { i = 1; } - // 计算"更多"标签所需的空间 + // Calculate space needed for the "more" tag const moreCount = sortedTags.length - i; const moreTagWidth = 3 + String(moreCount).length + t('market.moreTags').length; - // 如果剩余空间足够显示"更多"标签 + // If there's enough remaining space to display the "more" tag if (totalWidth + moreTagWidth <= estimatedAvailableWidth || i < 1) { return { tagsToShow: sortedTags.slice(0, i), @@ -68,7 +68,7 @@ const MarketServerCard: React.FC = ({ server, onClick }) }; } - // 如果连"更多"标签都放不下,减少一个标签以腾出空间 + // If there's not enough space for even the "more" tag, reduce one tag to make room return { tagsToShow: sortedTags.slice(0, Math.max(1, i - 1)), hasMore: true, diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 13909dd..50c13b9 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -16,7 +16,7 @@ const Sidebar: React.FC = ({ collapsed }) => { const { t } = useTranslation(); const location = useLocation(); - // 菜单项配置 + // Menu item configuration const menuItems: MenuItem[] = [ { path: '/', diff --git a/frontend/src/components/ui/ToggleGroup.tsx b/frontend/src/components/ui/ToggleGroup.tsx index 2982286..d4e173f 100644 --- a/frontend/src/components/ui/ToggleGroup.tsx +++ b/frontend/src/components/ui/ToggleGroup.tsx @@ -97,4 +97,38 @@ export const ToggleGroup: React.FC = ({ )} ); +}; + +interface SwitchProps { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + disabled?: boolean; +} + +export const Switch: React.FC = ({ + checked, + onCheckedChange, + disabled = false +}) => { + return ( + + ); }; \ No newline at end of file diff --git a/frontend/src/hooks/useServerData.ts b/frontend/src/hooks/useServerData.ts index 3dad563..b88e402 100644 --- a/frontend/src/hooks/useServerData.ts +++ b/frontend/src/hooks/useServerData.ts @@ -2,16 +2,16 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Server, ApiResponse } from '@/types'; -// 配置选项 +// Configuration options const CONFIG = { - // 初始化启动阶段的配置 + // Initialization phase configuration startup: { - maxAttempts: 60, // 初始化阶段最大尝试次数 - pollingInterval: 3000 // 初始阶段轮询间隔 (3秒) + maxAttempts: 60, // Maximum number of attempts during initialization + pollingInterval: 3000 // Polling interval during initialization (3 seconds) }, - // 正常运行阶段的配置 + // Normal operation phase configuration normal: { - pollingInterval: 10000 // 正常运行时的轮询间隔 (10秒) + pollingInterval: 10000 // Polling interval during normal operation (10 seconds) } }; @@ -23,12 +23,12 @@ export const useServerData = () => { const [isInitialLoading, setIsInitialLoading] = useState(true); const [fetchAttempts, setFetchAttempts] = useState(0); - // 轮询定时器引用 + // Timer reference for polling const intervalRef = useRef(null); - // 保存当前尝试次数,避免依赖循环 + // Track current attempt count to avoid dependency cycles const attemptsRef = useRef(0); - // 清理定时器 + // Clear the timer const clearTimer = () => { if (intervalRef.current) { clearInterval(intervalRef.current); @@ -36,9 +36,9 @@ export const useServerData = () => { } }; - // 开始正常轮询 + // Start normal polling const startNormalPolling = useCallback(() => { - // 确保没有其他定时器在运行 + // Ensure no other timers are running clearTimer(); const fetchServers = async () => { @@ -60,12 +60,12 @@ export const useServerData = () => { setServers([]); } - // 重置错误状态 + // Reset error state setError(null); } catch (err) { console.error('Error fetching servers during normal polling:', err); - // 使用友好的错误消息 + // Use friendly error message if (!navigator.onLine) { setError(t('errors.network')); } else if (err instanceof TypeError && ( @@ -79,21 +79,21 @@ export const useServerData = () => { } }; - // 立即执行一次 + // Execute immediately fetchServers(); - // 设置定期轮询 + // Set up regular polling intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval); }, [t]); useEffect(() => { - // 重置尝试计数 + // Reset attempt count if (refreshKey > 0) { attemptsRef.current = 0; setFetchAttempts(0); } - // 初始化加载阶段的请求函数 + // Initialization phase request function const fetchInitialData = async () => { try { const token = localStorage.getItem('mcphub_token'); @@ -104,51 +104,51 @@ export const useServerData = () => { }); const data = await response.json(); - // 处理API响应中的包装对象,提取data字段 + // Handle API response wrapper object, extract data field if (data && data.success && Array.isArray(data.data)) { setServers(data.data); setIsInitialLoading(false); - // 初始化成功,开始正常轮询 + // Initialization successful, start normal polling startNormalPolling(); return true; } else if (data && Array.isArray(data)) { - // 兼容性处理,如果API直接返回数组 + // Compatibility handling, if API directly returns array setServers(data); setIsInitialLoading(false); - // 初始化成功,开始正常轮询 + // Initialization successful, start normal polling startNormalPolling(); return true; } else { - // 如果数据格式不符合预期,设置为空数组 + // If data format is not as expected, set to empty array console.error('Invalid server data format:', data); setServers([]); setIsInitialLoading(false); - // 初始化成功但数据为空,开始正常轮询 + // Initialization successful but data is empty, start normal polling startNormalPolling(); return true; } } catch (err) { - // 增加尝试次数计数,使用 ref 避免触发 effect 重新运行 + // Increment attempt count, use ref to avoid triggering effect rerun attemptsRef.current += 1; console.error(`Initial loading attempt ${attemptsRef.current} failed:`, err); - // 更新状态用于显示 + // Update state for display setFetchAttempts(attemptsRef.current); - // 设置适当的错误消息 + // Set appropriate error message if (!navigator.onLine) { setError(t('errors.network')); } else { setError(t('errors.initialStartup')); } - // 如果已超过最大尝试次数,放弃初始化并切换到正常轮询 + // If maximum attempt count is exceeded, give up initialization and switch to normal polling if (attemptsRef.current >= CONFIG.startup.maxAttempts) { console.log('Maximum startup attempts reached, switching to normal polling'); setIsInitialLoading(false); - // 清除初始化的轮询 + // Clear initialization polling clearTimer(); - // 切换到正常轮询模式 + // Switch to normal polling mode startNormalPolling(); } @@ -156,45 +156,45 @@ export const useServerData = () => { } }; - // 组件挂载时,根据当前状态设置适当的轮询 + // On component mount, set appropriate polling based on current state if (isInitialLoading) { - // 确保没有其他定时器在运行 + // Ensure no other timers are running clearTimer(); - // 立即执行一次初始请求 + // Execute initial request immediately fetchInitialData(); - // 设置初始阶段的轮询间隔 + // Set polling interval for initialization phase intervalRef.current = setInterval(fetchInitialData, CONFIG.startup.pollingInterval); console.log(`Started initial polling with interval: ${CONFIG.startup.pollingInterval}ms`); } else { - // 已经初始化完成,开始正常轮询 + // Initialization completed, start normal polling startNormalPolling(); } - // 清理函数 + // Cleanup function return () => { clearTimer(); }; }, [refreshKey, t, isInitialLoading, startNormalPolling]); - // 手动触发刷新 + // Manually trigger refresh const triggerRefresh = () => { - // 清除当前的定时器 + // Clear current timer clearTimer(); - // 如果在初始化阶段,重置初始化状态 + // If in initialization phase, reset initialization state if (isInitialLoading) { setIsInitialLoading(true); attemptsRef.current = 0; setFetchAttempts(0); } - // refreshKey 的改变会触发 useEffect 再次运行 + // Change in refreshKey will trigger useEffect to run again setRefreshKey(prevKey => prevKey + 1); }; - // 服务器相关操作 + // Server related operations const handleServerAdd = () => { setRefreshKey(prevKey => prevKey + 1); }; diff --git a/frontend/src/hooks/useSettingsData.ts b/frontend/src/hooks/useSettingsData.ts new file mode 100644 index 0000000..dd6dce3 --- /dev/null +++ b/frontend/src/hooks/useSettingsData.ts @@ -0,0 +1,131 @@ +import { useState, useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ApiResponse } from '@/types'; +import { useToast } from '@/contexts/ToastContext'; + +// Define types for the settings data +interface RoutingConfig { + enableGlobalRoute: boolean; + enableGroupNameRoute: boolean; +} + +interface SystemSettings { + systemConfig?: { + routing?: RoutingConfig; + }; +} + +export const useSettingsData = () => { + const { t } = useTranslation(); + const { showToast } = useToast(); + + const [routingConfig, setRoutingConfig] = useState({ + enableGlobalRoute: true, + enableGroupNameRoute: true, + }); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [refreshKey, setRefreshKey] = useState(0); + + // Trigger a refresh of the settings data + const triggerRefresh = useCallback(() => { + setRefreshKey((prev) => prev + 1); + }, []); + + // Fetch current settings + const fetchSettings = useCallback(async () => { + setLoading(true); + setError(null); + + try { + const token = localStorage.getItem('mcphub_token'); + const response = await fetch('/api/settings', { + headers: { + 'x-auth-token': token || '', + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + + const data: ApiResponse = await response.json(); + + if (data.success && data.data?.systemConfig?.routing) { + setRoutingConfig({ + enableGlobalRoute: data.data.systemConfig.routing.enableGlobalRoute ?? true, + enableGroupNameRoute: data.data.systemConfig.routing.enableGroupNameRoute ?? true, + }); + } + } catch (error) { + console.error('Failed to fetch settings:', error); + setError(error instanceof Error ? error.message : 'Failed to fetch settings'); + showToast(t('errors.failedToFetchSettings')); + } finally { + setLoading(false); + } + }, [t, showToast]); + + // Update routing configuration + const updateRoutingConfig = async (key: keyof RoutingConfig, value: boolean) => { + setLoading(true); + setError(null); + + try { + const token = localStorage.getItem('mcphub_token'); + const response = await fetch('/api/system-config', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'x-auth-token': token || '', + }, + body: JSON.stringify({ + routing: { + [key]: value, + }, + }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + + const data = await response.json(); + + if (data.success) { + setRoutingConfig({ + ...routingConfig, + [key]: value, + }); + showToast(t('settings.systemConfigUpdated')); + return true; + } else { + showToast(t('errors.failedToUpdateSystemConfig')); + return false; + } + } catch (error) { + console.error('Failed to update system config:', error); + setError(error instanceof Error ? error.message : 'Failed to update system config'); + showToast(t('errors.failedToUpdateSystemConfig')); + return false; + } finally { + setLoading(false); + } + }; + + // Fetch settings when the component mounts or refreshKey changes + useEffect(() => { + fetchSettings(); + }, [fetchSettings, refreshKey]); + + return { + routingConfig, + loading, + error, + setError, + triggerRefresh, + fetchSettings, + updateRoutingConfig, + }; +}; diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index d588bfa..5a542ee 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -32,8 +32,8 @@ i18n }, detection: { - // Order of detection; we put 'navigator' first to use browser language - order: ['navigator', 'localStorage', 'cookie', 'htmlTag'], + // Order of detection; prioritize localStorage to respect user language choice + order: ['localStorage', 'cookie', 'htmlTag', 'navigator'], // Cache the language in localStorage caches: ['localStorage', 'cookie'], } diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 7f773f0..b4d9b1a 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -83,7 +83,9 @@ "serverUpdate": "Failed to edit server {{serverName}}. Please check the server status", "serverFetch": "Failed to retrieve server data. Please try again later", "initialStartup": "The server might be starting up. Please wait a moment as this process can take some time on first launch...", - "serverInstall": "Failed to install server" + "serverInstall": "Failed to install server", + "failedToFetchSettings": "Failed to fetch settings", + "failedToUpdateRouteConfig": "Failed to update route configuration" }, "common": { "processing": "Processing...", @@ -123,7 +125,8 @@ "language": "Language", "account": "Account Settings", "password": "Change Password", - "appearance": "Appearance" + "appearance": "Appearance", + "routeConfig": "Route Configuration" }, "market": { "title": "Server Market - (Data from mcpm.sh)" @@ -196,5 +199,12 @@ "noInstallationMethod": "No installation method available for this server", "showing": "Showing {{from}}-{{to}} of {{total}} servers", "perPage": "Per page" + }, + "settings": { + "enableGlobalRoute": "Enable Global Route", + "enableGlobalRouteDescription": "Allow connections to /sse endpoint without specifying a group ID", + "enableGroupNameRoute": "Enable Group Name Route", + "enableGroupNameRouteDescription": "Allow connections to /sse endpoint using group names instead of just group IDs", + "systemConfigUpdated": "System configuration updated successfully" } } \ No newline at end of file diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 61f5277..00a93ba 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -83,7 +83,9 @@ "serverUpdate": "编辑服务器 {{serverName}} 失败,请检查服务器状态", "serverFetch": "获取服务器数据失败,请稍后重试", "initialStartup": "服务器可能正在启动中。首次启动可能需要一些时间,请耐心等候...", - "serverInstall": "安装服务器失败" + "serverInstall": "安装服务器失败", + "failedToFetchSettings": "获取设置失败", + "failedToUpdateSystemConfig": "更新系统配置失败" }, "common": { "processing": "处理中...", @@ -120,7 +122,8 @@ "language": "语言", "account": "账户设置", "password": "修改密码", - "appearance": "外观" + "appearance": "外观", + "routeConfig": "路由配置" }, "groups": { "title": "分组管理" @@ -196,5 +199,12 @@ "noInstallationMethod": "该服务器没有可用的安装方法", "showing": "显示 {{from}}-{{to}}/{{total}} 个服务器", "perPage": "每页显示" + }, + "settings": { + "enableGlobalRoute": "启用全局路由", + "enableGlobalRouteDescription": "允许不指定分组 ID 就连接到 /sse 端点", + "enableGroupNameRoute": "启用分组名称路由", + "enableGroupNameRouteDescription": "允许使用分组名称而非分组 ID 连接到 /sse 端点", + "systemConfigUpdated": "系统配置更新成功" } } \ No newline at end of file diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 1cf3f13..9691548 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -7,7 +7,7 @@ const DashboardPage: React.FC = () => { const { t } = useTranslation(); const { servers, error, setError, isLoading } = useServerData(); - // 计算服务器统计信息 + // Calculate server statistics const serverStats = { total: servers.length, online: servers.filter(server => server.status === 'connected').length, @@ -22,7 +22,7 @@ const DashboardPage: React.FC = () => { connecting: 'status.connecting' } - // 计算各状态百分比(用于仪表板展示) + // Calculate percentage for each status (for dashboard display) const getStatusPercentage = (status: ServerStatus) => { if (servers.length === 0) return 0; return Math.round((servers.filter(server => server.status === status).length / servers.length) * 100); @@ -64,7 +64,7 @@ const DashboardPage: React.FC = () => { ) : (
- {/* 服务器总数 */} + {/* Total servers */}
@@ -79,7 +79,7 @@ const DashboardPage: React.FC = () => {
- {/* 在线服务器 */} + {/* Online servers */}
@@ -100,7 +100,7 @@ const DashboardPage: React.FC = () => {
- {/* 离线服务器 */} + {/* Offline servers */}
@@ -121,7 +121,7 @@ const DashboardPage: React.FC = () => {
- {/* 连接中服务器 */} + {/* Connecting servers */}
@@ -144,7 +144,7 @@ const DashboardPage: React.FC = () => {
)} - {/* 最近活动列表 */} + {/* Recent activity list */} {servers.length > 0 && !isLoading && (

{t('pages.dashboard.recentServers')}

diff --git a/frontend/src/pages/MarketPage.tsx b/frontend/src/pages/MarketPage.tsx index f70ac91..53caa9c 100644 --- a/frontend/src/pages/MarketPage.tsx +++ b/frontend/src/pages/MarketPage.tsx @@ -193,7 +193,7 @@ const MarketPage: React.FC = () => {
{/* Categories */} - {categories.length > 0 && ( + {categories.length > 0 ? (

{t('market.categories')}

@@ -218,6 +218,26 @@ const MarketPage: React.FC = () => { ))}
+ ) : loading ? ( +
+
+

{t('market.categories')}

+
+
+ + + + +

{t('app.loading')}

+
+
+ ) : ( +
+
+

{t('market.categories')}

+
+

{t('market.noCategories')}

+
)} {/* Tags */} diff --git a/frontend/src/pages/ServersPage.tsx b/frontend/src/pages/ServersPage.tsx index e3c12a8..32f67f4 100644 --- a/frontend/src/pages/ServersPage.tsx +++ b/frontend/src/pages/ServersPage.tsx @@ -16,9 +16,11 @@ const ServersPage: React.FC = () => { handleServerAdd, handleServerEdit, handleServerRemove, - handleServerToggle + handleServerToggle, + triggerRefresh } = useServerData(); const [editingServer, setEditingServer] = useState(null); + const [isRefreshing, setIsRefreshing] = useState(false); const handleEditClick = async (server: Server) => { const fullServerData = await handleServerEdit(server); @@ -31,6 +33,17 @@ const ServersPage: React.FC = () => { setEditingServer(null); }; + const handleRefresh = async () => { + setIsRefreshing(true); + try { + triggerRefresh(); + // Add a slight delay to make the spinner visible + await new Promise(resolve => setTimeout(resolve, 500)); + } finally { + setIsRefreshing(false); + } + }; + return (
@@ -38,12 +51,20 @@ const ServersPage: React.FC = () => {
diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index c653569..bdd2b0d 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -1,52 +1,146 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import ChangePasswordForm from '@/components/ChangePasswordForm'; +import { Switch } from '@/components/ui/ToggleGroup'; +import { useSettingsData } from '@/hooks/useSettingsData'; +import { useToast } from '@/contexts/ToastContext'; const SettingsPage: React.FC = () => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const navigate = useNavigate(); - + const { showToast } = useToast(); + const [currentLanguage, setCurrentLanguage] = useState(i18n.language); + + // Update current language when it changes + useEffect(() => { + setCurrentLanguage(i18n.language); + }, [i18n.language]); + + const { + routingConfig, + loading, + updateRoutingConfig + } = useSettingsData(); + + const [sectionsVisible, setSectionsVisible] = useState({ + routingConfig: false, + password: false + }); + + const toggleSection = (section: 'routingConfig' | 'password') => { + setSectionsVisible(prev => ({ + ...prev, + [section]: !prev[section] + })); + }; + + const handleRoutingConfigChange = async (key: 'enableGlobalRoute' | 'enableGroupNameRoute', value: boolean) => { + await updateRoutingConfig(key, value); + }; + const handlePasswordChangeSuccess = () => { setTimeout(() => { navigate('/'); }, 2000); }; + const handleLanguageChange = (lang: string) => { + localStorage.setItem('i18nextLng', lang); + window.location.reload(); + }; + return ( -
+

{t('pages.settings.title')}

- -
-

{t('auth.changePassword')}

-
- + + {/* Language Settings */} +
+
+

{t('pages.settings.language')}

+
+ + +
- - {/* 其他设置可以在这里添加 */} -
-

{t('pages.settings.language')}

-
- - + + {/* Route Configuration Settings */} +
+
toggleSection('routingConfig')} + > +

{t('pages.settings.routeConfig')}

+ + {sectionsVisible.routingConfig ? '▼' : '►'} +
+ + {sectionsVisible.routingConfig && ( +
+
+
+

{t('settings.enableGlobalRoute')}

+

{t('settings.enableGlobalRouteDescription')}

+
+ handleRoutingConfigChange('enableGlobalRoute', checked)} + /> +
+ +
+
+

{t('settings.enableGroupNameRoute')}

+

{t('settings.enableGroupNameRouteDescription')}

+
+ handleRoutingConfigChange('enableGroupNameRoute', checked)} + /> +
+
+ )} +
+ + {/* Change Password */} +
+
toggleSection('password')} + > +

{t('auth.changePassword')}

+ + {sectionsVisible.password ? '▼' : '►'} + +
+ + {sectionsVisible.password && ( +
+ +
+ )}
); diff --git a/src/controllers/groupController.ts b/src/controllers/groupController.ts index 1888f9f..6954df5 100644 --- a/src/controllers/groupController.ts +++ b/src/controllers/groupController.ts @@ -2,7 +2,7 @@ import { Request, Response } from 'express'; import { ApiResponse } from '../types/index.js'; import { getAllGroups, - getGroupById, + getGroupByIdOrName, createGroup, updateGroup, updateGroupServers, @@ -41,7 +41,7 @@ export const getGroup = (req: Request, res: Response): void => { return; } - const group = getGroupById(id); + const group = getGroupByIdOrName(id); if (!group) { res.status(404).json({ success: false, @@ -318,7 +318,7 @@ export const getGroupServers = (req: Request, res: Response): void => { return; } - const group = getGroupById(id); + const group = getGroupByIdOrName(id); if (!group) { res.status(404).json({ success: false, diff --git a/src/controllers/serverController.ts b/src/controllers/serverController.ts index 13dca23..e052ea1 100644 --- a/src/controllers/serverController.ts +++ b/src/controllers/serverController.ts @@ -8,7 +8,7 @@ import { notifyToolChanged, toggleServerStatus, } from '../services/mcpService.js'; -import { loadSettings } from '../config/index.js'; +import { loadSettings, saveSettings } from '../config/index.js'; export const getAllServers = (_: Request, res: Response): void => { try { @@ -244,3 +244,60 @@ export const toggleServer = async (req: Request, res: Response): Promise = }); } }; + +export const updateSystemConfig = (req: Request, res: Response): void => { + try { + const { routing } = req.body; + + if (!routing || (typeof routing.enableGlobalRoute !== 'boolean' && typeof routing.enableGroupNameRoute !== 'boolean')) { + res.status(400).json({ + success: false, + message: 'Invalid system configuration provided', + }); + return; + } + + const settings = loadSettings(); + if (!settings.systemConfig) { + settings.systemConfig = { + routing: { + enableGlobalRoute: true, + enableGroupNameRoute: true + } + }; + } + + if (!settings.systemConfig.routing) { + settings.systemConfig.routing = { + enableGlobalRoute: true, + enableGroupNameRoute: true + }; + } + + if (typeof routing.enableGlobalRoute === 'boolean') { + settings.systemConfig.routing.enableGlobalRoute = routing.enableGlobalRoute; + } + + if (typeof routing.enableGroupNameRoute === 'boolean') { + settings.systemConfig.routing.enableGroupNameRoute = routing.enableGroupNameRoute; + } + + if (saveSettings(settings)) { + res.json({ + success: true, + data: settings.systemConfig, + message: 'System configuration updated successfully', + }); + } else { + res.status(500).json({ + success: false, + message: 'Failed to save system configuration', + }); + } + } catch (error) { + res.status(500).json({ + success: false, + message: 'Internal server error', + }); + } +}; diff --git a/src/routes/index.ts b/src/routes/index.ts index aa92488..b7b06e4 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -7,6 +7,7 @@ import { updateServer, deleteServer, toggleServer, + updateSystemConfig } from '../controllers/serverController.js'; import { getGroups, @@ -46,6 +47,7 @@ export const initRoutes = (app: express.Application): void => { router.put('/servers/:name', updateServer); router.delete('/servers/:name', deleteServer); router.post('/servers/:name/toggle', toggleServer); + router.put('/system-config', updateSystemConfig); // Group management routes router.get('/groups', getGroups); diff --git a/src/server.ts b/src/server.ts index 57a9739..6ade189 100644 --- a/src/server.ts +++ b/src/server.ts @@ -32,7 +32,7 @@ export class AppServer { initMcpServer(config.mcpHubName, config.mcpHubVersion) .then(() => { console.log('MCP server initialized successfully'); - this.app.get('/sse/:groupId?', (req, res) => handleSseConnection(req, res)); + this.app.get('/sse/:group?', (req, res) => handleSseConnection(req, res)); this.app.post('/messages', handleSseMessage); }) .catch((error) => { diff --git a/src/services/groupService.ts b/src/services/groupService.ts index 35466a4..f5d6dbe 100644 --- a/src/services/groupService.ts +++ b/src/services/groupService.ts @@ -9,10 +9,19 @@ export const getAllGroups = (): IGroup[] => { return settings.groups || []; }; -// Get group by ID -export const getGroupById = (id: string): IGroup | undefined => { +// Get group by ID or name +export const getGroupByIdOrName = (key: string): IGroup | undefined => { + const settings = loadSettings(); + const routingConfig = settings.systemConfig?.routing || { + enableGlobalRoute: true, + enableGroupNameRoute: true, + }; const groups = getAllGroups(); - return groups.find((group) => group.id === id); + return ( + groups.find( + (group) => group.id === key || (group.name === key && routingConfig.enableGroupNameRoute), + ) || undefined + ); }; // Create a new group @@ -218,6 +227,6 @@ export const removeServerFromGroup = (groupId: string, serverName: string): IGro // Get all servers in a group export const getServersInGroup = (groupId: string): string[] => { - const group = getGroupById(groupId); + const group = getGroupByIdOrName(groupId); return group ? group.servers : []; }; diff --git a/src/services/mcpService.ts b/src/services/mcpService.ts index 69611b2..21a50bc 100644 --- a/src/services/mcpService.ts +++ b/src/services/mcpService.ts @@ -7,7 +7,7 @@ import { ServerInfo, ServerConfig } from '../types/index.js'; import { loadSettings, saveSettings, expandEnvVars } from '../config/index.js'; import config from '../config/index.js'; import { get } from 'http'; -import { getGroupId } from './sseService.js'; +import { getGroup } from './sseService.js'; import { getServersInGroup } from './groupService.js'; let currentServer: Server; @@ -316,12 +316,12 @@ export const createMcpServer = (name: string, version: string): Server => { const server = new Server({ name, version }, { capabilities: { tools: {} } }); server.setRequestHandler(ListToolsRequestSchema, async (_, extra) => { const sessionId = extra.sessionId || ''; - const groupId = getGroupId(sessionId); - console.log(`Handling ListToolsRequest for groupId: ${groupId}`); + const group = getGroup(sessionId); + console.log(`Handling ListToolsRequest for group: ${group}`); const allServerInfos = serverInfos.filter((serverInfo) => { if (serverInfo.enabled === false) return false; - if (!groupId) return true; - const serversInGroup = getServersInGroup(groupId); + if (!group) return true; + const serversInGroup = getServersInGroup(group); return serversInGroup.includes(serverInfo.name); }); diff --git a/src/services/sseService.ts b/src/services/sseService.ts index 067e903..aec8574 100644 --- a/src/services/sseService.ts +++ b/src/services/sseService.ts @@ -1,33 +1,48 @@ import { Request, Response } from 'express'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { getMcpServer } from './mcpService.js'; +import { loadSettings } from '../config/index.js'; -const transports: { [sessionId: string]: { transport: SSEServerTransport; groupId: string } } = {}; +const transports: { [sessionId: string]: { transport: SSEServerTransport; group: string } } = {}; -export const getGroupId = (sessionId: string): string => { - return transports[sessionId]?.groupId || ''; +export const getGroup = (sessionId: string): string => { + return transports[sessionId]?.group || ''; }; export const handleSseConnection = async (req: Request, res: Response): Promise => { + const settings = loadSettings(); + const routingConfig = settings.systemConfig?.routing || { + enableGlobalRoute: true, + enableGroupNameRoute: true, + }; + const group = req.params.group; + + // Check if this is a global route (no group) and if it's allowed + if (!group && !routingConfig.enableGlobalRoute) { + res.status(403).send('Global routes are disabled. Please specify a group ID.'); + return; + } + const transport = new SSEServerTransport('/messages', res); - const groupId = req.params.groupId; - transports[transport.sessionId] = { transport, groupId }; + transports[transport.sessionId] = { transport, group: group }; res.on('close', () => { delete transports[transport.sessionId]; console.log(`SSE connection closed: ${transport.sessionId}`); }); - console.log(`New SSE connection established: ${transport.sessionId}`); + console.log( + `New SSE connection established: ${transport.sessionId} with group: ${group || 'global'}`, + ); await getMcpServer().connect(transport); }; export const handleSseMessage = async (req: Request, res: Response): Promise => { const sessionId = req.query.sessionId as string; - const { transport, groupId } = transports[sessionId]; - req.params.groupId = groupId; - req.query.groupId = groupId; - console.log(`Received message for sessionId: ${sessionId} in groupId: ${groupId}`); + const { transport, group } = transports[sessionId]; + req.params.group = group; + req.query.group = group; + console.log(`Received message for sessionId: ${sessionId} in group: ${group}`); if (transport) { await transport.handlePostMessage(req, res); } else { diff --git a/src/types/index.ts b/src/types/index.ts index 792c089..8f47fba 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -78,6 +78,13 @@ export interface McpSettings { [key: string]: ServerConfig; // Key-value pairs of server names and their configurations }; groups?: IGroup[]; // Array of server groups + systemConfig?: { + routing?: { + enableGlobalRoute?: boolean; // Controls whether the /sse endpoint without group is enabled + enableGroupNameRoute?: boolean; // Controls whether group routing by name is allowed + }; + // Add other system configuration sections here in the future + }; } // Configuration details for an individual server