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 37bc9c81 feat: support server-side instance filtering (#743)
37bc9c81 is described below
commit 37bc9c8196517daaa9c46ad2096df0474cda0b95
Author: yx9o <[email protected]>
AuthorDate: Mon Aug 3 11:17:41 2026 +0800
feat: support server-side instance filtering (#743)
---
.../instance/InMemoryInstanceRepository.java | 21 ++-
.../instance/InMemoryInstanceRepositoryTest.java | 44 +++++
web/src/api/instance.test.ts | 20 +++
web/src/api/instance.ts | 14 +-
.../pages/instance/__tests__/InstancePage.test.tsx | 188 +++++++++++++++++++++
web/src/pages/instance/index.tsx | 78 +++++----
web/src/services/instanceService.test.ts | 11 ++
web/src/services/instanceService.ts | 25 ++-
8 files changed, 358 insertions(+), 43 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/InMemoryInstanceRepository.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/InMemoryInstanceRepository.java
index a7c46766..ec38ba59 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/InMemoryInstanceRepository.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/InMemoryInstanceRepository.java
@@ -23,6 +23,7 @@ import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
@@ -80,23 +81,31 @@ public class InMemoryInstanceRepository implements
InstanceRepository {
@Override
public List<InstanceVO> search(String keyword) {
- String lower = keyword.toLowerCase();
+ String lower = keyword.toLowerCase(Locale.ROOT);
return store.values().stream()
- .filter(i -> i.getName().toLowerCase().contains(lower)
- || i.getRemark() != null &&
i.getRemark().toLowerCase().contains(lower))
+ .filter(instance -> matchesSearch(instance, lower))
.collect(Collectors.toList());
}
@Override
public List<InstanceVO> findByTypeAndSearch(InstanceType type, String
keyword) {
- String lower = keyword.toLowerCase();
+ String lower = keyword.toLowerCase(Locale.ROOT);
return store.values().stream()
.filter(i -> i.getType() == type)
- .filter(i -> i.getName().toLowerCase().contains(lower)
- || i.getRemark() != null &&
i.getRemark().toLowerCase().contains(lower))
+ .filter(instance -> matchesSearch(instance, lower))
.collect(Collectors.toList());
}
+ private boolean matchesSearch(InstanceVO instance, String lowerKeyword) {
+ return containsIgnoreCase(instance.getName(), lowerKeyword)
+ || containsIgnoreCase(instance.getEndpoint(), lowerKeyword)
+ || containsIgnoreCase(instance.getRemark(), lowerKeyword);
+ }
+
+ private boolean containsIgnoreCase(String value, String lowerKeyword) {
+ return value != null &&
value.toLowerCase(Locale.ROOT).contains(lowerKeyword);
+ }
+
@Override
public Optional<InstanceVO> findById(String id) {
return Optional.ofNullable(store.get(id));
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/InMemoryInstanceRepositoryTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/InMemoryInstanceRepositoryTest.java
new file mode 100644
index 00000000..06a7bddb
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/InMemoryInstanceRepositoryTest.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.instance;
+
+import org.apache.rocketmq.studio.common.domain.enums.InstanceType;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class InMemoryInstanceRepositoryTest {
+
+ private final InMemoryInstanceRepository repository = new
InMemoryInstanceRepository();
+
+ @Test
+ void searchShouldMatchInstanceEndpoints() {
+ assertThat(repository.search("10.0.2.100:8080"))
+ .extracting(InstanceVO::getId)
+ .containsExactly("inst-2");
+ }
+
+ @Test
+ void typeAndSearchShouldMatchEndpointsWithinTheSelectedType() {
+ assertThat(repository.findByTypeAndSearch(InstanceType.DIRECT,
"10.0.3.100"))
+ .extracting(InstanceVO::getId)
+ .containsExactly("inst-3");
+
+ assertThat(repository.findByTypeAndSearch(InstanceType.PROXY,
"10.0.3.100"))
+ .isEmpty();
+ }
+}
diff --git a/web/src/api/instance.test.ts b/web/src/api/instance.test.ts
index 2bfb0f98..d423e867 100644
--- a/web/src/api/instance.test.ts
+++ b/web/src/api/instance.test.ts
@@ -56,6 +56,26 @@ describe('instance API', () => {
await expect(updateInstance({ id: instance.id, remark: 'updated'
})).resolves.toEqual(instance);
});
+ it('sends normalized instance filters', async () => {
+ mock.onGet('/instances').reply((config) => {
+ expect(config.params).toEqual({ type: 'PROXY', search: 'proxy:8080' });
+ return [200, { code: 200, data: [instance] }];
+ });
+
+ await expect(listInstances({ type: 'PROXY', search: ' proxy:8080 '
})).resolves.toEqual([
+ instance,
+ ]);
+ });
+
+ it('omits blank instance filters', async () => {
+ mock.onGet('/instances').reply((config) => {
+ expect(config.params).toEqual({});
+ return [200, { code: 200, data: [instance] }];
+ });
+
+ await expect(listInstances({ search: ' '
})).resolves.toEqual([instance]);
+ });
+
it('sends the instance id when deleting', async () => {
mock.onPost('/instances/delete').reply((config) => {
expect(JSON.parse(config.data)).toEqual({ id: instance.id });
diff --git a/web/src/api/instance.ts b/web/src/api/instance.ts
index 30dfe81f..98b0609d 100644
--- a/web/src/api/instance.ts
+++ b/web/src/api/instance.ts
@@ -45,9 +45,19 @@ export interface UpdateInstanceRequest {
remark?: string;
}
+export interface InstanceQuery {
+ type?: Instance['type'];
+ search?: string;
+}
+
// ─── Instance CRUD ──────────────────────────────────────────────
-export async function listInstances() {
- const res = await client.get<{ data: Instance[] }>('/instances');
+export async function listInstances(query: InstanceQuery = {}) {
+ const search = query.search?.trim();
+ const params = {
+ ...(query.type ? { type: query.type } : {}),
+ ...(search ? { search } : {}),
+ };
+ const res = await client.get<{ data: Instance[] }>('/instances', { params });
return res.data.data;
}
diff --git a/web/src/pages/instance/__tests__/InstancePage.test.tsx
b/web/src/pages/instance/__tests__/InstancePage.test.tsx
new file mode 100644
index 00000000..269679e9
--- /dev/null
+++ b/web/src/pages/instance/__tests__/InstancePage.test.tsx
@@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { App } from 'antd';
+import { act, fireEvent, render, screen, waitFor, within } from
'@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { Instance } from '../../../api/instance';
+import { LangProvider } from '../../../i18n/LangContext';
+import * as instanceService from '../../../services/instanceService';
+import InstancePage from '../index';
+
+vi.mock('../../../services/instanceService', () => ({
+ createInstance: vi.fn(),
+ deleteInstance: vi.fn(),
+ listInstances: vi.fn(),
+ updateInstance: vi.fn(),
+}));
+
+beforeAll(() => {
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+});
+
+const instance = (id: string, name: string, type: Instance['type'] = 'PROXY'):
Instance => ({
+ id,
+ name,
+ remark: '',
+ type,
+ endpoint: `${name}:8080`,
+ topicCount: 1,
+ consumerGroupCount: 1,
+ createdAt: '2026-01-01T00:00:00Z',
+ updatedAt: '2026-01-01T00:00:00Z',
+});
+
+const renderPage = () =>
+ render(
+ <App>
+ <LangProvider>
+ <InstancePage />
+ </LangProvider>
+ </App>,
+ );
+
+describe('InstancePage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(instanceService.listInstances).mockResolvedValue([
+ instance('proxy-1', 'production-proxy'),
+ instance('direct-1', 'development-direct', 'DIRECT'),
+ ]);
+ });
+
+ it('loads server-filtered results when the search or type changes', async ()
=> {
+ const user = userEvent.setup();
+ renderPage();
+
+ expect(await screen.findByText('production-proxy')).toBeInTheDocument();
+ expect(instanceService.listInstances).toHaveBeenCalledWith({});
+
+ fireEvent.change(screen.getByPlaceholderText('搜索实例名称或地址'), {
+ target: { value: 'proxy-hz' },
+ });
+ await waitFor(
+ () => expect(instanceService.listInstances).toHaveBeenCalledWith({
search: 'proxy-hz' }),
+ { timeout: 1000 },
+ );
+
+ fireEvent.change(screen.getByPlaceholderText('搜索实例名称或地址'), {
+ target: { value: '' },
+ });
+ await waitFor(() =>
expect(instanceService.listInstances).toHaveBeenLastCalledWith({}), {
+ timeout: 1000,
+ });
+
+ const typeSelect = screen.getByRole('combobox');
+ fireEvent.mouseDown(typeSelect.parentElement!);
+ await user.click(
+ await screen.findByText('Direct 模式', { selector:
'.ant-select-item-option-content' }),
+ );
+
+ await waitFor(() =>
+ expect(instanceService.listInstances).toHaveBeenLastCalledWith({ type:
'DIRECT' }),
+ );
+ });
+
+ it('ignores an older search response that finishes after the latest
request', async () => {
+ let resolveOldSearch!: (instances: Instance[]) => void;
+ let resolveLatestSearch!: (instances: Instance[]) => void;
+ const oldSearch = new Promise<Instance[]>((resolve) => {
+ resolveOldSearch = resolve;
+ });
+ const latestSearch = new Promise<Instance[]>((resolve) => {
+ resolveLatestSearch = resolve;
+ });
+ vi.mocked(instanceService.listInstances)
+ .mockResolvedValueOnce([instance('initial', 'initial-instance')])
+ .mockReturnValueOnce(oldSearch)
+ .mockReturnValueOnce(latestSearch);
+ renderPage();
+
+ expect(await screen.findByText('initial-instance')).toBeInTheDocument();
+ const searchInput = screen.getByPlaceholderText('搜索实例名称或地址');
+ fireEvent.change(searchInput, { target: { value: 'old' } });
+ await waitFor(
+ () => expect(instanceService.listInstances).toHaveBeenCalledWith({
search: 'old' }),
+ {
+ timeout: 1000,
+ },
+ );
+
+ fireEvent.change(searchInput, { target: { value: 'latest' } });
+ await waitFor(
+ () => expect(instanceService.listInstances).toHaveBeenCalledWith({
search: 'latest' }),
+ { timeout: 1000 },
+ );
+
+ await act(async () => resolveLatestSearch([instance('latest',
'latest-instance')]));
+ expect(await screen.findByText('latest-instance')).toBeInTheDocument();
+
+ await act(async () => resolveOldSearch([instance('old', 'old-instance')]));
+ expect(screen.queryByText('old-instance')).not.toBeInTheDocument();
+ expect(screen.getByText('latest-instance')).toBeInTheDocument();
+ });
+
+ it('reloads the current filters after creating an instance', async () => {
+ const user = userEvent.setup();
+
vi.mocked(instanceService.createInstance).mockResolvedValue(instance('created',
'new-proxy'));
+ renderPage();
+
+ expect(await screen.findByText('production-proxy')).toBeInTheDocument();
+ const typeSelect = screen.getByRole('combobox');
+ fireEvent.mouseDown(typeSelect.parentElement!);
+ await user.click(
+ await screen.findByText('Direct 模式', { selector:
'.ant-select-item-option-content' }),
+ );
+ await waitFor(() =>
+ expect(instanceService.listInstances).toHaveBeenLastCalledWith({ type:
'DIRECT' }),
+ );
+
+ await user.click(screen.getByRole('button', { name: /添加实例/ }));
+ const dialog = await screen.findByRole('dialog');
+ await user.type(within(dialog).getByLabelText('实例名称'), 'new-proxy');
+ const createTypeSelect = within(dialog).getByRole('combobox');
+ fireEvent.mouseDown(createTypeSelect.parentElement!);
+ const proxyOptions = await screen.findAllByText('Proxy 模式', {
+ selector: '.ant-select-item-option-content',
+ });
+ await user.click(proxyOptions[proxyOptions.length - 1]);
+ await user.type(within(dialog).getByLabelText('接入地址'), 'proxy-new:8080');
+ await user.click(within(dialog).getByRole('button', { name: /连\s*接/ }));
+
+ await waitFor(() =>
+ expect(instanceService.createInstance).toHaveBeenCalledWith({
+ name: 'new-proxy',
+ type: 'PROXY',
+ endpoint: 'proxy-new:8080',
+ }),
+ );
+ expect(instanceService.listInstances).toHaveBeenLastCalledWith({ type:
'DIRECT' });
+ });
+});
diff --git a/web/src/pages/instance/index.tsx b/web/src/pages/instance/index.tsx
index 3eb3d0ed..225b806d 100644
--- a/web/src/pages/instance/index.tsx
+++ b/web/src/pages/instance/index.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import {
Table,
Card,
@@ -34,7 +34,7 @@ import { useLang } from '../../i18n/LangContext';
import { Plus, MagnifyingGlass } from '@phosphor-icons/react';
import { EditOutlined, DeleteOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
-import type { Instance } from '../../api/instance';
+import type { Instance, InstanceQuery } from '../../api/instance';
import {
createInstance,
deleteInstance,
@@ -50,6 +50,8 @@ const typeLabel: Record<string, { text: string; color: string
}> = {
DIRECT: { text: 'Direct 模式', color: 'orange' },
};
+type InstanceTypeFilter = 'ALL' | Instance['type'];
+
/* ═══════════════════════════════════════════
InstancePage
═══════════════════════════════════════════ */
@@ -58,38 +60,60 @@ const InstancePage = () => {
const [instances, setInstances] = useState<Instance[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
- const [typeFilter, setTypeFilter] = useState('ALL');
+ const [debouncedSearch, setDebouncedSearch] = useState('');
+ const [typeFilter, setTypeFilter] = useState<InstanceTypeFilter>('ALL');
const [addModalOpen, setAddModalOpen] = useState(false);
const [addForm] = Form.useForm();
const [editModalOpen, setEditModalOpen] = useState(false);
const [editingInstance, setEditingInstance] = useState<Instance |
null>(null);
const [editForm] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
+ const requestIdRef = useRef(0);
+
+ useEffect(() => {
+ const timer = window.setTimeout(() => setDebouncedSearch(search.trim()),
300);
+ return () => window.clearTimeout(timer);
+ }, [search]);
+
+ const loadInstances = useCallback(async () => {
+ const requestId = ++requestIdRef.current;
+ const query: InstanceQuery = {
+ ...(typeFilter === 'ALL' ? {} : { type: typeFilter }),
+ ...(debouncedSearch ? { search: debouncedSearch } : {}),
+ };
+
+ setLoading(true);
+ try {
+ const nextInstances = await listInstances(query);
+ if (requestId === requestIdRef.current) {
+ setInstances(nextInstances);
+ }
+ } catch {
+ if (requestId === requestIdRef.current) {
+ message.error('实例列表加载失败,请稍后重试');
+ }
+ } finally {
+ if (requestId === requestIdRef.current) {
+ setLoading(false);
+ }
+ }
+ }, [debouncedSearch, typeFilter]);
useEffect(() => {
- let cancelled = false;
- void listInstances()
- .then((nextInstances) => {
- if (!cancelled) setInstances(nextInstances);
- })
- .catch(() => {
- if (!cancelled) message.error('实例列表加载失败,请稍后重试');
- })
- .finally(() => {
- if (!cancelled) setLoading(false);
- });
+ const timer = window.setTimeout(() => void loadInstances(), 0);
return () => {
- cancelled = true;
+ window.clearTimeout(timer);
+ requestIdRef.current += 1;
};
- }, []);
+ }, [loadInstances]);
const handleCreate = async () => {
try {
const values = await addForm.validateFields();
setSubmitting(true);
const created = await createInstance(values);
- setInstances((previous) => [...previous, created]);
+ await loadInstances();
message.success(`实例「${created.name}」添加成功`);
setAddModalOpen(false);
addForm.resetFields();
@@ -106,9 +130,7 @@ const InstancePage = () => {
const values = await editForm.validateFields();
setSubmitting(true);
const updated = await updateInstance({ id: editingInstance.id, remark:
values.remark || '' });
- setInstances((previous) =>
- previous.map((instance) => (instance.id === updated.id ? updated :
instance)),
- );
+ await loadInstances();
message.success(`实例「${updated.name}」备注已更新`);
setEditModalOpen(false);
editForm.resetFields();
@@ -122,20 +144,14 @@ const InstancePage = () => {
const handleDelete = async (instance: Instance) => {
try {
await deleteInstance(instance.id);
- setInstances((previous) => previous.filter((item) => item.id !==
instance.id));
+ await loadInstances();
message.success('已删除');
} catch {
message.error('删除实例失败,请稍后重试');
}
};
- const filtered = instances
- .filter((i) => {
- const matchSearch = i.name.includes(search) ||
i.endpoint.includes(search);
- const matchType = typeFilter === 'ALL' || i.type === typeFilter;
- return matchSearch && matchType;
- })
- .sort((a, b) => a.name.localeCompare(b.name));
+ const sortedInstances = [...instances].sort((a, b) =>
a.name.localeCompare(b.name));
const columns: ColumnsType<Instance> = [
{
@@ -258,7 +274,7 @@ const InstancePage = () => {
<div style={{ marginBottom: 20 }}>
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600
}}>{t('instance.title')}</h2>
<span style={{ fontSize: 13, color: '#9CA3AF' }}>
- 管理 RocketMQ 集群连接,共 {instances.length} 个实例
+ 管理 RocketMQ 集群连接,当前显示 {instances.length} 个实例
</span>
</div>
@@ -279,7 +295,7 @@ const InstancePage = () => {
style={{ width: 240 }}
allowClear
/>
- <Select
+ <Select<InstanceTypeFilter>
value={typeFilter}
onChange={setTypeFilter}
style={{ width: 140 }}
@@ -303,7 +319,7 @@ const InstancePage = () => {
<Card bodyStyle={{ padding: 0 }}>
<Table
columns={columns}
- dataSource={filtered}
+ dataSource={sortedInstances}
loading={loading}
rowKey="id"
pagination={false}
diff --git a/web/src/services/instanceService.test.ts
b/web/src/services/instanceService.test.ts
index e7f394b6..a8f93118 100644
--- a/web/src/services/instanceService.test.ts
+++ b/web/src/services/instanceService.test.ts
@@ -40,6 +40,17 @@ describe('instanceService mock instances', () => {
expect(fresh[0]).not.toBe(instances[0]);
});
+ it('filters mock instances with the same type and search semantics as the
API', async () => {
+ const byType = await listInstances({ type: 'DIRECT' });
+ expect(byType.map((instance) => instance.id)).toEqual(['4']);
+
+ const byEndpoint = await listInstances({ search: ' PROXY-HZ ' });
+ expect(byEndpoint.map((instance) => instance.id)).toEqual(['1']);
+
+ const combined = await listInstances({ type: 'DIRECT', search:
'namesrv-legacy' });
+ expect(combined.map((instance) => instance.id)).toEqual(['4']);
+ });
+
it('does not expose created or updated store records by reference', async ()
=> {
const created = await createInstance({
name: 'rocketmq-copy-test',
diff --git a/web/src/services/instanceService.ts
b/web/src/services/instanceService.ts
index 5cadff45..0b6564c4 100644
--- a/web/src/services/instanceService.ts
+++ b/web/src/services/instanceService.ts
@@ -1,6 +1,11 @@
import { USE_MOCK } from '../config';
import * as instanceApi from '../api/instance';
-import type { Instance, CreateInstanceRequest, UpdateInstanceRequest } from
'../api/instance';
+import type {
+ Instance,
+ CreateInstanceRequest,
+ InstanceQuery,
+ UpdateInstanceRequest,
+} from '../api/instance';
import { mockInstances } from '../mock/instances';
// Compile-time switch: mock or real API
@@ -10,9 +15,21 @@ function copyInstance(instance: Instance): Instance {
return { ...instance };
}
-export async function listInstances(): Promise<Instance[]> {
- if (USE_MOCK) return mockInstances.map(copyInstance);
- return instanceApi.listInstances();
+export async function listInstances(query: InstanceQuery = {}):
Promise<Instance[]> {
+ if (USE_MOCK) {
+ const search = query.search?.trim().toLowerCase();
+ return mockInstances
+ .filter((instance) => !query.type || instance.type === query.type)
+ .filter(
+ (instance) =>
+ !search ||
+ [instance.name, instance.endpoint, instance.remark].some((value) =>
+ value.toLowerCase().includes(search),
+ ),
+ )
+ .map(copyInstance);
+ }
+ return instanceApi.listInstances(query);
}
export async function createInstance(data: CreateInstanceRequest):
Promise<Instance> {