This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 3756d2f7c feat: add paginated data source and credential inventory 
(#2436)
3756d2f7c is described below

commit 3756d2f7c64d0720c5ec96dd12936859f106744d
Author: xdz997 <[email protected]>
AuthorDate: Fri Aug 21 17:44:39 2026 +0800

    feat: add paginated data source and credential inventory (#2436)
    
    * feat: consume paginated cloud credential inventory
    
    * feat: add paginated data source inventory
---
 .../persistence/MybatisPlusSettingsRepository.java |  16 +++
 .../studio/settings/SettingsController.java        |  10 ++
 .../studio/settings/SettingsRepository.java        |   3 +
 .../rocketmq/studio/settings/SettingsService.java  |  13 ++
 .../studio/settings/SettingsControllerTest.java    |  23 ++++
 .../studio/settings/SettingsServiceTest.java       |  29 +++++
 web/src/api/settings.ts                            |  19 +++
 web/src/pages/settings/CloudCredentialTab.tsx      | 134 ++++++++++++++++----
 web/src/pages/settings/DataSourceTab.tsx           | 141 +++++++++++++++++----
 .../settings/__tests__/CloudCredentialTab.test.tsx |  81 +++++++++++-
 .../settings/__tests__/DataSourceTab.test.tsx      |  43 +++++--
 11 files changed, 448 insertions(+), 64 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/persistence/MybatisPlusSettingsRepository.java
 
b/server/src/main/java/org/apache/rocketmq/studio/persistence/MybatisPlusSettingsRepository.java
index d4b427d5a..2996400f0 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/persistence/MybatisPlusSettingsRepository.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/persistence/MybatisPlusSettingsRepository.java
@@ -17,6 +17,7 @@
 package org.apache.rocketmq.studio.persistence;
 
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import lombok.extern.slf4j.Slf4j;
@@ -25,6 +26,7 @@ import 
org.apache.rocketmq.studio.persistence.entity.RmqSettings;
 import org.apache.rocketmq.studio.persistence.mapper.RmqDataSourceMapper;
 import org.apache.rocketmq.studio.persistence.mapper.RmqSettingsMapper;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.settings.DataSourceVO;
 import org.apache.rocketmq.studio.settings.GeneralSettingsVO;
 import org.apache.rocketmq.studio.settings.SettingsRepository;
@@ -127,6 +129,20 @@ public class MybatisPlusSettingsRepository implements 
SettingsRepository {
                 .collect(Collectors.toList());
     }
 
+    @Override
+    public PageResult<DataSourceVO> findDataSources(String search, String 
type, int page, int pageSize) {
+        String normalizedSearch = search == null || search.isBlank() ? null : 
search.trim();
+        String normalizedType = type == null || type.isBlank() ? null : 
type.trim();
+        QueryWrapper<RmqDataSource> query = new QueryWrapper<RmqDataSource>()
+                .like(normalizedSearch != null, "json", normalizedSearch)
+                .apply(normalizedType != null,
+                        "LOWER(json) LIKE CONCAT('%\"type\":\"', LOWER({0}), 
'\"%')", normalizedType)
+                .orderByDesc("gmt_modified", "id");
+        Page<RmqDataSource> result = dataSourceMapper.selectPage(new 
Page<>(page, pageSize), query);
+        return 
PageResult.of(result.getRecords().stream().map(this::toDataSourceVO).toList(),
+                result.getTotal(), page, pageSize);
+    }
+
     @Override
     @Transactional
     public DataSourceVO saveDataSource(DataSourceVO dataSource) {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsController.java
 
b/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsController.java
index 935a6da46..4874ccf77 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsController.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsController.java
@@ -17,6 +17,7 @@
 package org.apache.rocketmq.studio.settings;
 
 import org.apache.rocketmq.studio.common.domain.Result;
+import org.apache.rocketmq.studio.common.domain.PageResult;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
 import org.springframework.web.bind.annotation.GetMapping;
@@ -51,6 +52,15 @@ public class SettingsController {
         return Result.ok(settingsService.listDataSources());
     }
 
+    @GetMapping("/datasources/page")
+    public Result<PageResult<DataSourceVO>> listDataSources(
+            @RequestParam(required = false) String search,
+            @RequestParam(required = false) String type,
+            @RequestParam(defaultValue = "1") int page,
+            @RequestParam(defaultValue = "20") int pageSize) {
+        return Result.ok(settingsService.listDataSources(search, type, page, 
pageSize));
+    }
+
     @PostMapping("/datasources/create")
     public Result<DataSourceVO> createDataSource(@Valid @RequestBody 
DataSourceDTO request) {
         return 
Result.ok(settingsService.createDataSource(request.toDataSourceVO()));
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsRepository.java
 
b/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsRepository.java
index 3a73aaddb..bf4fba6af 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsRepository.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsRepository.java
@@ -19,6 +19,7 @@ package org.apache.rocketmq.studio.settings;
 
 import java.util.List;
 import java.util.Optional;
+import org.apache.rocketmq.studio.common.domain.PageResult;
 
 public interface SettingsRepository {
 
@@ -28,6 +29,8 @@ public interface SettingsRepository {
 
     List<DataSourceVO> findAllDataSources();
 
+    PageResult<DataSourceVO> findDataSources(String search, String type, int 
page, int pageSize);
+
     DataSourceVO saveDataSource(DataSourceVO dataSource);
 
     boolean replaceDataSource(DataSourceVO dataSource);
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsService.java 
b/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsService.java
index 27c40e085..8772c97b7 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsService.java
@@ -23,6 +23,7 @@ import 
org.apache.rocketmq.studio.auth.AuthenticatedUserContext;
 import org.apache.rocketmq.studio.audit.OperationAuditService;
 import org.apache.rocketmq.studio.cluster.metrics.MetricsBackendType;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.common.util.UrlHostGuard;
 import java.net.InetAddress;
 import java.net.UnknownHostException;
@@ -195,6 +196,18 @@ public class SettingsService {
         return settingsRepository.findAllDataSources();
     }
 
+    public PageResult<DataSourceVO> listDataSources(String search, String 
type, int page, int pageSize) {
+        if (page < 1) {
+            throw new BusinessException(400, "page must be greater than zero");
+        }
+        if (pageSize < 1 || pageSize > 100) {
+            throw new BusinessException(400, "pageSize must be between 1 and 
100");
+        }
+        log.debug("Listing data sources, search={}, type={}, page={}, 
pageSize={}",
+                search, type, page, pageSize);
+        return settingsRepository.findDataSources(search, type, page, 
pageSize);
+    }
+
 
     public DataSourceVO createDataSource(DataSourceVO dataSource) {
         if (dataSource == null) {
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsControllerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsControllerTest.java
index 85630ecd0..4ef25638b 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsControllerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsControllerTest.java
@@ -19,6 +19,7 @@ package org.apache.rocketmq.studio.settings;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.apache.rocketmq.studio.auth.AuthenticatedUserContext;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.junit.jupiter.api.Test;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
@@ -224,6 +225,28 @@ class SettingsControllerTest {
                 .andExpect(jsonPath("$.data", hasSize(0)));
     }
 
+    @Test
+    void listDataSourcesPageShouldBindFiltersAndPagination() throws Exception {
+        DataSourceVO ds1 = 
DataSourceVO.builder().key("ds-1").name("Production").type("Prometheus")
+                .url("prod:9876").status("connected").build();
+        PageResult<DataSourceVO> page = PageResult.of(List.of(ds1), 1, 2, 20);
+        when(settingsService.listDataSources("prod", "prometheus", 2, 
20)).thenReturn(page);
+
+        mockMvc.perform(get("/api/settings/datasources/page")
+                        .param("search", "prod")
+                        .param("type", "prometheus")
+                        .param("page", "2")
+                        .param("pageSize", "20"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code", is(200)))
+                .andExpect(jsonPath("$.data.total", is(1)))
+                .andExpect(jsonPath("$.data.page", is(2)))
+                .andExpect(jsonPath("$.data.size", is(20)))
+                .andExpect(jsonPath("$.data.items[0].key", is("ds-1")));
+
+        verify(settingsService).listDataSources("prod", "prometheus", 2, 20);
+    }
+
     @Test
     void createDataSourceShouldReturnCreatedSource() throws Exception {
         DataSourceVO input = DataSourceVO.builder().name("New 
DS").type("Prometheus")
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsServiceTest.java
index 2ff5fdc39..2ac9e493b 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsServiceTest.java
@@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import org.apache.rocketmq.studio.auth.AuthenticatedUserContext;
 import org.apache.rocketmq.studio.audit.OperationAuditService;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -292,6 +293,34 @@ class SettingsServiceTest {
         assertThat(result).isEmpty();
     }
 
+    @Test
+    void listDataSourcesShouldValidatePaginationBeforeRepositoryAccess() {
+        assertThatThrownBy(() -> settingsService.listDataSources(null, null, 
0, 20))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("page must be greater than zero");
+        assertThatThrownBy(() -> settingsService.listDataSources(null, null, 
1, 0))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("pageSize must be between 1 and 100");
+        assertThatThrownBy(() -> settingsService.listDataSources(null, null, 
1, 101))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("pageSize must be between 1 and 100");
+
+        verifyNoInteractions(settingsRepository);
+    }
+
+    @Test
+    void listDataSourcesShouldDelegateBoundedInventoryQuery() {
+        PageResult<DataSourceVO> page = PageResult.of(List.of(), 0, 2, 20);
+        when(settingsRepository.findDataSources(" prod ", " prometheus ", 2, 
20))
+                .thenReturn(page);
+
+        PageResult<DataSourceVO> result = settingsService.listDataSources(
+                " prod ", " prometheus ", 2, 20);
+
+        assertThat(result).isSameAs(page);
+        verify(settingsRepository).findDataSources(" prod ", " prometheus ", 
2, 20);
+    }
+
     @Test
     void createDataSourceShouldAssignKeyBeforeSaving() {
         DataSourceVO input = DataSourceVO.builder().name("New 
DS").type("rocketmq")
diff --git a/web/src/api/settings.ts b/web/src/api/settings.ts
index b221cf937..5052d95fe 100644
--- a/web/src/api/settings.ts
+++ b/web/src/api/settings.ts
@@ -57,6 +57,13 @@ export interface DataSource {
   instanceIds?: string[];
 }
 
+export interface DataSourcePage {
+  items: DataSource[];
+  total: number;
+  page: number;
+  size: number;
+}
+
 // ─── General Settings ───────────────────────────────────────────
 export async function getGeneralSettings() {
   const res = await client.get<{ data: GeneralSettings }>('/settings/general');
@@ -84,6 +91,18 @@ export async function listDataSources() {
   return res.data.data;
 }
 
+export async function listDataSourcesPage(params: {
+  search?: string;
+  type?: string;
+  page?: number;
+  pageSize?: number;
+}) {
+  const res = await client.get<{ data: DataSourcePage 
}>('/settings/datasources/page', {
+    params,
+  });
+  return res.data.data;
+}
+
 export async function createDataSource(data: Partial<DataSource>) {
   const res = await client.post<{ data: DataSource 
}>('/settings/datasources/create', data);
   return res.data.data;
diff --git a/web/src/pages/settings/CloudCredentialTab.tsx 
b/web/src/pages/settings/CloudCredentialTab.tsx
index 2cc682f4d..5846636bd 100644
--- a/web/src/pages/settings/CloudCredentialTab.tsx
+++ b/web/src/pages/settings/CloudCredentialTab.tsx
@@ -15,13 +15,13 @@
  * limitations under the License.
  */
 
-import { useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
 import {
   Button,
+  Input,
   Descriptions,
   Flex,
   Form,
-  Input,
   Modal,
   Popconfirm,
   Select,
@@ -32,6 +32,7 @@ import {
 } from 'antd';
 import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons';
 import type { ColumnsType } from 'antd/es/table';
+import { MagnifyingGlass } from '@phosphor-icons/react';
 
 import {
   createCloudCredential,
@@ -57,6 +58,8 @@ const VENDOR_OPTIONS = [
   { value: 'TENCENT', label: '腾讯云' },
 ];
 
+const PAGE_SIZE_OPTIONS = [20, 50, 100];
+
 interface CredentialFormValues {
   name: string;
   vendor: InstanceVendor;
@@ -67,28 +70,76 @@ interface CredentialFormValues {
 
 export const CloudCredentialTab = () => {
   const [credentials, setCredentials] = useState<CloudCredential[]>([]);
+  const [total, setTotal] = useState(0);
+  const [page, setPage] = useState(1);
+  const [pageSize, setPageSize] = useState(20);
+  const [vendorFilter, setVendorFilter] = useState<InstanceVendor | 
undefined>();
+  const [search, setSearch] = useState('');
+  const [debouncedSearch, setDebouncedSearch] = useState('');
   const [loading, setLoading] = useState(true);
   const [modalOpen, setModalOpen] = useState(false);
   const [editingCredential, setEditingCredential] = useState<CloudCredential | 
null>(null);
   const [form] = Form.useForm<CredentialFormValues>();
   const [submitting, setSubmitting] = useState(false);
+  const requestSeqRef = useRef(0);
+
+  useEffect(() => {
+    const timer = window.setTimeout(() => setDebouncedSearch(search.trim()), 
300);
+    return () => window.clearTimeout(timer);
+  }, [search]);
+
+  const loadCredentials = useCallback(() => {
+    const requestId = ++requestSeqRef.current;
+    Promise.resolve().then(() => {
+      if (requestId === requestSeqRef.current) {
+        setLoading(true);
+      }
+    });
+    return (async () => {
+      try {
+        const result = await listCloudCredentials(vendorFilter, 
debouncedSearch, page, pageSize);
+        if (requestId !== requestSeqRef.current) return;
+        if (result.items.length === 0 && result.total > 0 && page > 1) {
+          const lastPage = Math.max(1, Math.ceil(result.total / result.size));
+          if (page > lastPage) {
+            setPage(lastPage);
+            return;
+          }
+        }
+        setCredentials(result.items);
+        setTotal(result.total);
+      } catch {
+        if (requestId === requestSeqRef.current) {
+          message.error('云凭据加载失败,请稍后重试');
+        }
+      } finally {
+        if (requestId === requestSeqRef.current) {
+          setLoading(false);
+        }
+      }
+    })();
+  }, [debouncedSearch, page, pageSize, vendorFilter]);
 
   useEffect(() => {
-    let cancelled = false;
-    void listCloudCredentials()
-      .then((result) => {
-        if (!cancelled) setCredentials(result.items);
-      })
-      .catch(() => {
-        if (!cancelled) message.error('云凭据加载失败,请稍后重试');
-      })
-      .finally(() => {
-        if (!cancelled) setLoading(false);
-      });
-    return () => {
-      cancelled = true;
-    };
-  }, []);
+    void loadCredentials();
+  }, [loadCredentials]);
+
+  useEffect(
+    () => () => {
+      requestSeqRef.current += 1;
+    },
+    [],
+  );
+
+  const changeVendorFilter = (value?: InstanceVendor) => {
+    setVendorFilter(value);
+    setPage(1);
+  };
+
+  const changeSearch = (value: string) => {
+    setSearch(value);
+    setPage(1);
+  };
 
   const closeModal = () => {
     setModalOpen(false);
@@ -126,16 +177,17 @@ export const CloudCredentialTab = () => {
         setCredentials((previous) => previous.map((item) => (item.id === 
saved.id ? saved : item)));
         message.success('云凭据已更新');
       } else {
-        const saved = await createCloudCredential({
+        await createCloudCredential({
           name: values.name,
           vendor: values.vendor,
           accessKey: values.accessKey ?? '',
           secretKey: values.secretKey ?? '',
           remark: values.remark,
         });
-        setCredentials((previous) => [...previous, saved]);
+        setPage(1);
         message.success('云凭据已添加');
       }
+      await loadCredentials();
       closeModal();
     } catch (error) {
       if (error && typeof error === 'object' && 'errorFields' in error) {
@@ -150,7 +202,12 @@ export const CloudCredentialTab = () => {
   const handleDelete = async (credential: CloudCredential) => {
     try {
       await deleteCloudCredential(credential.id);
-      setCredentials((previous) => previous.filter((item) => item.id !== 
credential.id));
+      const remainingOnPage = credentials.length - 1;
+      if (remainingOnPage === 0 && page > 1) {
+        setPage(page - 1);
+      } else {
+        await loadCredentials();
+      }
       message.success('云凭据已删除');
     } catch {
       message.error('删除云凭据失败(可能仍被实例引用),请稍后重试');
@@ -200,7 +257,25 @@ export const CloudCredentialTab = () => {
 
   return (
     <>
-      <Flex justify="flex-end" style={{ marginBottom: 16 }}>
+      <Flex justify="space-between" align="center" gap={12} wrap style={{ 
marginBottom: 16 }}>
+        <Flex gap={12} align="center" wrap>
+          <Input
+            allowClear
+            prefix={<MagnifyingGlass size={14} color="#9CA3AF" />}
+            placeholder="搜索凭据名称"
+            style={{ width: 240 }}
+            value={search}
+            onChange={(event) => changeSearch(event.target.value)}
+          />
+          <Select<InstanceVendor>
+            allowClear
+            placeholder="全部云厂商"
+            style={{ width: 160 }}
+            value={vendorFilter}
+            onChange={changeVendorFilter}
+            options={VENDOR_OPTIONS}
+          />
+        </Flex>
         <Button type="primary" icon={<PlusOutlined />} 
onClick={openCreateModal} disabled={loading}>
           添加云凭据
         </Button>
@@ -211,7 +286,22 @@ export const CloudCredentialTab = () => {
         dataSource={credentials}
         rowKey="id"
         loading={loading}
-        pagination={false}
+        pagination={{
+          current: page,
+          pageSize,
+          total,
+          showSizeChanger: true,
+          pageSizeOptions: PAGE_SIZE_OPTIONS.map(String),
+          showTotal: (count) => `共 ${count} 条`,
+          onChange: (nextPage, nextPageSize) => {
+            if (nextPageSize !== pageSize) {
+              setPage(1);
+              setPageSize(nextPageSize);
+            } else {
+              setPage(nextPage);
+            }
+          },
+        }}
         size="middle"
       />
 
diff --git a/web/src/pages/settings/DataSourceTab.tsx 
b/web/src/pages/settings/DataSourceTab.tsx
index eb1a07ea1..ccd2d5306 100644
--- a/web/src/pages/settings/DataSourceTab.tsx
+++ b/web/src/pages/settings/DataSourceTab.tsx
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
 import {
   Button,
   Flex,
@@ -30,6 +30,7 @@ import {
   Typography,
   message,
 } from 'antd';
+import { MagnifyingGlass } from '@phosphor-icons/react';
 import { ApiOutlined, DeleteOutlined, EditOutlined, PlusOutlined } from 
'@ant-design/icons';
 import type { ColumnsType } from 'antd/es/table';
 
@@ -38,7 +39,7 @@ import StatusBadge from '../../components/StatusBadge';
 import {
   createDataSource,
   deleteDataSource,
-  listDataSources,
+  listDataSourcesPage,
   testDataSource,
   updateDataSource,
 } from '../../api/settings';
@@ -69,6 +70,8 @@ const DATA_SOURCE_TYPE_OPTIONS = [
   { value: 'ARMS', label: 'ARMS' },
 ];
 
+const PAGE_SIZE_OPTIONS = [20, 50, 100];
+
 type DataSourceFormValues = Partial<DataSource>;
 
 const secretFieldNames = ['username', 'password', 'bearerToken'] as const;
@@ -91,6 +94,12 @@ const withoutSecrets = (values: DataSourceFormValues): 
Partial<DataSource> => {
 export const DataSourceTab = () => {
   const { t } = useLang();
   const [dataSources, setDataSources] = useState<DataSource[]>([]);
+  const [total, setTotal] = useState(0);
+  const [page, setPage] = useState(1);
+  const [pageSize, setPageSize] = useState(20);
+  const [search, setSearch] = useState('');
+  const [debouncedSearch, setDebouncedSearch] = useState('');
+  const [typeFilter, setTypeFilter] = useState<string | undefined>();
   const [instances, setInstances] = useState<Instance[]>([]);
   const [loading, setLoading] = useState(true);
   const [modalOpen, setModalOpen] = useState(false);
@@ -99,24 +108,60 @@ export const DataSourceTab = () => {
   const authValue = Form.useWatch('auth', dsForm);
   const [testingKeys, setTestingKeys] = useState<Set<string>>(() => new Set());
   const [submitting, setSubmitting] = useState(false);
+  const requestSeqRef = useRef(0);
 
   useEffect(() => {
-    let cancelled = false;
-    void listDataSources()
-      .then((sources) => {
-        if (!cancelled) setDataSources(sources);
-      })
-      .catch(() => {
-        if (!cancelled) message.error('数据源加载失败,请稍后重试');
-      })
-      .finally(() => {
-        if (!cancelled) setLoading(false);
-      });
+    const timer = window.setTimeout(() => setDebouncedSearch(search.trim()), 
300);
+    return () => window.clearTimeout(timer);
+  }, [search]);
 
-    return () => {
-      cancelled = true;
-    };
-  }, []);
+  const loadDataSources = useCallback(() => {
+    const requestId = ++requestSeqRef.current;
+    Promise.resolve().then(() => {
+      if (requestId === requestSeqRef.current) {
+        setLoading(true);
+      }
+    });
+    return (async () => {
+      try {
+        const result = await listDataSourcesPage({
+          search: debouncedSearch,
+          type: typeFilter,
+          page,
+          pageSize,
+        });
+        if (requestId !== requestSeqRef.current) return;
+        if (result.items.length === 0 && result.total > 0 && page > 1) {
+          const lastPage = Math.max(1, Math.ceil(result.total / result.size));
+          if (page > lastPage) {
+            setPage(lastPage);
+            return;
+          }
+        }
+        setDataSources(result.items);
+        setTotal(result.total);
+      } catch {
+        if (requestId === requestSeqRef.current) {
+          message.error('数据源加载失败,请稍后重试');
+        }
+      } finally {
+        if (requestId === requestSeqRef.current) {
+          setLoading(false);
+        }
+      }
+    })();
+  }, [debouncedSearch, page, pageSize, typeFilter]);
+
+  useEffect(() => {
+    void loadDataSources();
+  }, [loadDataSources]);
+
+  useEffect(
+    () => () => {
+      requestSeqRef.current += 1;
+    },
+    [],
+  );
 
   useEffect(() => {
     let cancelled = false;
@@ -177,11 +222,14 @@ export const DataSourceTab = () => {
       const saved = editingDataSource
         ? await updateDataSource({ ...editingDataSource, ...dataSourceValues })
         : await createDataSource(dataSourceValues);
-      setDataSources((previous) =>
-        editingDataSource
-          ? previous.map((dataSource) => (dataSource.key === saved.key ? saved 
: dataSource))
-          : [...previous, saved],
-      );
+      if (editingDataSource) {
+        setDataSources((previous) =>
+          previous.map((dataSource) => (dataSource.key === saved.key ? saved : 
dataSource)),
+        );
+      } else {
+        setPage(1);
+      }
+      await loadDataSources();
       message.success(editingDataSource ? '数据源已更新' : '数据源已添加');
       setModalOpen(false);
       dsForm.resetFields();
@@ -198,7 +246,11 @@ export const DataSourceTab = () => {
   const handleDelete = async (dataSource: DataSource) => {
     try {
       await deleteDataSource(dataSource.key);
-      setDataSources((previous) => previous.filter((item) => item.key !== 
dataSource.key));
+      if (dataSources.length === 1 && page > 1) {
+        setPage(page - 1);
+      } else {
+        await loadDataSources();
+      }
       message.success('数据源已删除');
     } catch {
       message.error('删除数据源失败,请稍后重试');
@@ -285,7 +337,31 @@ export const DataSourceTab = () => {
 
   return (
     <>
-      <Flex justify="flex-end" style={{ marginBottom: 16 }}>
+      <Flex justify="space-between" align="center" gap={12} wrap style={{ 
marginBottom: 16 }}>
+        <Flex gap={12} align="center" wrap>
+          <Input
+            allowClear
+            prefix={<MagnifyingGlass size={14} color="#9CA3AF" />}
+            placeholder="搜索数据源名称"
+            style={{ width: 240 }}
+            value={search}
+            onChange={(event) => {
+              setSearch(event.target.value);
+              setPage(1);
+            }}
+          />
+          <Select
+            allowClear
+            placeholder="全部类型"
+            style={{ width: 160 }}
+            value={typeFilter}
+            onChange={(value) => {
+              setTypeFilter(value);
+              setPage(1);
+            }}
+            options={DATA_SOURCE_TYPE_OPTIONS}
+          />
+        </Flex>
         <Button type="primary" icon={<PlusOutlined />} 
onClick={openCreateModal} disabled={loading}>
           添加数据源
         </Button>
@@ -296,7 +372,22 @@ export const DataSourceTab = () => {
         dataSource={dataSources}
         rowKey="key"
         loading={loading}
-        pagination={false}
+        pagination={{
+          current: page,
+          pageSize,
+          total,
+          showSizeChanger: true,
+          pageSizeOptions: PAGE_SIZE_OPTIONS.map(String),
+          showTotal: (count) => `共 ${count} 条`,
+          onChange: (nextPage, nextPageSize) => {
+            if (nextPageSize !== pageSize) {
+              setPage(1);
+              setPageSize(nextPageSize);
+            } else {
+              setPage(nextPage);
+            }
+          },
+        }}
         size="middle"
       />
 
diff --git a/web/src/pages/settings/__tests__/CloudCredentialTab.test.tsx 
b/web/src/pages/settings/__tests__/CloudCredentialTab.test.tsx
index 47213b993..bcb4dde62 100644
--- a/web/src/pages/settings/__tests__/CloudCredentialTab.test.tsx
+++ b/web/src/pages/settings/__tests__/CloudCredentialTab.test.tsx
@@ -52,6 +52,14 @@ const credentials: CloudCredentialPage = {
   size: 20,
 };
 
+const deferred = <T,>() => {
+  let resolve!: (value: T) => void;
+  const promise = new Promise<T>((promiseResolve) => {
+    resolve = promiseResolve;
+  });
+  return { promise, resolve };
+};
+
 const renderTab = () =>
   render(
     <App>
@@ -91,6 +99,62 @@ describe('CloudCredentialTab', () => {
     expect(screen.getByText('阿里云')).toBeInTheDocument();
   });
 
+  it('loads the first page and sends the selected filters', async () => {
+    renderTab();
+
+    await waitFor(() => 
expect(listCloudCredentials).toHaveBeenCalledWith(undefined, '', 1, 20));
+  });
+
+  it('resets to the first page and queries filters after they change', async 
() => {
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderTab();
+    await waitFor(() => expect(listCloudCredentials).toHaveBeenCalled());
+
+    await user.click(screen.getAllByRole('combobox')[0]);
+    await user.click(
+      await screen.findByText('阿里云', { selector: 
'.ant-select-item-option-content' }),
+    );
+    await user.type(screen.getByPlaceholderText('搜索凭据名称'), 'prod');
+
+    await waitFor(() => 
expect(listCloudCredentials).toHaveBeenCalledWith('ALIYUN', 'prod', 1, 20));
+  });
+
+  it('does not let a stale credential response overwrite the latest filters', 
async () => {
+    const initial = deferred<CloudCredentialPage>();
+    const latest = deferred<CloudCredentialPage>();
+    vi.mocked(listCloudCredentials)
+      .mockReturnValueOnce(initial.promise)
+      .mockReturnValueOnce(latest.promise);
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderTab();
+
+    await user.click(screen.getAllByRole('combobox')[0]);
+    await user.click(await screen.findByText('腾讯云'));
+    await waitFor(() =>
+      expect(listCloudCredentials).toHaveBeenLastCalledWith('TENCENT', '', 1, 
20),
+    );
+
+    latest.resolve({
+      items: [
+        {
+          id: 9,
+          name: 'latest-credential',
+          vendor: 'TENCENT',
+          accessKey: 'AKID****9999',
+          gmtCreate: '2026-08-18T12:00:00',
+        },
+      ],
+      total: 1,
+      page: 1,
+      size: 20,
+    });
+    await screen.findByText('latest-credential');
+
+    initial.resolve(credentials);
+    await waitFor(() => 
expect(screen.getByText('latest-credential')).toBeInTheDocument());
+    expect(screen.queryByText('aliyun-test')).not.toBeInTheDocument();
+  });
+
   it('creates a credential from the modal form', async () => {
     vi.mocked(createCloudCredential).mockResolvedValue({
       id: 2,
@@ -106,9 +170,8 @@ describe('CloudCredentialTab', () => {
     await user.click(screen.getByRole('button', { name: /添加云凭据/ }));
 
     const dialog = await screen.findByRole('dialog');
-    await user.type(within(dialog).getByPlaceholderText(/请输入凭据名称|例如/), 
'tencent-prod');
-    // fill name field explicitly (placeholder shared with example text)
-    await user.click(within(dialog).getByLabelText('云厂商'));
+    await user.type(within(dialog).getByPlaceholderText(/例如/), 'tencent-prod');
+    await user.click(within(dialog).getAllByRole('combobox')[0]);
     await user.click(await screen.findByText('腾讯云'));
     await user.type(screen.getByPlaceholderText('LTAI...'), 
'AKID000000009999');
     await user.type(screen.getByPlaceholderText('请输入 SecretKey'), 
'secret-9999');
@@ -123,7 +186,9 @@ describe('CloudCredentialTab', () => {
         remark: undefined,
       }),
     );
-    await waitFor(() => 
expect(screen.getByText('tencent-prod')).toBeInTheDocument());
+    await waitFor(() =>
+      expect(listCloudCredentials).toHaveBeenLastCalledWith(undefined, '', 1, 
20),
+    );
   });
 
   it('updates name and remark while keeping the secret unchanged when blank', 
async () => {
@@ -155,7 +220,9 @@ describe('CloudCredentialTab', () => {
         remark: '新备注',
       }),
     );
-    await waitFor(() => 
expect(screen.getByText('aliyun-renamed')).toBeInTheDocument());
+    await waitFor(() =>
+      expect(listCloudCredentials).toHaveBeenLastCalledWith(undefined, '', 1, 
20),
+    );
   });
 
   it('deletes a credential after confirmation', async () => {
@@ -168,6 +235,8 @@ describe('CloudCredentialTab', () => {
     await user.click(await screen.findByRole('button', { name: /确\s*定/ }));
 
     await waitFor(() => expect(deleteCloudCredential).toHaveBeenCalledWith(1));
-    await waitFor(() => 
expect(screen.queryByText('aliyun-test')).not.toBeInTheDocument());
+    await waitFor(() =>
+      expect(listCloudCredentials).toHaveBeenLastCalledWith(undefined, '', 1, 
20),
+    );
   });
 });
diff --git a/web/src/pages/settings/__tests__/DataSourceTab.test.tsx 
b/web/src/pages/settings/__tests__/DataSourceTab.test.tsx
index 4d8662324..a050acf80 100644
--- a/web/src/pages/settings/__tests__/DataSourceTab.test.tsx
+++ b/web/src/pages/settings/__tests__/DataSourceTab.test.tsx
@@ -19,8 +19,8 @@ import { beforeAll, beforeEach, describe, expect, it, vi } 
from 'vitest';
 import { render, screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { App } from 'antd';
-import type { DataSource } from '../../../api/settings';
-import { createDataSource, listDataSources, testDataSource } from 
'../../../api/settings';
+import type { DataSource, DataSourcePage } from '../../../api/settings';
+import { createDataSource, listDataSourcesPage, testDataSource } from 
'../../../api/settings';
 import { LangProvider } from '../../../i18n/LangContext';
 import { DataSourceTab } from '../DataSourceTab';
 
@@ -28,7 +28,7 @@ vi.mock('../../../api/settings', () => ({
   createDataSource: vi.fn(),
   deleteDataSource: vi.fn(),
   getGeneralSettings: vi.fn(),
-  listDataSources: vi.fn(),
+  listDataSourcesPage: vi.fn(),
   saveGeneralSettings: vi.fn(),
   testDataSource: vi.fn(),
   updateDataSource: vi.fn(),
@@ -52,6 +52,13 @@ const sources: DataSource[] = [
   },
 ];
 
+const sourcePage: DataSourcePage = {
+  items: sources,
+  total: sources.length,
+  page: 1,
+  size: 20,
+};
+
 const deferred = <T,>() => {
   let resolve!: (value: T) => void;
   let reject!: (reason?: unknown) => void;
@@ -81,12 +88,12 @@ beforeAll(() => {
 describe('DataSourceTab', () => {
   beforeEach(() => {
     vi.clearAllMocks();
-    vi.mocked(listDataSources).mockResolvedValue(sources);
+    vi.mocked(listDataSourcesPage).mockResolvedValue(sourcePage);
   });
 
   it('keeps data source creation disabled until the initial list is ready', 
async () => {
-    const initialList = deferred<DataSource[]>();
-    vi.mocked(listDataSources).mockReturnValue(initialList.promise);
+    const initialList = deferred<DataSourcePage>();
+    vi.mocked(listDataSourcesPage).mockReturnValue(initialList.promise);
     const user = userEvent.setup({ pointerEventsCheck: 0 });
     render(
       <App>
@@ -99,7 +106,7 @@ describe('DataSourceTab', () => {
     await user.click(addButton);
     expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
 
-    initialList.resolve(sources);
+    initialList.resolve(sourcePage);
 
     await waitFor(() => expect(addButton).toBeEnabled());
     await user.click(addButton);
@@ -149,9 +156,10 @@ describe('DataSourceTab', () => {
   });
 
   it('keeps each row loading until its own connection test finishes', async () 
=> {
-    vi.mocked(listDataSources).mockResolvedValue(
-      sources.map((source) => ({ ...source, auth: 'None' })),
-    );
+    vi.mocked(listDataSourcesPage).mockResolvedValue({
+      ...sourcePage,
+      items: sources.map((source) => ({ ...source, auth: 'None' })),
+    });
     let resolveFirst: (value: { success: boolean; message: string }) => void = 
() => undefined;
     let resolveSecond: (value: { success: boolean; message: string }) => void 
= () => undefined;
     vi.mocked(testDataSource)
@@ -267,6 +275,19 @@ describe('DataSourceTab', () => {
       auth: 'None',
       status: 'healthy',
     });
+    vi.mocked(listDataSourcesPage).mockResolvedValue({
+      ...sourcePage,
+      items: [
+        {
+          key: 'mimir-prod',
+          name: 'Mimir prod',
+          type: 'Mimir',
+          url: 'http://mimir:9009',
+          auth: 'None',
+          status: 'healthy',
+        },
+      ],
+    });
 
     const user = userEvent.setup({ pointerEventsCheck: 0 });
     render(
@@ -275,7 +296,7 @@ describe('DataSourceTab', () => {
       </App>,
     );
 
-    await screen.findByText('Prometheus prod');
+    await waitFor(() => expect(listDataSourcesPage).toHaveBeenCalled());
     await user.click(screen.getByRole('button', { name: /添加数据源/ }));
     await user.type(screen.getByLabelText('名称'), 'Mimir prod');
     await selectAntdOption(user, '类型', 'Grafana Mimir');

Reply via email to