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 7df91fde3 fix(studio): preserve field boundaries and pull ownership
(#6064)
7df91fde3 is described below
commit 7df91fde36241973ee604b763e5edfae5f26c40f
Author: coder999o <[email protected]>
AuthorDate: Mon Oct 12 16:22:27 2026 +0800
fix(studio): preserve field boundaries and pull ownership (#6064)
Three fixes where a delimiter or an in-flight request let one value stand
in for another: separators written verbatim into a message's wire properties, a
`|` used to delimit a connection identity when `|` is a legal topic character,
and a dismissed queue card whose ownership token outlived the dismissal.
- #6064 RocketMQ's property codec does not escape its separators —
`MessageDecoder.messageProperties2String` concatenates `name\u0001value\u0002`
and `string2messageProperties` splits pairs on `indexOf(PROPERTY_SEPARATOR)`
and then on the **first** `NAME_VALUE_SEPARATOR` inside a pair — while
`RocketMQAdminClientImpl.sendOwnedMessage` wrote the tag, key and every user
property straight onto the wire at `:526`, `:529` and `:542`, so a `\u0002` in
a name, tag, key or value truncated tha [...]
- #6207 `closeEntry` at `QueueBrowser.tsx:145-147` only filtered `entries`
and left `pullingRef` alone, so a same-key pull still in flight passed the
`requestId === requestSeqRef.current` ownership check at `:128` and re-inserted
the card the operator had just dismissed (the `catch` branch at `:134`
re-raised a toast for the same abandoned pull), while `pullingRef.has(key)` at
`:116` kept that queue locked until the stale request settled → the in-flight
registry becomes a `Map<string, [...]
- #6303 `clientConnectionDiagnostics.ts:156-162` built the
duplicate-detection identity with `[type, clientId, groupOrTopic,
address].join('|')` and `:267` decoded it with `split('|')`, but `|` is a legal
topic and group character — the kernel's `TopicValidator` sets
`VALID_CHAR_BIT_MAP['|'] = true` — and a client `instanceName` is arbitrary, so
two distinct quadruples could collapse into one `identityCounts` key and raise
a false `EXACT_DUPLICATE_CONNECTION`, while the `clientId`, `r [...]
Tests: `RocketMQAdminClientImplTest` adds four `@Test` methods plus a
`@ParameterizedTest(ints = {1, 2})` — six executions covering an illegal
property name for each separator, an illegal value, tag and key, a
`verifyNoInteractions(runtimeAdminClientResolver, clientPool, sendProducer)`
proof that the rejection happens before any client is resolved, an audit
verification, and a round-trip case that re-encodes and re-decodes the captured
properties through the real `MessageDecoder` so o [...]
Folded: #6207, #6303 were closed as folded into this commit (same author).
---
.../provider/apache/RocketMQAdminClientImpl.java | 26 ++++
.../apache/RocketMQAdminClientImplTest.java | 90 +++++++++++++-
web/src/components/QueueBrowser.tsx | 19 +--
web/src/components/__tests__/QueueBrowser.test.tsx | 134 ++++++++++++++++++++-
web/src/utils/clientConnectionDiagnostics.test.ts | 26 ++++
web/src/utils/clientConnectionDiagnostics.ts | 6 +-
6 files changed, 286 insertions(+), 15 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImpl.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImpl.java
index a72f219c1..c5ab62e94 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImpl.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImpl.java
@@ -27,6 +27,7 @@ import org.apache.rocketmq.common.TopicConfig;
import org.apache.rocketmq.common.TopicAttributes;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.common.message.MessageConst;
+import org.apache.rocketmq.common.message.MessageDecoder;
import org.apache.rocketmq.common.message.MessageQueue;
import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
import org.apache.rocketmq.remoting.protocol.ResponseCode;
@@ -498,6 +499,7 @@ public class RocketMQAdminClientImpl implements AdminClient
{
}
ResourceOwnershipGuard.requireText(request.getInstanceId(),
"instanceId");
validateMessageSize(request);
+ validateMessageProperties(request);
InstanceVO instance =
ownershipGuard.requireInstance(request.getInstanceId());
request.setInstanceId(instance.getName());
request.setTopic(ResourceOwnershipGuard.requireText(request.getTopic(),
"topicName"));
@@ -590,6 +592,30 @@ public class RocketMQAdminClientImpl implements
AdminClient {
}
}
+ private void validateMessageProperties(SendMessageDTO request) {
+ validatePropertyText(request, request.getTag(), "tag", false);
+ validatePropertyText(request, request.getKey(), "key", false);
+ if (request.getProperties() != null) {
+ request.getProperties().forEach((name, value) -> {
+ if (!isSystemReservedProperty(name)) {
+ validatePropertyText(request, name, "property name", true);
+ validatePropertyText(request, value, "property value",
false);
+ }
+ });
+ }
+ }
+
+ private void validatePropertyText(SendMessageDTO request, String text,
String field, boolean name) {
+ // RocketMQ does not escape its wire separators. The name/value
separator is safe
+ // inside a value, but either separator inside a name changes the
decoded property.
+ if (text != null && (text.indexOf(MessageDecoder.PROPERTY_SEPARATOR)
>= 0
+ || name && text.indexOf(MessageDecoder.NAME_VALUE_SEPARATOR)
>= 0)) {
+ String message = "Message " + field + " contains a RocketMQ
property separator";
+ recordAudit("SEND_MESSAGE", request.getTopic(), message, "FAILED");
+ throw new BusinessException(400, message);
+ }
+ }
+
private static boolean isSystemReservedProperty(String key) {
return key == null
|| MessageConst.STRING_HASH_SET.contains(key)
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImplTest.java
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImplTest.java
index 5b53ba63a..809a99b09 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImplTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImplTest.java
@@ -31,6 +31,7 @@ import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.client.producer.SendStatus;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.common.message.MessageConst;
+import org.apache.rocketmq.common.message.MessageDecoder;
import org.apache.rocketmq.common.message.MessageQueue;
import org.apache.rocketmq.remoting.exception.RemotingTimeoutException;
import org.apache.rocketmq.remoting.protocol.admin.ConsumeStats;
@@ -1730,6 +1731,93 @@ class RocketMQAdminClientImplTest {
eq(null), eq("Message send did not succeed: null"),
eq("FAILED"));
}
+ @ParameterizedTest
+ @ValueSource(ints = {1, 2})
+ void sendMessageRejectsPropertyNameSeparatorsTest(int separator) {
+ SendMessageDTO request = SendMessageDTO.builder()
+ .instanceId("instance-a").topic("TopicA").body("hello")
+ .properties(Map.of("customer" + (char) separator + "name",
"alice"))
+ .build();
+
+ assertThatThrownBy(() -> adminClient.sendMessage(request))
+ .isInstanceOfSatisfying(BusinessException.class, exception -> {
+ assertThat(exception.getCode()).isEqualTo(400);
+ assertThat(exception).hasMessageContaining("property
name");
+ });
+ verifyNoInteractions(runtimeAdminClientResolver, clientPool,
sendProducer);
+ verify(auditService).record(eq("SEND_MESSAGE"), eq("MESSAGE"),
eq("TopicA"),
+ isNull(), eq("Message property name contains a RocketMQ
property separator"), eq("FAILED"));
+ }
+
+ @Test
+ void sendMessageRejectsPropertyValueInjectionTest() {
+ SendMessageDTO request = SendMessageDTO.builder()
+ .instanceId("instance-a").topic("TopicA").body("hello")
+ .properties(Map.of("customer", "alice" +
MessageDecoder.PROPERTY_SEPARATOR
+ + "extra" + MessageDecoder.NAME_VALUE_SEPARATOR +
"unexpected"))
+ .build();
+
+ assertThatThrownBy(() -> adminClient.sendMessage(request))
+ .isInstanceOfSatisfying(BusinessException.class, exception -> {
+ assertThat(exception.getCode()).isEqualTo(400);
+ assertThat(exception).hasMessageContaining("property
value");
+ });
+ verifyNoInteractions(runtimeAdminClientResolver, clientPool,
sendProducer);
+ }
+
+ @Test
+ void sendMessageRejectsTagPropertySeparatorTest() {
+ SendMessageDTO request = SendMessageDTO.builder()
+ .instanceId("instance-a").topic("TopicA").body("hello")
+ .tag("tagA" + MessageDecoder.PROPERTY_SEPARATOR + "extra")
+ .build();
+
+ assertThatThrownBy(() -> adminClient.sendMessage(request))
+ .isInstanceOfSatisfying(BusinessException.class, exception -> {
+ assertThat(exception.getCode()).isEqualTo(400);
+ assertThat(exception).hasMessageContaining("tag");
+ });
+ verifyNoInteractions(runtimeAdminClientResolver, clientPool,
sendProducer);
+ }
+
+ @Test
+ void sendMessageRejectsKeyPropertySeparatorTest() {
+ SendMessageDTO request = SendMessageDTO.builder()
+ .instanceId("instance-a").topic("TopicA").body("hello")
+ .key("keyA" + MessageDecoder.PROPERTY_SEPARATOR + "extra")
+ .build();
+
+ assertThatThrownBy(() -> adminClient.sendMessage(request))
+ .isInstanceOfSatisfying(BusinessException.class, exception -> {
+ assertThat(exception.getCode()).isEqualTo(400);
+ assertThat(exception).hasMessageContaining("key");
+ });
+ verifyNoInteractions(runtimeAdminClientResolver, clientPool,
sendProducer);
+ }
+
+ @Test
+ void sendMessagePreservesEncodablePropertyValuesTest() throws Exception {
+ SendResult sendResult = new SendResult();
+ sendResult.setSendStatus(SendStatus.SEND_OK);
+ when(sendProducer.send(any(Message.class))).thenReturn(sendResult);
+ Map<String, String> userProperties = Map.of("customer",
"\u754c,alice\nsecond=line\tend"
+ + MessageDecoder.NAME_VALUE_SEPARATOR + "suffix");
+ SendMessageDTO request = SendMessageDTO.builder()
+ .instanceId("instance-a").topic("TopicA").body("hello")
+ .tag("tagA").key("keyA").properties(userProperties)
+ .build();
+
+ adminClient.sendMessage(request);
+
+ ArgumentCaptor<Message> captor =
ArgumentCaptor.forClass(Message.class);
+ verify(sendProducer).send(captor.capture());
+ Map<String, String> decoded = MessageDecoder.string2messageProperties(
+
MessageDecoder.messageProperties2String(captor.getValue().getProperties()));
+ assertThat(decoded).containsAllEntriesOf(userProperties)
+ .containsEntry(MessageConst.PROPERTY_TAGS, "tagA")
+ .containsEntry(MessageConst.PROPERTY_KEYS, "keyA");
+ }
+
@Test
void sendMessageFiltersSystemReservedPropertiesTest() throws Exception {
SendResult sendResult = new SendResult();
@@ -1740,7 +1828,7 @@ class RocketMQAdminClientImplTest {
// Source properties copied verbatim from a stored message used to
fail the send with
// "The Property<KEYS> is used by system"; reserved keys must be
dropped instead.
Map<String, String> properties = new HashMap<>();
- properties.put(MessageConst.PROPERTY_KEYS, "injected-keys");
+ properties.put(MessageConst.PROPERTY_KEYS, "injected" +
MessageDecoder.PROPERTY_SEPARATOR + "keys");
properties.put(MessageConst.PROPERTY_TAGS, "injected-tags");
properties.put(MessageConst.PROPERTY_UNIQ_CLIENT_MESSAGE_ID_KEYIDX,
"injected-uniq");
properties.put(MessageConst.PROPERTY_WAIT_STORE_MSG_OK, "false");
diff --git a/web/src/components/QueueBrowser.tsx
b/web/src/components/QueueBrowser.tsx
index 26adcab79..9de3120b5 100644
--- a/web/src/components/QueueBrowser.tsx
+++ b/web/src/components/QueueBrowser.tsx
@@ -59,7 +59,7 @@ export const useQueueBrowser = (instanceId?: string) => {
const [entries, setEntries] = useState<PulledEntry[]>([]);
const requestSeqRef = useRef(0);
const loadingRef = useRef(false);
- const pullingRef = useRef(new Set<string>());
+ const pullingRef = useRef(new Map<string, symbol>());
useEffect(() => {
const requestId = ++requestSeqRef.current;
@@ -119,8 +119,11 @@ export const useQueueBrowser = (instanceId?: string) => {
const key = `${queue.brokerName}-${queue.queueId}`;
if (pullingRef.current.has(key)) return;
const offset = offsets[key] ?? queue.minOffset;
- pullingRef.current.add(key);
- setPulling(new Set(pullingRef.current));
+ const pullToken = Symbol(key);
+ pullingRef.current.set(key, pullToken);
+ setPulling(new Set(pullingRef.current.keys()));
+ const ownsPull = () =>
+ requestId === requestSeqRef.current && pullingRef.current.get(key) ===
pullToken;
try {
const msg = await pullMessageAtOffset({
instanceId,
@@ -129,24 +132,26 @@ export const useQueueBrowser = (instanceId?: string) => {
queueId: queue.queueId,
offset,
});
- if (requestId !== requestSeqRef.current) return;
+ if (!ownsPull()) return;
setEntries((prev) => [
...prev.filter((entry) => entry.key !== key),
{ key, offset, message: msg },
]);
} catch (err) {
- if (requestId === requestSeqRef.current) {
+ if (ownsPull()) {
message.error(err instanceof Error ? err.message : '拉取消息失败');
}
} finally {
- if (requestId === requestSeqRef.current) {
+ if (ownsPull()) {
pullingRef.current.delete(key);
- setPulling(new Set(pullingRef.current));
+ setPulling(new Set(pullingRef.current.keys()));
}
}
};
const closeEntry = (key: string) => {
+ pullingRef.current.delete(key);
+ setPulling(new Set(pullingRef.current.keys()));
setEntries((prev) => prev.filter((entry) => entry.key !== key));
};
diff --git a/web/src/components/__tests__/QueueBrowser.test.tsx
b/web/src/components/__tests__/QueueBrowser.test.tsx
index 0285171c3..e59a440b3 100644
--- a/web/src/components/__tests__/QueueBrowser.test.tsx
+++ b/web/src/components/__tests__/QueueBrowser.test.tsx
@@ -15,11 +15,19 @@
* limitations under the License.
*/
-import { act, fireEvent, render, screen, waitFor, within } from
'@testing-library/react';
+import {
+ act,
+ fireEvent,
+ render,
+ renderHook,
+ screen,
+ waitFor,
+ within,
+} from '@testing-library/react';
+import { message } from 'antd';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from
'vitest';
import { StrictMode } from 'react';
-import { message } from 'antd';
import type { MessageRecord, QueueOffset } from '../../api/message';
import { getQueueOffsets, pullMessageAtOffset } from '../../api/message';
import { QueueBrowserResults, useQueueBrowser } from '../QueueBrowser';
@@ -47,10 +55,12 @@ beforeAll(() => {
const createDeferred = <T,>() => {
let resolve!: (value: T) => void;
- const promise = new Promise<T>((promiseResolve) => {
+ let reject!: (reason: Error) => void;
+ const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
+ reject = promiseReject;
});
- return { promise, resolve };
+ return { promise, resolve, reject };
};
const queue = (brokerName: string): QueueOffset => ({
@@ -76,6 +86,16 @@ const messageRecord = (msgId: string): MessageRecord => ({
size: 2,
});
+const renderLoadedBrowser = async (queues = [queue('broker-a')]) => {
+ vi.mocked(getQueueOffsets).mockResolvedValue(queues);
+ const { result } = renderHook(() => useQueueBrowser('instance-a'));
+ act(() => result.current.setTopic('topic-a'));
+ await act(async () => {
+ await result.current.loadQueues();
+ });
+ return result;
+};
+
function QueueBrowserProbe({ instanceId = 'instance-a' }: { instanceId?:
string }) {
const state = useQueueBrowser(instanceId);
const firstQueue = state.queues[0];
@@ -130,6 +150,8 @@ function QueueBrowserResultsProbe() {
describe('QueueBrowser request ownership', () => {
beforeEach(() => {
vi.clearAllMocks();
+ vi.mocked(getQueueOffsets).mockReset();
+ vi.mocked(pullMessageAtOffset).mockReset();
});
describe('page lifetime', () => {
@@ -189,6 +211,7 @@ describe('QueueBrowser request ownership', () => {
},
);
});
+ afterEach(() => vi.restoreAllMocks());
it('keeps the newest topic queue load when an older request resolves later',
async () => {
const topicA = createDeferred<QueueOffset[]>();
@@ -283,6 +306,109 @@ describe('QueueBrowser request ownership', () => {
await act(async () => pull.resolve(messageRecord('message-a')));
});
+ it('does not reopen a closed message card when its replacement pull
resolves', async () => {
+ const replacement = createDeferred<MessageRecord | null>();
+ vi.mocked(getQueueOffsets).mockResolvedValue([queue('broker-a')]);
+ vi.mocked(pullMessageAtOffset)
+ .mockResolvedValueOnce(messageRecord('initial-message'))
+ .mockReturnValueOnce(replacement.promise);
+ const user = userEvent.setup();
+ render(<QueueBrowserResultsProbe />);
+
+ await user.click(screen.getByRole('button', { name: 'topic-a' }));
+ await user.click(screen.getByRole('button', { name: 'load' }));
+ await screen.findByText('broker-a');
+ await user.click(screen.getByRole('button', { name: 'pull' }));
+ await screen.findByText('initial-message');
+ await user.click(screen.getByRole('button', { name: 'pull' }));
+ await waitFor(() => expect(pullMessageAtOffset).toHaveBeenCalledTimes(2));
+ await user.click(screen.getByRole('button', { name: 'close' }));
+
+ await act(async () => replacement.resolve(messageRecord('late-message')));
+
+ expect(screen.queryByText('initial-message')).not.toBeInTheDocument();
+ expect(screen.queryByText('late-message')).not.toBeInTheDocument();
+ });
+
+ it('closing one queue keeps another queue pull active', async () => {
+ const closedPull = createDeferred<MessageRecord | null>();
+ const otherPull = createDeferred<MessageRecord | null>();
+ vi.mocked(pullMessageAtOffset)
+ .mockResolvedValueOnce(messageRecord('initial-message'))
+ .mockReturnValueOnce(closedPull.promise)
+ .mockReturnValueOnce(otherPull.promise);
+ const browser = await renderLoadedBrowser([queue('broker-a'),
queue('broker-b')]);
+ await act(async () => {
+ await browser.current.handlePull(queue('broker-a'));
+ });
+ act(() => {
+ void browser.current.handlePull(queue('broker-a'));
+ void browser.current.handlePull(queue('broker-b'));
+ browser.current.closeEntry('broker-a-0');
+ });
+
+ await act(async () => closedPull.resolve(messageRecord('late-message')));
+ expect(browser.current.entries).toEqual([]);
+ expect([...browser.current.pulling]).toEqual(['broker-b-0']);
+ await act(async () => otherPull.resolve(messageRecord('other-message')));
+ expect(browser.current.entries.map((entry) =>
entry.message?.msgId)).toEqual(['other-message']);
+ expect(browser.current.pulling.size).toBe(0);
+ });
+
+ it('a dismissed pull cannot unlock or overwrite a newly requested message',
async () => {
+ const stalePull = createDeferred<MessageRecord | null>();
+ const currentPull = createDeferred<MessageRecord | null>();
+ vi.mocked(pullMessageAtOffset)
+ .mockResolvedValueOnce(messageRecord('initial-message'))
+ .mockReturnValueOnce(stalePull.promise)
+ .mockReturnValueOnce(currentPull.promise);
+ const browser = await renderLoadedBrowser();
+ await act(async () => {
+ await browser.current.handlePull(queue('broker-a'));
+ });
+ act(() => {
+ void browser.current.handlePull(queue('broker-a'));
+ browser.current.closeEntry('broker-a-0');
+ void browser.current.handlePull(queue('broker-a'));
+ });
+ expect(pullMessageAtOffset).toHaveBeenCalledTimes(3);
+
+ await act(async () => stalePull.resolve(messageRecord('stale-message')));
+ expect(browser.current.entries).toEqual([]);
+ expect(browser.current.pulling.has('broker-a-0')).toBe(true);
+ act(() => {
+ void browser.current.handlePull(queue('broker-a'));
+ });
+ expect(pullMessageAtOffset).toHaveBeenCalledTimes(3);
+ await act(async () =>
currentPull.resolve(messageRecord('current-message')));
+ expect(browser.current.entries.map((entry) =>
entry.message?.msgId)).toEqual([
+ 'current-message',
+ ]);
+ expect(browser.current.pulling.size).toBe(0);
+ });
+
+ it('ignores a dismissed pull failure', async () => {
+ const dismissedPull = createDeferred<MessageRecord | null>();
+ const errorToast = vi.spyOn(message, 'error').mockImplementation(vi.fn());
+ vi.mocked(pullMessageAtOffset)
+ .mockResolvedValueOnce(messageRecord('initial-message'))
+ .mockReturnValueOnce(dismissedPull.promise);
+ const browser = await renderLoadedBrowser();
+ await act(async () => {
+ await browser.current.handlePull(queue('broker-a'));
+ });
+ act(() => {
+ void browser.current.handlePull(queue('broker-a'));
+ browser.current.closeEntry('broker-a-0');
+ });
+
+ await act(async () => dismissedPull.reject(new Error('dismissed request
failed')));
+ expect(errorToast).not.toHaveBeenCalled();
+ expect(browser.current.entries).toEqual([]);
+ expect(browser.current.pulling.size).toBe(0);
+ errorToast.mockRestore();
+ });
+
it('keeps a replacement pull locked when a stale pull settles', async () => {
const stalePull = createDeferred<MessageRecord | null>();
const currentPull = createDeferred<MessageRecord | null>();
diff --git a/web/src/utils/clientConnectionDiagnostics.test.ts
b/web/src/utils/clientConnectionDiagnostics.test.ts
index 30221b91d..1c8912c64 100644
--- a/web/src/utils/clientConnectionDiagnostics.test.ts
+++ b/web/src/utils/clientConnectionDiagnostics.test.ts
@@ -131,6 +131,32 @@ describe('client connection diagnostics', () => {
);
});
+ it('keeps distinct identities containing pipe characters separate', () => {
+ const diagnostics = analyzeClientConnections([
+ connection({ clientId: 'client|partition', groupOrTopic: 'orders' }),
+ connection({ clientId: 'client', groupOrTopic: 'partition|orders' }),
+ ]);
+
+ expect(diagnostics.issues.filter((item) => item.code ===
'EXACT_DUPLICATE_CONNECTION')).toEqual(
+ [],
+ );
+ });
+
+ it('preserves complete identity fields in duplicate evidence', () => {
+ const original = connection({ clientId: 'client|partition', groupOrTopic:
'orders|events' });
+ const diagnostics = analyzeClientConnections([original, { ...original }]);
+ const duplicates = diagnostics.issues.filter(
+ (item) => item.code === 'EXACT_DUPLICATE_CONNECTION',
+ );
+
+ expect(duplicates).toHaveLength(1);
+ expect(duplicates[0]).toMatchObject({
+ clientId: 'client|partition',
+ resource: 'orders|events',
+ evidence: ['address=10.0.1.10:49152', 'count=2'],
+ });
+ });
+
it('flags mixed protocols, mixed versions, and single consumer instances per
resource', () => {
const diagnostics = analyzeClientConnections([
connection({
diff --git a/web/src/utils/clientConnectionDiagnostics.ts
b/web/src/utils/clientConnectionDiagnostics.ts
index 9bc9243c2..e9d9029d4 100644
--- a/web/src/utils/clientConnectionDiagnostics.ts
+++ b/web/src/utils/clientConnectionDiagnostics.ts
@@ -154,12 +154,12 @@ const issue = (
});
const connectionIdentity = (connection: ClientConnection): string =>
- [
+ JSON.stringify([
normalizeText(connection.type),
normalizeText(connection.clientId),
normalizeText(connection.groupOrTopic),
normalizeText(connection.address),
- ].join('|');
+ ]);
const resourceKey = (connection: ClientConnection): string =>
`${normalizeText(connection.type)}:${normalizeText(connection.groupOrTopic)}`;
@@ -264,7 +264,7 @@ const addClientIdIssues = (connections: ClientConnection[],
issues: ClientConnec
identityCounts.forEach((count, identity) => {
if (count <= 1) return;
- const [, clientId, resource, address] = identity.split('|');
+ const [, clientId, resource, address]: string[] = JSON.parse(identity);
issues.push(
issue(
'EXACT_DUPLICATE_CONNECTION',