From 907bca8aac47e549e5d062dcdfa6db57c45faf1f Mon Sep 17 00:00:00 2001 From: samanhappy Date: Sun, 10 Aug 2025 17:39:34 +0800 Subject: [PATCH] Refactor cloud and market pages for improved functionality and UI consistency (#265) --- frontend/src/App.tsx | 17 +- frontend/src/components/CloudServerCard.tsx | 18 +- frontend/src/components/MarketServerCard.tsx | 141 +++-- frontend/src/components/layout/Sidebar.tsx | 9 - frontend/src/pages/CloudPage.tsx | 344 ------------ frontend/src/pages/MarketPage.tsx | 541 ++++++++++++------- frontend/src/pages/ServersPage.tsx | 9 - locales/en.json | 8 +- locales/zh.json | 8 +- 9 files changed, 473 insertions(+), 622 deletions(-) delete mode 100644 frontend/src/pages/CloudPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 980f6ea..224fe4e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom'; +import { BrowserRouter as Router, Route, Routes, Navigate, useParams } from 'react-router-dom'; import { AuthProvider } from './contexts/AuthContext'; import { ToastProvider } from './contexts/ToastContext'; import { ThemeProvider } from './contexts/ThemeContext'; @@ -12,10 +12,15 @@ import GroupsPage from './pages/GroupsPage'; import UsersPage from './pages/UsersPage'; import SettingsPage from './pages/SettingsPage'; import MarketPage from './pages/MarketPage'; -import CloudPage from './pages/CloudPage'; import LogsPage from './pages/LogsPage'; import { getBasePath } from './utils/runtime'; +// Helper component to redirect cloud server routes to market +const CloudRedirect: React.FC = () => { + const { serverName } = useParams<{ serverName: string }>(); + return ; +}; + function App() { const basename = getBasePath(); return ( @@ -36,8 +41,12 @@ function App() { } /> } /> } /> - } /> - } /> + {/* Legacy cloud routes redirect to market with cloud tab */} + } /> + } + /> } /> } /> diff --git a/frontend/src/components/CloudServerCard.tsx b/frontend/src/components/CloudServerCard.tsx index bba4e2c..c4ae318 100644 --- a/frontend/src/components/CloudServerCard.tsx +++ b/frontend/src/components/CloudServerCard.tsx @@ -60,7 +60,7 @@ const CloudServerCard: React.FC = ({ server, onClick }) => return (
{/* Background gradient overlay on hover */} @@ -68,15 +68,15 @@ const CloudServerCard: React.FC = ({ server, onClick }) => {/* Server Header */}
-
+
-

+

{server.title || server.name}

{/* Author Section */} -
-
+
+
{getAuthorInitials(server.author_name)}
@@ -99,15 +99,15 @@ const CloudServerCard: React.FC = ({ server, onClick }) =>
{/* Description */} -
-

+

+

{getDisplayDescription()}

{/* Tools Info */} {server.tools && server.tools.length > 0 && ( -
+
@@ -121,7 +121,7 @@ const CloudServerCard: React.FC = ({ server, onClick }) => )} {/* Footer - 固定在底部 */} -
+
diff --git a/frontend/src/components/MarketServerCard.tsx b/frontend/src/components/MarketServerCard.tsx index 3bd4dd5..2fdb3da 100644 --- a/frontend/src/components/MarketServerCard.tsx +++ b/frontend/src/components/MarketServerCard.tsx @@ -10,6 +10,16 @@ interface MarketServerCardProps { const MarketServerCard: React.FC = ({ server, onClick }) => { const { t } = useTranslation(); + // Get initials for avatar + const getAuthorInitials = (name: string) => { + return name + .split(' ') + .map(word => word.charAt(0)) + .join('') + .toUpperCase() + .slice(0, 2); + }; + // Intelligently calculate how many tags to display to ensure they fit in a single line const getTagsToDisplay = () => { if (!server.tags || server.tags.length === 0) { @@ -80,70 +90,89 @@ const MarketServerCard: React.FC = ({ server, onClick }) return (
onClick(server)} > -
-

{server.display_name}

- {server.is_official && ( - - {t('market.official')} - - )} -
-

{server.description}

+ {/* Background gradient overlay on hover */} +
- {/* Categories */} -
- {server.categories?.length > 0 ? ( - server.categories.map((category, index) => ( - - {category} - - )) - ) : ( - - - )} -
+ {/* Server Header */} +
+
+
+

+ {server.display_name} +

- {/* Tags */} -
- {server.tags?.length > 0 ? ( -
- {tagsToShow.map((tag, index) => ( - - #{tag} - - ))} - {hasMore && ( - - +{moreCount} {t('market.moreTags')} + {/* Author Section */} +
+
+ {getAuthorInitials(server.author?.name || t('market.unknown'))} +
+
+

{server.author?.name || t('market.unknown')}

+
+
+
+ + {/* Server Type Badge */} +
+ {server.is_official && ( + + {t('market.official')} )}
- ) : ( - - - )} -
- -
-
- {t('market.by')} - - {server.author?.name || t('market.unknown')} -
-
- - - - {server.tools?.length || 0} {t('market.tools')} + + {/* Description */} +
+

+ {server.description} +

+
+ + {/* Categories */} +
+
+ {server.categories?.length > 0 ? ( + server.categories.map((category, index) => ( + + {category} + + )) + ) : ( + - + )} +
+
+ + {/* Tags */} +
+
+ {server.tags?.length > 0 ? ( +
+ {tagsToShow.map((tag, index) => ( + + #{tag} + + ))} + {hasMore && ( + + +{moreCount} {t('market.moreTags')} + + )} +
+ ) : ( + - + )} +
diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 8896776..9eeab97 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -61,15 +61,6 @@ const Sidebar: React.FC = ({ collapsed }) => { ), }] : []), - { - path: '/cloud', - label: t('nav.cloud'), - icon: ( - - - - ), - }, { path: '/market', label: t('nav.market'), diff --git a/frontend/src/pages/CloudPage.tsx b/frontend/src/pages/CloudPage.tsx deleted file mode 100644 index 0b2371c..0000000 --- a/frontend/src/pages/CloudPage.tsx +++ /dev/null @@ -1,344 +0,0 @@ -import React, { useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useNavigate, useParams } from 'react-router-dom'; -import { CloudServer, ServerConfig } from '@/types'; -import { useCloudData } from '@/hooks/useCloudData'; -import { useToast } from '@/contexts/ToastContext'; -import { apiPost } from '@/utils/fetchInterceptor'; -import CloudServerCard from '@/components/CloudServerCard'; -import CloudServerDetail from '@/components/CloudServerDetail'; -import MCPRouterApiKeyError from '@/components/MCPRouterApiKeyError'; -import Pagination from '@/components/ui/Pagination'; - -const CloudPage: React.FC = () => { - const { t } = useTranslation(); - const navigate = useNavigate(); - const { serverName } = useParams<{ serverName?: string }>(); - const { showToast } = useToast(); - const [installing, setInstalling] = useState(false); - const [installedServers, setInstalledServers] = useState>(new Set()); - - const { - servers, - allServers, - // categories, - loading, - error, - setError, - // searchServers, - // filterByCategory, - // filterByTag, - // selectedCategory, - // selectedTag, - fetchServerTools, - callServerTool, - // Pagination - currentPage, - totalPages, - changePage, - serversPerPage, - changeServersPerPage - } = useCloudData(); - - // const [searchQuery, setSearchQuery] = useState(''); - - // const handleSearch = (e: React.FormEvent) => { - // e.preventDefault(); - // searchServers(searchQuery); - // }; - - // const handleCategoryClick = (category: string) => { - // filterByCategory(category); - // }; - - // const handleClearFilters = () => { - // setSearchQuery(''); - // filterByCategory(''); - // filterByTag(''); - // }; - - const handleServerClick = (server: CloudServer) => { - navigate(`/cloud/${server.name}`); - }; - - const handleBackToList = () => { - navigate('/cloud'); - }; - - const handleCallTool = async (serverName: string, toolName: string, args: Record) => { - try { - const result = await callServerTool(serverName, toolName, args); - showToast(t('cloud.toolCallSuccess', { toolName }), 'success'); - return result; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - // Don't show toast for API key errors, let the component handle it - if (!isMCPRouterApiKeyError(errorMessage)) { - showToast(t('cloud.toolCallError', { toolName, error: errorMessage }), 'error'); - } - throw error; - } - }; - - // Helper function to check if error is MCPRouter API key not configured - const isMCPRouterApiKeyError = (errorMessage: string) => { - return errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' || - errorMessage.toLowerCase().includes('mcprouter api key not configured'); - }; - - const handlePageChange = (page: number) => { - changePage(page); - // Scroll to top of page when changing pages - window.scrollTo({ top: 0, behavior: 'smooth' }); - }; - - const handleChangeItemsPerPage = (e: React.ChangeEvent) => { - const newValue = parseInt(e.target.value, 10); - changeServersPerPage(newValue); - }; - - // Handle cloud server installation - const handleInstallCloudServer = async (server: CloudServer, config: ServerConfig) => { - try { - setInstalling(true); - - const payload = { - name: server.name, - config: config - }; - - const result = await apiPost('/servers', payload); - - if (!result.success) { - const errorMessage = result?.message || t('server.addError'); - showToast(errorMessage, 'error'); - return; - } - - // Update installed servers set - setInstalledServers(prev => new Set(prev).add(server.name)); - showToast(t('cloud.installSuccess', { name: server.title || server.name }), 'success'); - - } catch (error) { - console.error('Error installing cloud server:', error); - const errorMessage = error instanceof Error ? error.message : String(error); - showToast(t('cloud.installError', { error: errorMessage }), 'error'); - } finally { - setInstalling(false); - } - }; - - // Render detailed view if a server name is in the URL - if (serverName) { - return ( - - ); - } - - return ( -
-
-
-

- {t('cloud.title')} - - {t('cloud.subtitle').includes('提供支持') ? '由 ' : 'Powered by '} - - MCPRouter - - {t('cloud.subtitle').includes('提供支持') ? ' 提供支持' : ''} - -

-
-
- - {error && ( - <> - {isMCPRouterApiKeyError(error) ? ( - - ) : ( -
-
-

{error}

- -
-
- )} - - )} - - {/* Search bar at the top -
-
-
- setSearchQuery(e.target.value)} - placeholder={t('cloud.searchPlaceholder')} - className="shadow appearance-none border border-gray-200 rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input" - /> -
- - {(searchQuery || selectedCategory || selectedTag) && ( - - )} -
-
- */} - -
- {/* Left sidebar for filters -
-
- {categories.length > 0 ? ( -
-
-

{t('cloud.categories')}

- {selectedCategory && ( - filterByCategory('')}> - {t('cloud.clearCategoryFilter')} - - )} -
-
- {categories.map((category) => ( - - ))} -
-
- ) : loading ? ( -
-
-

{t('cloud.categories')}

-
-
- - - - -

{t('app.loading')}

-
-
- ) : ( -
-
-

{t('cloud.categories')}

-
-

{t('cloud.noCategories')}

-
- )} -
-
- */} - - {/* Main content area */} -
- {loading ? ( -
-
- - - - -

{t('app.loading')}

-
-
- ) : servers.length === 0 ? ( -
-

{t('cloud.noServers')}

-
- ) : ( - <> -
- {servers.map((server, index) => ( - - ))} -
- -
-
- {t('cloud.showing', { - from: (currentPage - 1) * serversPerPage + 1, - to: Math.min(currentPage * serversPerPage, allServers.length), - total: allServers.length - })} -
- -
- - -
-
- -
-
- - )} -
-
-
- ); -}; - -export default CloudPage; diff --git a/frontend/src/pages/MarketPage.tsx b/frontend/src/pages/MarketPage.tsx index 79cd01e..f042eb7 100644 --- a/frontend/src/pages/MarketPage.tsx +++ b/frontend/src/pages/MarketPage.tsx @@ -1,11 +1,16 @@ import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { useNavigate, useParams } from 'react-router-dom'; -import { MarketServer, ServerConfig } from '@/types'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { MarketServer, CloudServer, ServerConfig } from '@/types'; import { useMarketData } from '@/hooks/useMarketData'; +import { useCloudData } from '@/hooks/useCloudData'; import { useToast } from '@/contexts/ToastContext'; +import { apiPost } from '@/utils/fetchInterceptor'; import MarketServerCard from '@/components/MarketServerCard'; import MarketServerDetail from '@/components/MarketServerDetail'; +import CloudServerCard from '@/components/CloudServerCard'; +import CloudServerDetail from '@/components/CloudServerDetail'; +import MCPRouterApiKeyError from '@/components/MCPRouterApiKeyError'; import Pagination from '@/components/ui/Pagination'; const MarketPage: React.FC = () => { @@ -14,82 +19,140 @@ const MarketPage: React.FC = () => { const { serverName } = useParams<{ serverName?: string }>(); const { showToast } = useToast(); + // Get tab from URL search params, default to cloud market + const [searchParams, setSearchParams] = useSearchParams(); + const currentTab = searchParams.get('tab') || 'cloud'; + + // Local market data const { - servers, - allServers, - categories, - loading, - error, - setError, - searchServers, - filterByCategory, - filterByTag, - selectedCategory, - selectedTag, - installServer, - fetchServerByName, + servers: localServers, + allServers: allLocalServers, + categories: localCategories, + loading: localLoading, + error: localError, + setError: setLocalError, + searchServers: searchLocalServers, + filterByCategory: filterLocalByCategory, + filterByTag: filterLocalByTag, + selectedCategory: selectedLocalCategory, + selectedTag: selectedLocalTag, + installServer: installLocalServer, + fetchServerByName: fetchLocalServerByName, isServerInstalled, // Pagination - currentPage, - totalPages, - changePage, - serversPerPage, - changeServersPerPage + currentPage: localCurrentPage, + totalPages: localTotalPages, + changePage: changeLocalPage, + serversPerPage: localServersPerPage, + changeServersPerPage: changeLocalServersPerPage } = useMarketData(); + // Cloud market data + const { + servers: cloudServers, + allServers: allCloudServers, + loading: cloudLoading, + error: cloudError, + setError: setCloudError, + fetchServerTools, + callServerTool, + // Pagination + currentPage: cloudCurrentPage, + totalPages: cloudTotalPages, + changePage: changeCloudPage, + serversPerPage: cloudServersPerPage, + changeServersPerPage: changeCloudServersPerPage + } = useCloudData(); + const [selectedServer, setSelectedServer] = useState(null); + const [selectedCloudServer, setSelectedCloudServer] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [installing, setInstalling] = useState(false); + const [installedCloudServers, setInstalledCloudServers] = useState>(new Set()); // Load server details if a server name is in the URL useEffect(() => { const loadServerDetails = async () => { if (serverName) { - const server = await fetchServerByName(serverName); - if (server) { - setSelectedServer(server); + // Determine if it's a cloud or local server based on the current tab + if (currentTab === 'cloud') { + // Try to find the server in cloud servers + const server = cloudServers.find(s => s.name === serverName); + if (server) { + setSelectedCloudServer(server); + } else { + // If server not found, navigate back to market page + navigate('/market?tab=cloud'); + } } else { - // If server not found, navigate back to market page - navigate('/market'); + // Local market + const server = await fetchLocalServerByName(serverName); + if (server) { + setSelectedServer(server); + } else { + // If server not found, navigate back to market page + navigate('/market?tab=local'); + } } } else { setSelectedServer(null); + setSelectedCloudServer(null); } }; loadServerDetails(); - }, [serverName, fetchServerByName, navigate]); + }, [serverName, currentTab, cloudServers, fetchLocalServerByName, navigate]); + + // Tab switching handler + const switchTab = (tab: 'local' | 'cloud') => { + const newSearchParams = new URLSearchParams(searchParams); + newSearchParams.set('tab', tab); + setSearchParams(newSearchParams); + // Clear any selected server when switching tabs + if (serverName) { + navigate('/market?' + newSearchParams.toString()); + } + }; const handleSearch = (e: React.FormEvent) => { e.preventDefault(); - searchServers(searchQuery); + if (currentTab === 'local') { + searchLocalServers(searchQuery); + } + // Cloud search is not implemented in the original cloud page }; const handleCategoryClick = (category: string) => { - filterByCategory(category); + if (currentTab === 'local') { + filterLocalByCategory(category); + } }; const handleClearFilters = () => { setSearchQuery(''); - filterByCategory(''); - filterByTag(''); + if (currentTab === 'local') { + filterLocalByCategory(''); + filterLocalByTag(''); + } }; - const handleServerClick = (server: MarketServer) => { - navigate(`/market/${server.name}`); + const handleServerClick = (server: MarketServer | CloudServer) => { + if (currentTab === 'cloud') { + navigate(`/market/${server.name}?tab=cloud`); + } else { + navigate(`/market/${server.name}?tab=local`); + } }; const handleBackToList = () => { - navigate('/market'); + navigate(`/market?tab=${currentTab}`); }; - const handleInstall = async (server: MarketServer, config: ServerConfig) => { + const handleLocalInstall = async (server: MarketServer, config: ServerConfig) => { try { setInstalling(true); - // Pass the server object and the config to the installServer function - const success = await installServer(server, config); + const success = await installLocalServer(server, config); if (success) { - // Show success message using toast instead of alert showToast(t('market.installSuccess', { serverName: server.display_name }), 'success'); } } finally { @@ -97,15 +160,75 @@ const MarketPage: React.FC = () => { } }; + // Handle cloud server installation + const handleCloudInstall = async (server: CloudServer, config: ServerConfig) => { + try { + setInstalling(true); + + const payload = { + name: server.name, + config: config + }; + + const result = await apiPost('/servers', payload); + + if (!result.success) { + const errorMessage = result?.message || t('server.addError'); + showToast(errorMessage, 'error'); + return; + } + + // Update installed servers set + setInstalledCloudServers(prev => new Set(prev).add(server.name)); + showToast(t('cloud.installSuccess', { name: server.title || server.name }), 'success'); + + } catch (error) { + console.error('Error installing cloud server:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + showToast(t('cloud.installError', { error: errorMessage }), 'error'); + } finally { + setInstalling(false); + } + }; + + const handleCallTool = async (serverName: string, toolName: string, args: Record) => { + try { + const result = await callServerTool(serverName, toolName, args); + showToast(t('cloud.toolCallSuccess', { toolName }), 'success'); + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + // Don't show toast for API key errors, let the component handle it + if (!isMCPRouterApiKeyError(errorMessage)) { + showToast(t('cloud.toolCallError', { toolName, error: errorMessage }), 'error'); + } + throw error; + } + }; + + // Helper function to check if error is MCPRouter API key not configured + const isMCPRouterApiKeyError = (errorMessage: string) => { + return errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' || + errorMessage.toLowerCase().includes('mcprouter api key not configured'); + }; + const handlePageChange = (page: number) => { - changePage(page); + if (currentTab === 'local') { + changeLocalPage(page); + } else { + changeCloudPage(page); + } // Scroll to top of page when changing pages window.scrollTo({ top: 0, behavior: 'smooth' }); }; const handleChangeItemsPerPage = (e: React.ChangeEvent) => { const newValue = parseInt(e.target.value, 10); - changeServersPerPage(newValue); + if (currentTab === 'local') { + changeLocalServersPerPage(newValue); + } else { + changeCloudServersPerPage(newValue); + } }; // Render detailed view if a server is selected @@ -114,164 +237,201 @@ const MarketPage: React.FC = () => { ); } + // Render cloud server detail if selected + if (selectedCloudServer) { + return ( + + ); + } + + // Get current data based on active tab + const isLocalTab = currentTab === 'local'; + const servers = isLocalTab ? localServers : cloudServers; + const allServers = isLocalTab ? allLocalServers : allCloudServers; + const categories = isLocalTab ? localCategories : []; + const loading = isLocalTab ? localLoading : cloudLoading; + const error = isLocalTab ? localError : cloudError; + const setError = isLocalTab ? setLocalError : setCloudError; + const selectedCategory = isLocalTab ? selectedLocalCategory : ''; + const selectedTag = isLocalTab ? selectedLocalTag : ''; + const currentPage = isLocalTab ? localCurrentPage : cloudCurrentPage; + const totalPages = isLocalTab ? localTotalPages : cloudTotalPages; + const serversPerPage = isLocalTab ? localServersPerPage : cloudServersPerPage; + return (
-
-
-

- {t('market.title')} - {t('pages.market.title').split(' - ')[1]} -

+ {/* Tab Navigation */} +
+
+
{error && ( -
-
-

{error}

+ <> + {!isLocalTab && isMCPRouterApiKeyError(error) ? ( + + ) : ( +
+
+

{error}

+ +
+
+ )} + + )} + + {/* Search bar for local market only */} + {isLocalTab && ( +
+
+
+ setSearchQuery(e.target.value)} + placeholder={t('market.searchPlaceholder')} + className="shadow appearance-none border border-gray-200 rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input" + /> +
-
+ {(searchQuery || selectedCategory || selectedTag) && ( + + )} +
)} - {/* Search bar at the top */} -
-
-
- setSearchQuery(e.target.value)} - placeholder={t('market.searchPlaceholder')} - className="shadow appearance-none border border-gray-200 rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input" - /> -
- - {(searchQuery || selectedCategory || selectedTag) && ( - - )} -
-
-
- {/* Left sidebar for filters (without search) */} -
-
- {/* Categories */} - {categories.length > 0 ? ( -
-
-

{t('market.categories')}

- {selectedCategory && ( - filterByCategory('')}> - {t('market.clearCategoryFilter')} - - )} -
-
- {categories.map((category) => ( - - ))} -
-
- ) : loading ? ( -
-
-

{t('market.categories')}

-
-
- - - - -

{t('app.loading')}

-
-
- ) : ( -
-
-

{t('market.categories')}

-
-

{t('market.noCategories')}

-
- )} - - {/* Tags */} - {/* {tags.length > 0 && ( -
-
-
-

{t('market.tags')}

- + {/* Left sidebar for filters (local market only) */} + {isLocalTab && ( +
+
+ {/* Categories */} + {categories.length > 0 ? ( +
+
+

{t('market.categories')}

+ {selectedCategory && ( + filterLocalByCategory('')}> + {t('market.clearCategoryFilter')} + + )}
- {selectedTag && ( - filterByTag('')}> - {t('market.clearTagFilter')} - - )} -
- {showTags && ( -
- {tags.map((tag) => ( +
+ {categories.map((category) => ( ))}
- )} -
- )} */} +
+ ) : loading ? ( +
+
+

{t('market.categories')}

+
+
+ + + + +

{t('app.loading')}

+
+
+ ) : ( +
+
+

{t('market.categories')}

+
+

{t('market.noCategories')}

+
+ )} +
-
+ )} {/* Main content area */}
@@ -287,27 +447,43 @@ const MarketPage: React.FC = () => {
) : servers.length === 0 ? (
-

{t('market.noServers')}

+

{isLocalTab ? t('market.noServers') : t('cloud.noServers')}

) : ( <>
{servers.map((server, index) => ( - + isLocalTab ? ( + + ) : ( + + ) ))}
- {t('market.showing', { - from: (currentPage - 1) * serversPerPage + 1, - to: Math.min(currentPage * serversPerPage, allServers.length), - total: allServers.length - })} + {isLocalTab ? ( + t('market.showing', { + from: (currentPage - 1) * serversPerPage + 1, + to: Math.min(currentPage * serversPerPage, allServers.length), + total: allServers.length + }) + ) : ( + t('cloud.showing', { + from: (currentPage - 1) * serversPerPage + 1, + to: Math.min(currentPage * serversPerPage, allServers.length), + total: allServers.length + }) + )}
{ />