From 0be6c36e120ee842ed3578983c9b0f978cb47eb2 Mon Sep 17 00:00:00 2001 From: Zhyim <32584979+zhyim0712@users.noreply.github.com> Date: Thu, 1 Jan 2026 13:36:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20pagination=20for=20server?= =?UTF-8?q?=20list=20with=20customizable=20items=20pe=E2=80=A6=20(#534)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/ui/Pagination.tsx | 18 ++++-- frontend/src/contexts/ServerContext.tsx | 67 +++++++++++++++++-- frontend/src/pages/ServersPage.tsx | 79 +++++++++++++++++++---- locales/en.json | 4 ++ locales/fr.json | 4 ++ locales/tr.json | 4 ++ locales/zh.json | 4 ++ src/controllers/serverController.ts | 61 +++++++++++++++-- src/dao/ServerDao.ts | 76 ++++++++++++++++++++++ src/dao/ServerDaoDbImpl.ts | 28 +++++++- src/db/repositories/ServerRepository.ts | 35 ++++++++++ src/services/mcpService.ts | 47 ++++++++++---- 12 files changed, 384 insertions(+), 43 deletions(-) diff --git a/frontend/src/components/ui/Pagination.tsx b/frontend/src/components/ui/Pagination.tsx index 2d9e07a..846b0b5 100644 --- a/frontend/src/components/ui/Pagination.tsx +++ b/frontend/src/components/ui/Pagination.tsx @@ -1,16 +1,20 @@ import React from 'react'; +import { useTranslation } from 'react-i18next'; interface PaginationProps { currentPage: number; totalPages: number; onPageChange: (page: number) => void; + disabled?: boolean; } const Pagination: React.FC = ({ currentPage, totalPages, - onPageChange + onPageChange, + disabled = false }) => { + const { t } = useTranslation(); // Generate page buttons const getPageButtons = () => { const buttons = []; @@ -95,26 +99,26 @@ const Pagination: React.FC = ({
{getPageButtons()}
); diff --git a/frontend/src/contexts/ServerContext.tsx b/frontend/src/contexts/ServerContext.tsx index 0ac5159..3d3f25f 100644 --- a/frontend/src/contexts/ServerContext.tsx +++ b/frontend/src/contexts/ServerContext.tsx @@ -17,6 +17,16 @@ const CONFIG = { }, }; +// Pagination info type +interface PaginationInfo { + page: number; + limit: number; + total: number; + totalPages: number; + hasNextPage: boolean; + hasPrevPage: boolean; +} + // Context type definition interface ServerContextType { servers: Server[]; @@ -24,6 +34,11 @@ interface ServerContextType { setError: (error: string | null) => void; isLoading: boolean; fetchAttempts: number; + pagination: PaginationInfo | null; + currentPage: number; + serversPerPage: number; + setCurrentPage: (page: number) => void; + setServersPerPage: (limit: number) => void; triggerRefresh: () => void; refreshIfNeeded: () => void; // Smart refresh with debounce handleServerAdd: () => void; @@ -45,6 +60,9 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr const [refreshKey, setRefreshKey] = useState(0); const [isInitialLoading, setIsInitialLoading] = useState(true); const [fetchAttempts, setFetchAttempts] = useState(0); + const [pagination, setPagination] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [serversPerPage, setServersPerPage] = useState(10); // Timer reference for polling const intervalRef = useRef(null); @@ -73,18 +91,31 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr const fetchServers = async () => { try { console.log('[ServerContext] Fetching servers from API...'); - const data = await apiGet('/servers'); + // Build query parameters for pagination + const params = new URLSearchParams(); + params.append('page', currentPage.toString()); + params.append('limit', serversPerPage.toString()); + const data = await apiGet(`/servers?${params.toString()}`); // Update last fetch time lastFetchTimeRef.current = Date.now(); if (data && data.success && Array.isArray(data.data)) { setServers(data.data); + // Update pagination info if available + if (data.pagination) { + setPagination(data.pagination); + } else { + setPagination(null); + } } else if (data && Array.isArray(data)) { + // Compatibility handling for non-paginated responses setServers(data); + setPagination(null); } else { console.error('Invalid server data format:', data); setServers([]); + setPagination(null); } // Reset error state @@ -114,7 +145,7 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr // Set up regular polling intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval); }, - [t], + [t, currentPage, serversPerPage], ); // Watch for authentication status changes @@ -150,7 +181,11 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr const fetchInitialData = async () => { try { console.log('[ServerContext] Initial fetch - attempt', attemptsRef.current + 1); - const data = await apiGet('/servers'); + // Build query parameters for pagination + const params = new URLSearchParams(); + params.append('page', currentPage.toString()); + params.append('limit', serversPerPage.toString()); + const data = await apiGet(`/servers?${params.toString()}`); // Update last fetch time lastFetchTimeRef.current = Date.now(); @@ -158,6 +193,12 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr // Handle API response wrapper object, extract data field if (data && data.success && Array.isArray(data.data)) { setServers(data.data); + // Update pagination info if available + if (data.pagination) { + setPagination(data.pagination); + } else { + setPagination(null); + } setIsInitialLoading(false); // Initialization successful, start normal polling (skip immediate to avoid duplicate fetch) startNormalPolling({ immediate: false }); @@ -165,6 +206,7 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr } else if (data && Array.isArray(data)) { // Compatibility handling, if API directly returns array setServers(data); + setPagination(null); setIsInitialLoading(false); // Initialization successful, start normal polling (skip immediate to avoid duplicate fetch) startNormalPolling({ immediate: false }); @@ -173,6 +215,7 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr // If data format is not as expected, set to empty array console.error('Invalid server data format:', data); setServers([]); + setPagination(null); setIsInitialLoading(false); // Initialization successful but data is empty, start normal polling (skip immediate) startNormalPolling({ immediate: false }); @@ -227,7 +270,7 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr return () => { clearTimer(); }; - }, [refreshKey, t, isInitialLoading, startNormalPolling]); + }, [refreshKey, t, isInitialLoading, startNormalPolling, currentPage, serversPerPage]); // Manually trigger refresh (always refreshes) const triggerRefresh = useCallback(() => { @@ -383,12 +426,28 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr [t, triggerRefresh], ); + // Handle page change + const handlePageChange = useCallback((page: number) => { + setCurrentPage(page); + }, []); + + // Handle servers per page change + const handleServersPerPageChange = useCallback((limit: number) => { + setServersPerPage(limit); + setCurrentPage(1); // Reset to first page when changing page size + }, []); + const value: ServerContextType = { servers, error, setError, isLoading: isInitialLoading, fetchAttempts, + pagination, + currentPage, + serversPerPage, + setCurrentPage: handlePageChange, + setServersPerPage: handleServersPerPageChange, triggerRefresh, refreshIfNeeded, handleServerAdd, diff --git a/frontend/src/pages/ServersPage.tsx b/frontend/src/pages/ServersPage.tsx index f685c2e..6f31bbd 100644 --- a/frontend/src/pages/ServersPage.tsx +++ b/frontend/src/pages/ServersPage.tsx @@ -8,6 +8,7 @@ import EditServerForm from '@/components/EditServerForm'; import { useServerData } from '@/hooks/useServerData'; import DxtUploadForm from '@/components/DxtUploadForm'; import JSONImportForm from '@/components/JSONImportForm'; +import Pagination from '@/components/ui/Pagination'; const ServersPage: React.FC = () => { const { t } = useTranslation(); @@ -17,6 +18,11 @@ const ServersPage: React.FC = () => { error, setError, isLoading, + pagination, + currentPage, + serversPerPage, + setCurrentPage, + setServersPerPage, handleServerAdd, handleServerEdit, handleServerRemove, @@ -151,19 +157,66 @@ const ServersPage: React.FC = () => {

