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');