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 249a2002c fix(web): consolidate client connection resilience (#2989)
249a2002c is described below
commit 249a2002c053af02fc466d59ea40ee5976fe3d3c
Author: aias00 <[email protected]>
AuthorDate: Fri Sep 4 15:34:37 2026 +0800
fix(web): consolidate client connection resilience (#2989)
Client connection inventories may omit clientId or address. Keep that
nullable contract at the API boundary and make the client table search, row
keys, and details tolerate incomplete metadata while preserving the current
registry-driven loading flow.
Constraint: combines the still-relevant behavior from #2671 and #2862 on
top of the latest rocketmq-studio baseline.
Rejected: cherry-picking old PR heads | both branches predate later Studio
merges and would reintroduce unrelated stale changes.
Confidence: high
Scope-risk: narrow
Tested: npm test -- --run src/pages/cluster/__tests__/ClientsPage.test.tsx
src/pages/cluster/__tests__/clientsSearch.test.ts src/api/connections.test.ts
src/services/connectionsService.test.ts
Tested: npm run lint
Tested: npm run build
Tested: npx prettier --check src/api/connections.ts
src/pages/cluster/clients.tsx src/pages/cluster/clientsSearch.ts
src/pages/cluster/__tests__/ClientsPage.test.tsx
src/pages/cluster/__tests__/clientsSearch.test.ts
Tested: git diff --check
Signed-off-by: liuhy <[email protected]>
---
web/src/api/connections.ts | 4 +-
.../pages/cluster/__tests__/ClientsPage.test.tsx | 40 +++++++++++++++
.../pages/cluster/__tests__/clientsSearch.test.ts | 57 ++++++++++++++++++++++
web/src/pages/cluster/clients.tsx | 42 ++++++++--------
web/src/pages/cluster/clientsSearch.ts | 33 +++++++++++++
web/src/utils/clientConnectionDiagnostics.ts | 4 +-
6 files changed, 157 insertions(+), 23 deletions(-)
diff --git a/web/src/api/connections.ts b/web/src/api/connections.ts
index 549e44c6c..a47d2de8a 100644
--- a/web/src/api/connections.ts
+++ b/web/src/api/connections.ts
@@ -2,11 +2,11 @@ import client from './client';
// Matches mock/clients.ts
export interface ClientConnection {
- clientId: string;
+ clientId?: string | null;
type: string;
groupOrTopic: string;
protocol: string;
- address: string;
+ address?: string | null;
language: string;
version: string;
connectedAt?: string | null;
diff --git a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
index 277762e4e..ad7cb4042 100644
--- a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
@@ -296,6 +296,46 @@ describe('Clients page', () => {
expect(screen.queryByText('[email protected]:49154')).toBeNull();
});
+ it('keeps incomplete client metadata searchable by address', async () => {
+ const user = userEvent.setup();
+ vi.mocked(connectionsService.listConnections).mockResolvedValue([
+ {
+ ...connection,
+ clientId: null,
+ address: '10.0.1.99:49152',
+ partial: true,
+ } as ClientConnection,
+ ]);
+ renderWithProviders(<ClientsPage />);
+
+ await screen.findByText('10.0.1.99:49152');
+ await user.type(screen.getByPlaceholderText('搜索 Client ID 或地址'),
'10.0.1.99');
+
+ expect(screen.getByText('10.0.1.99:49152')).toBeInTheDocument();
+ });
+
+ it('opens details for connections without client id or address', async () =>
{
+ const user = userEvent.setup();
+ vi.mocked(connectionsService.listConnections).mockResolvedValue([
+ {
+ ...connection,
+ clientId: undefined,
+ address: null,
+ groupOrTopic: 'legacy-topic',
+ },
+ ]);
+ renderWithProviders(<ClientsPage />);
+
+ const rows = await screen.findAllByRole('row', { name: /legacy-topic/ });
+ const row = rows.find((candidate) =>
within(candidate).queryByRole('button', { name: /详情/ }));
+ expect(row).toBeDefined();
+ await user.click(within(row!).getByRole('button', { name: /详情/ }));
+
+ const dialog = await screen.findByRole('dialog', { name: /客户端详情 - -/ });
+ expect(within(dialog).getByText('legacy-topic')).toBeInTheDocument();
+ expect(within(dialog).getAllByText('-')).toHaveLength(2);
+ });
+
it('exports the currently filtered client connections as CSV', async () => {
const createObjectURL = vi.fn((blob: Blob | MediaSource) => {
expect(blob).toBeInstanceOf(Blob);
diff --git a/web/src/pages/cluster/__tests__/clientsSearch.test.ts
b/web/src/pages/cluster/__tests__/clientsSearch.test.ts
new file mode 100644
index 000000000..8a8c480df
--- /dev/null
+++ b/web/src/pages/cluster/__tests__/clientsSearch.test.ts
@@ -0,0 +1,57 @@
+/*
+ * 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 { describe, expect, it } from 'vitest';
+import { matchesClientSearch } from '../clientsSearch';
+
+describe('matchesClientSearch', () => {
+ it('matches client id case-insensitively', () => {
+ const connection = { clientId: 'PID_10.0.0.5_1', address: '10.0.0.5:8080'
};
+
+ expect(matchesClientSearch(connection, 'pid_10.0.0.5')).toBe(true);
+ expect(matchesClientSearch(connection, 'PID_10.0.0.5_1')).toBe(true);
+ expect(matchesClientSearch(connection, 'nope')).toBe(false);
+ });
+
+ it('matches address case-insensitively', () => {
+ const connection = { clientId: 'PID_1', address: '10.0.0.5:8080' };
+
+ expect(matchesClientSearch(connection, '10.0.0.5:8080')).toBe(true);
+ });
+
+ it('trims whitespace from the search input', () => {
+ const connection = { clientId: 'PID_1', address: '10.0.0.5:8080' };
+
+ expect(matchesClientSearch(connection, ' pid_1 ')).toBe(true);
+ expect(matchesClientSearch(connection, ' nope ')).toBe(false);
+ });
+
+ it('treats empty and whitespace-only search as a full match', () => {
+ const connection = { clientId: 'PID_1', address: '10.0.0.5:8080' };
+
+ expect(matchesClientSearch(connection, '')).toBe(true);
+ expect(matchesClientSearch(connection, ' ')).toBe(true);
+ expect(matchesClientSearch(connection, undefined)).toBe(true);
+ });
+
+ it('does not throw when clientId or address is missing', () => {
+ expect(matchesClientSearch({}, 'pid')).toBe(false);
+ expect(matchesClientSearch({ clientId: null, address: undefined }, '
')).toBe(true);
+ expect(matchesClientSearch({ address: '10.0.0.9:8080' },
'10.0.0.9')).toBe(true);
+ expect(matchesClientSearch({ clientId: 'PID_9' }, 'pid_9')).toBe(true);
+ });
+});
diff --git a/web/src/pages/cluster/clients.tsx
b/web/src/pages/cluster/clients.tsx
index 98eefcea0..74efbd225 100644
--- a/web/src/pages/cluster/clients.tsx
+++ b/web/src/pages/cluster/clients.tsx
@@ -51,6 +51,7 @@ import {
type ClientConnectionIssue,
type ClientResourceSummary,
} from '../../utils/clientConnectionDiagnostics';
+import { matchesClientSearch } from './clientsSearch';
const { Text } = Typography;
const DEFAULT_LOAD_ERROR = '客户端连接加载失败,请稍后重试';
@@ -148,6 +149,8 @@ function getLoadErrorMessage(error: unknown): string {
return DEFAULT_LOAD_ERROR;
}
+const displayMetadata = (value: string | null | undefined) => value || '-';
+
/* ═══════════════════════════════════════════
ClientsPage
═══════════════════════════════════════════ */
@@ -279,7 +282,7 @@ const ClientsPage = () => {
const instances = Array.from(
new Map(
clusterConnections.map((connection) => [
- `${connection.type}:${connection.clientId}`,
+ `${connection.type}:${connection.clientId ?? connection.address ??
connection.groupOrTopic}`,
connection,
]),
).values(),
@@ -338,14 +341,10 @@ const ClientsPage = () => {
];
/* ─── Filtered data (search + cluster only, table handles column filters)
─── */
- const filtered = useMemo(() => {
- const normalizedSearch = search.toLowerCase();
- return clusterConnections.filter(
- (connection) =>
- connection.clientId.toLowerCase().includes(normalizedSearch) ||
- connection.address?.toLowerCase().includes(normalizedSearch),
- );
- }, [clusterConnections, search]);
+ const filtered = useMemo(
+ () => clusterConnections.filter((connection) =>
matchesClientSearch(connection, search)),
+ [clusterConnections, search],
+ );
const exportConnections = useMemo(() => {
const matches = (key: string, value: string) => {
@@ -391,16 +390,16 @@ const ClientsPage = () => {
key: 'clientId',
width: 260,
ellipsis: true,
- render: (id: string) => (
+ render: (id?: string | null) => (
<Text
- copyable
+ copyable={Boolean(id)}
style={{
fontSize: 14,
fontFamily: 'monospace',
whiteSpace: 'nowrap',
}}
>
- {id}
+ {displayMetadata(id)}
</Text>
),
},
@@ -451,8 +450,8 @@ const ClientsPage = () => {
dataIndex: 'address',
key: 'address',
width: 180,
- render: (addr: string) => (
- <Text style={{ fontSize: 14, fontFamily: 'monospace' }}>{addr}</Text>
+ render: (addr?: string | null) => (
+ <Text style={{ fontSize: 14, fontFamily: 'monospace'
}}>{displayMetadata(addr)}</Text>
),
},
{
@@ -884,7 +883,7 @@ const ClientsPage = () => {
columns={columns}
dataSource={filtered}
rowKey={(connection) =>
-
`${connection.type}:${connection.clientId}:${connection.groupOrTopic}`
+ `${connection.type}:${connection.clientId ??
''}:${connection.address ?? ''}:${connection.groupOrTopic}`
}
loading={loading}
onChange={(pagination, filters, _sorter, extra) => {
@@ -908,7 +907,7 @@ const ClientsPage = () => {
</Card>
<Modal
- title={t('clients.detailTitle', { id: selectedConnection?.clientId ??
'' })}
+ title={t('clients.detailTitle', { id:
displayMetadata(selectedConnection?.clientId) })}
open={Boolean(selectedConnection)}
onCancel={() => setSelectedConnection(null)}
footer={<Button onClick={() =>
setSelectedConnection(null)}>{t('common.close')}</Button>}
@@ -918,8 +917,11 @@ const ClientsPage = () => {
{selectedConnection && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label={t('clients.clientId')}>
- <Text copyable style={{ fontFamily: 'monospace' }}>
- {selectedConnection.clientId}
+ <Text
+ copyable={Boolean(selectedConnection.clientId)}
+ style={{ fontFamily: 'monospace' }}
+ >
+ {displayMetadata(selectedConnection.clientId)}
</Text>
</Descriptions.Item>
<Descriptions.Item label={t('clients.cluster')}>
@@ -937,7 +939,9 @@ const ClientsPage = () => {
</Tag>
</Descriptions.Item>
<Descriptions.Item label={t('common.address')}>
- <Text style={{ fontFamily: 'monospace'
}}>{selectedConnection.address}</Text>
+ <Text style={{ fontFamily: 'monospace' }}>
+ {displayMetadata(selectedConnection.address)}
+ </Text>
</Descriptions.Item>
<Descriptions.Item label={t('clients.language')}>
<Tag color={languageConfig[selectedConnection.language]?.color
?? 'default'}>
diff --git a/web/src/pages/cluster/clientsSearch.ts
b/web/src/pages/cluster/clientsSearch.ts
new file mode 100644
index 000000000..48936db19
--- /dev/null
+++ b/web/src/pages/cluster/clientsSearch.ts
@@ -0,0 +1,33 @@
+/*
+ * 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.
+ */
+
+export type ClientSearchFields = {
+ clientId?: string | null;
+ address?: string | null;
+};
+
+export const matchesClientSearch = (
+ connection: ClientSearchFields,
+ rawSearch: string | null | undefined,
+) => {
+ const normalizedSearch = (rawSearch ?? '').trim().toLowerCase();
+ if (!normalizedSearch) return true;
+ return (
+ (connection.clientId ?? '').toLowerCase().includes(normalizedSearch) ||
+ (connection.address ?? '').toLowerCase().includes(normalizedSearch)
+ );
+};
diff --git a/web/src/utils/clientConnectionDiagnostics.ts
b/web/src/utils/clientConnectionDiagnostics.ts
index 640fc16eb..9bc9243c2 100644
--- a/web/src/utils/clientConnectionDiagnostics.ts
+++ b/web/src/utils/clientConnectionDiagnostics.ts
@@ -111,12 +111,12 @@ const normalizeText = (value?: string | null, fallback =
'unknown'): string => {
return trimmed || fallback;
};
-const uniqueSorted = (values: string[]): string[] =>
+const uniqueSorted = (values: Array<string | null | undefined>): string[] =>
[...new Set(values.map((value) =>
normalizeText(value)).filter(Boolean))].sort((a, b) =>
a.localeCompare(b),
);
-const countBy = (values: string[]): Map<string, number> => {
+const countBy = (values: Array<string | null | undefined>): Map<string,
number> => {
const counts = new Map<string, number>();
values.forEach((value) => {
const normalized = normalizeText(value);