{t('app.noServers')}

) : ( -
- {servers.map((server, index) => ( - - ))} -
+ <> +
+ {servers.map((server, index) => ( + + ))} +
+ +
+
+ {pagination ? ( + t('common.showing', { + start: (pagination.page - 1) * pagination.limit + 1, + end: Math.min(pagination.page * pagination.limit, pagination.total), + total: pagination.total + }) + ) : ( + t('common.showing', { + start: 1, + end: servers.length, + total: servers.length + }) + )} +
+
+ {pagination && pagination.totalPages > 1 && ( + + )} +
+
+ + +
+
+ )} {editingServer && ( diff --git a/locales/en.json b/locales/en.json index 8c58c00..be95c5a 100644 --- a/locales/en.json +++ b/locales/en.json @@ -248,6 +248,10 @@ "wechat": "WeChat", "discord": "Discord", "required": "Required", + "itemsPerPage": "Items per page", + "showing": "Showing {{start}}-{{end}} of {{total}}", + "previous": "Previous", + "next": "Next", "secret": "Secret", "default": "Default", "value": "Value", diff --git a/locales/fr.json b/locales/fr.json index 29906f3..cbfc106 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -248,6 +248,10 @@ "github": "GitHub", "wechat": "WeChat", "discord": "Discord", + "itemsPerPage": "Éléments par page", + "showing": "Affichage de {{start}}-{{end}} sur {{total}}", + "previous": "Précédent", + "next": "Suivant", "required": "Requis", "secret": "Secret", "default": "Défaut", diff --git a/locales/tr.json b/locales/tr.json index 2576485..367298f 100644 --- a/locales/tr.json +++ b/locales/tr.json @@ -248,6 +248,10 @@ "github": "GitHub", "wechat": "WeChat", "discord": "Discord", + "itemsPerPage": "Sayfa başına öğe", + "showing": "{{total}} öğeden {{start}}-{{end}} gösteriliyor", + "previous": "Önceki", + "next": "Sonraki", "required": "Gerekli", "secret": "Gizli", "default": "Varsayılan", diff --git a/locales/zh.json b/locales/zh.json index c5848d5..e0d9998 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -248,6 +248,10 @@ "dismiss": "忽略", "github": "GitHub", "wechat": "微信", + "itemsPerPage": "每页显示", + "showing": "显示第 {{start}}-{{end}} 条,共 {{total}} 条", + "previous": "上一页", + "next": "下一页", "discord": "Discord", "required": "必填", "secret": "敏感", diff --git a/src/controllers/serverController.ts b/src/controllers/serverController.ts index 50ddf88..dbe05ab 100644 --- a/src/controllers/serverController.ts +++ b/src/controllers/serverController.ts @@ -7,6 +7,7 @@ import { BatchCreateServersResponse, BatchServerResult, ServerConfig, + ServerInfo, } from '../types/index.js'; import { getServersInfo, @@ -24,13 +25,66 @@ import { createSafeJSON } from '../utils/serialization.js'; import { cloneDefaultOAuthServerConfig } from '../constants/oauthServerDefaults.js'; import { getServerDao, getGroupDao, getSystemConfigDao } from '../dao/DaoFactory.js'; import { getBearerKeyDao } from '../dao/DaoFactory.js'; +import { UserContextService } from '../services/userContextService.js'; -export const getAllServers = async (_: Request, res: Response): Promise => { +export const getAllServers = async (req: Request, res: Response): Promise => { try { - const serversInfo = await getServersInfo(); + // Parse pagination parameters from query string + const page = req.query.page ? parseInt(req.query.page as string, 10) : 1; + const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined; + + // Validate pagination parameters + if (page < 1) { + res.status(400).json({ + success: false, + message: 'Page number must be greater than 0', + }); + return; + } + + if (limit !== undefined && (limit < 1 || limit > 1000)) { + res.status(400).json({ + success: false, + message: 'Limit must be between 1 and 1000', + }); + return; + } + + // Get current user for filtering + const currentUser = UserContextService.getInstance().getCurrentUser(); + const isAdmin = !currentUser || currentUser.isAdmin; + + // Get servers info with pagination if limit is specified + let serversInfo: Omit[]; + let pagination = undefined; + + if (limit !== undefined) { + // Use DAO layer pagination with proper filtering + const serverDao = getServerDao(); + const paginatedResult = isAdmin + ? await serverDao.findAllPaginated(page, limit) + : await serverDao.findByOwnerPaginated(currentUser!.username, page, limit); + + // Get runtime info for paginated servers + serversInfo = await getServersInfo(page, limit, currentUser); + + pagination = { + page: paginatedResult.page, + limit: paginatedResult.limit, + total: paginatedResult.total, + totalPages: paginatedResult.totalPages, + hasNextPage: paginatedResult.page < paginatedResult.totalPages, + hasPrevPage: paginatedResult.page > 1, + }; + } else { + // No pagination, get all servers (will be filtered by mcpService) + serversInfo = await getServersInfo(); + } + const response: ApiResponse = { success: true, data: createSafeJSON(serversInfo), + ...(pagination && { pagination }), }; res.json(response); } catch (error) { @@ -564,10 +618,9 @@ export const updateServer = async (req: Request, res: Response): Promise = }); } } catch (error) { - console.error('Failed to update server:', error); res.status(500).json({ success: false, - message: error instanceof Error ? error.message : 'Internal server error', + message: 'Internal server error', }); } }; diff --git a/src/dao/ServerDao.ts b/src/dao/ServerDao.ts index 50eb102..1af8442 100644 --- a/src/dao/ServerDao.ts +++ b/src/dao/ServerDao.ts @@ -2,10 +2,31 @@ import { ServerConfig } from '../types/index.js'; import { BaseDao } from './base/BaseDao.js'; import { JsonFileBaseDao } from './base/JsonFileBaseDao.js'; +/** + * Pagination result interface + */ +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + /** * Server DAO interface with server-specific operations */ export interface ServerDao extends BaseDao { + /** + * Find all servers with pagination + */ + findAllPaginated(page: number, limit: number): Promise>; + + /** + * Find servers by owner with pagination + */ + findByOwnerPaginated(owner: string, page: number, limit: number): Promise>; + /** * Find servers by owner */ @@ -176,6 +197,61 @@ export class ServerDaoImpl extends JsonFileBaseDao implements ServerDao { return servers.length; } + async findAllPaginated(page: number, limit: number): Promise> { + const allServers = await this.getAll(); + // Sort: enabled servers first, then by creation time + const sortedServers = allServers.sort((a, b) => { + const aEnabled = a.enabled !== false; + const bEnabled = b.enabled !== false; + if (aEnabled !== bEnabled) { + return aEnabled ? -1 : 1; + } + return 0; // Keep original order for same enabled status + }); + + const total = sortedServers.length; + const totalPages = Math.ceil(total / limit); + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + const data = sortedServers.slice(startIndex, endIndex); + + return { + data, + total, + page, + limit, + totalPages, + }; + } + + async findByOwnerPaginated(owner: string, page: number, limit: number): Promise> { + const allServers = await this.getAll(); + const filteredServers = allServers.filter((server) => server.owner === owner); + // Sort: enabled servers first, then by creation time + const sortedServers = filteredServers.sort((a, b) => { + const aEnabled = a.enabled !== false; + const bEnabled = b.enabled !== false; + if (aEnabled !== bEnabled) { + return aEnabled ? -1 : 1; + } + return 0; // Keep original order for same enabled status + }); + + const total = sortedServers.length; + const totalPages = Math.ceil(total / limit); + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + const data = sortedServers.slice(startIndex, endIndex); + + return { + data, + total, + page, + limit, + totalPages, + }; + } + async findByOwner(owner: string): Promise { const servers = await this.getAll(); return servers.filter((server) => server.owner === owner); diff --git a/src/dao/ServerDaoDbImpl.ts b/src/dao/ServerDaoDbImpl.ts index d24c9a2..bb3ebf6 100644 --- a/src/dao/ServerDaoDbImpl.ts +++ b/src/dao/ServerDaoDbImpl.ts @@ -1,4 +1,4 @@ -import { ServerDao, ServerConfigWithName } from './index.js'; +import { ServerDao, ServerConfigWithName, PaginatedResult } from './index.js'; import { ServerRepository } from '../db/repositories/ServerRepository.js'; /** @@ -16,6 +16,32 @@ export class ServerDaoDbImpl implements ServerDao { return servers.map((s) => this.mapToServerConfig(s)); } + async findAllPaginated(page: number, limit: number): Promise> { + const { data, total } = await this.repository.findAllPaginated(page, limit); + const totalPages = Math.ceil(total / limit); + + return { + data: data.map((s) => this.mapToServerConfig(s)), + total, + page, + limit, + totalPages, + }; + } + + async findByOwnerPaginated(owner: string, page: number, limit: number): Promise> { + const { data, total } = await this.repository.findByOwnerPaginated(owner, page, limit); + const totalPages = Math.ceil(total / limit); + + return { + data: data.map((s) => this.mapToServerConfig(s)), + total, + page, + limit, + totalPages, + }; + } + async findById(name: string): Promise { const server = await this.repository.findByName(name); return server ? this.mapToServerConfig(server) : null; diff --git a/src/db/repositories/ServerRepository.ts b/src/db/repositories/ServerRepository.ts index 48c6bdd..f3aabb5 100644 --- a/src/db/repositories/ServerRepository.ts +++ b/src/db/repositories/ServerRepository.ts @@ -69,6 +69,41 @@ export class ServerRepository { return await this.repository.count(); } + /** + * Find servers with pagination + */ + async findAllPaginated(page: number, limit: number): Promise<{ data: Server[]; total: number }> { + const skip = (page - 1) * limit; + const [data, total] = await this.repository.findAndCount({ + order: { + enabled: 'DESC', // Enabled servers first + createdAt: 'ASC' // Then by creation time + }, + skip, + take: limit, + }); + + return { data, total }; + } + + /** + * Find servers by owner with pagination + */ + async findByOwnerPaginated(owner: string, page: number, limit: number): Promise<{ data: Server[]; total: number }> { + const skip = (page - 1) * limit; + const [data, total] = await this.repository.findAndCount({ + where: { owner }, + order: { + enabled: 'DESC', // Enabled servers first + createdAt: 'ASC' // Then by creation time + }, + skip, + take: limit, + }); + + return { data, total }; + } + /** * Find servers by owner */ diff --git a/src/services/mcpService.ts b/src/services/mcpService.ts index bd0a261..a14b068 100644 --- a/src/services/mcpService.ts +++ b/src/services/mcpService.ts @@ -772,9 +772,19 @@ export const registerAllTools = async (isInit: boolean, serverName?: string): Pr }; // Get all server information -export const getServersInfo = async (): Promise[]> => { - const allServers: ServerConfigWithName[] = await getServerDao().findAll(); +export const getServersInfo = async ( + page?: number, + limit?: number, + user?: any, +): Promise[]> => { const dataService = getDataService(); + + // Get paginated or all server configurations from DAO + // If pagination is used with a non-admin user, filtering is already done at DAO level + const isPaginated = limit !== undefined && page !== undefined; + const allServers: ServerConfigWithName[] = isPaginated + ? (await getServerDao().findAllPaginated(page, limit)).data + : await getServerDao().findAll(); // Ensure that servers recently added via DAO but not yet initialized in serverInfos // are still visible in the servers list. This avoids a race condition where @@ -783,10 +793,19 @@ export const getServersInfo = async (): Promise s.name)); + // Create a set of server names we're interested in (for pagination) + const requestedServerNames = new Set(allServers.map((s) => s.name)); + + // Filter serverInfos to only include requested servers if pagination is used + const filteredServerInfos = isPaginated + ? combinedServerInfos.filter((s) => requestedServerNames.has(s.name)) + : combinedServerInfos; + + // Add servers from DAO that don't have runtime info yet for (const server of allServers) { if (!existingNames.has(server.name)) { const isEnabled = server.enabled === undefined ? true : server.enabled; - combinedServerInfos.push({ + filteredServerInfos.push({ name: server.name, owner: server.owner, // Newly created servers that are enabled should appear as "connecting" @@ -802,12 +821,16 @@ export const getServersInfo = async (): Promise { + const infos = filterServerInfos + .filter((info) => requestedServerNames.has(info.name)) // Only include requested servers + .map(({ name, status, tools, prompts, createTime, error, oauth }) => { const serverConfig = allServers.find((server) => server.name === name); const enabled = serverConfig ? serverConfig.enabled !== false : true; @@ -846,12 +869,8 @@ export const getServersInfo = async (): Promise { - if (a.enabled === b.enabled) return 0; - return a.enabled ? -1 : 1; - }); + }); + // Sorting is now handled at DAO layer for consistent pagination results return infos; };