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 38c569d38 fix(client): show proxy-connected consumers in the client 
list (#5453)
38c569d38 is described below

commit 38c569d382702b319d7d6dcfb0e8d41a0723b9b0
Author: pilichoumao <[email protected]>
AuthorDate: Fri Oct 9 12:52:07 2026 +0800

    fix(client): show proxy-connected consumers in the client list (#5453)
    
    Consumers that connect through a Proxy never showed up in the client list: 
the consumer scan only
    asked brokers, so a group whose clients sit behind a Proxy looked offline. 
The scan now walks the
    brokers of the selected topology and, for each of them, the Proxy addresses 
discovered through the
    heartbeat-syncer group, queries both by explicit address, and de-duplicates 
by
    (cluster, group, clientId, clientAddr). A Proxy response carries no 
protocol and only a
    compatibility version, so both fields are null on Proxy rows and the page 
renders `-`; `partial`
    marks a scan where some query failed, and a scan that failed everywhere 
with nothing to show is a
    502. `docs/api-spec.md` documents the new `namesrvAddr` parameter and these 
field semantics.
    
    Maintainer edits on top of the contribution (review follow-ups):
    
    - Offline detection now reuses the existing `isGroupConnectionAbsent`, so a 
"group not online"
      answer that does not carry code 206 no longer counts as a scan failure - 
it used to mark every row
      `partial`, and to 502 when it was the only group.
    - Proxy discovery treats `TOPIC_NOT_EXIST` like `CONSUMER_NOT_ONLINE` ("no 
Proxy observable right
      now"), matching `ProxyConsumerResolver`.
    - Failed discoveries are cached for the rest of the request, so one 
unreachable broker is probed
      once instead of once per remaining group. A batch deadline like #4877's 
was deliberately not
      added: this path is a serial blocking RPC rather than an executor batch, 
and a time-based bound
      cannot be pinned by a test.
    - Extracted `toProxyRemotingAddresses` so both discovery paths share one 
address mapping.
    - `clients.partialScan` (zh + en) replaces the hardcoded Alert text, and 
the "scan limit was
      reached" wording is gone from both it and `docs/api-spec.md` - the 
consumer path has no such limit.
      The spec still documents `protocol` / `version` as `null` rather than 
absent: `ClientConnectionVO`
      carries no `@JsonInclude` and there is no global Jackson inclusion 
setting, so nulls are emitted.
    - The protocol column gains an "unknown" filter option, so the rows behind 
the `-: N` statistic can
      actually be filtered; the CSV export uses the same sentinel.
    
    Verification: `RocketMQClientProviderTest` (43) + 
`ProxyConsumerResolverTest` (6) green with the dev
    profile, 0 checkstyle violations; `ClientsPage.test.tsx` 24/24, prettier 
and eslint clean. Each new
    behaviour was mutation-checked - reverting the offline detection, the 
`TOPIC_NOT_EXIST` branch, the
    failure cache or the unknown-protocol filter option turns its case red.
---
 docs/api-spec.md                                   |  15 +-
 .../provider/apache/ProxyConsumerResolver.java     |  51 ++--
 .../provider/apache/RocketMQClientProvider.java    | 125 ++++++---
 .../apache/RocketMQClientProviderTest.java         | 293 ++++++++++++++++++++-
 web/src/api/connections.ts                         |   4 +-
 web/src/i18n/translations.ts                       |   4 +
 .../pages/cluster/__tests__/ClientsPage.test.tsx   |  89 +++++++
 web/src/pages/cluster/clients.tsx                  |  43 +--
 8 files changed, 538 insertions(+), 86 deletions(-)

diff --git a/docs/api-spec.md b/docs/api-spec.md
index 2205e6233..a53e229ff 100644
--- a/docs/api-spec.md
+++ b/docs/api-spec.md
@@ -1839,13 +1839,14 @@ GET 
/api/dlq/export-excel?instanceId={instanceId}&groupName={groupName}&startTim
 ### 10.1 获取客户端连接列表
 
 ```
-GET /api/clients?clusterId={clusterId}&type={type}
+GET /api/clients?namesrvAddr={namesrvAddr}&clusterId={clusterId}&type={type}
 ```
 
 **Query Parameters:**
 
 | 参数 | 类型 | 必填 | 说明 |
 |------|------|------|------|
+| `namesrvAddr` | `string` | 是 | 页面选中的 NameServer 地址 |
 | `clusterId` | `string` | 否 | 按集群过滤 |
 | `type` | `string` | 否 | 按类型过滤: `Producer` / `Consumer` |
 
@@ -1856,13 +1857,19 @@ GET /api/clients?clusterId={clusterId}&type={type}
 | `clientId` | `string` | 客户端 ID |
 | `type` | `string` | 类型: `Producer` / `Consumer` |
 | `groupOrTopic` | `string` | 消费组名或 Topic 名 |
-| `protocol` | `string` | 协议: `gRPC` / `Remoting` |
+| `protocol` | `string / null` | 可确认的协议: `gRPC` / `Remoting`;未知为 `null` |
 | `address` | `string` | 客户端地址 |
-| `language` | `string` | 客户端语言: `Java` / `Go` / `Python` / `Rust` / `C++` / 
`C#` / `Node.js` / `PHP` |
-| `version` | `string` | SDK 版本号 |
+| `language` | `string / null` | 客户端语言: `Java` / `Go` / `Python` / `Rust` / 
`C++` / `C#` / `Node.js` / `PHP` |
+| `version` | `string / null` | 可确认的 SDK 版本号;未知为 `null` |
 | `connectedAt` | `string` | 连接时间 |
+| `partial` | `boolean` | 连接扫描不完整(部分查询失败) |
 | `clusterName` | `string` | 所属集群名称(显示在第一列) |
 
+消费者查询合并所选 NameServer、集群中的 Broker 和 Proxy 连接,并按客户端及地址去重。
+Proxy 地址沿用心跳同步消费组发现机制及 Remoting 管理端口 8080,仅在本次请求内缓存。
+现有 Proxy 响应不提供客户端协议,版本号也可能是兼容值;这两项返回 `null`,页面显示 `-`,语言保留实际返回的可识别值。
+确认离线返回空列表;查询失败时保留已有行并设置 `partial`,没有可返回的行时返回查询错误。
+
 ### 10.2 获取 Producer Group 候选列表
 
 ```
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/ProxyConsumerResolver.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/ProxyConsumerResolver.java
index ba0da98fc..d0f254188 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/ProxyConsumerResolver.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/ProxyConsumerResolver.java
@@ -18,6 +18,10 @@
 package org.apache.rocketmq.studio.provider.apache;
 
 import jakarta.annotation.PreDestroy;
+import org.apache.rocketmq.client.exception.MQBrokerException;
+import org.apache.rocketmq.client.exception.MQClientException;
+import org.apache.rocketmq.remoting.exception.RemotingException;
+import org.apache.rocketmq.tools.admin.MQAdminExt;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.rocketmq.remoting.netty.NettyClientConfig;
 import org.apache.rocketmq.remoting.netty.NettyRemotingClient;
@@ -183,22 +187,10 @@ public class ProxyConsumerResolver {
         if (cached != null && cached.expiresAtMillis() > 
System.currentTimeMillis()) {
             return ProxyAddressResolution.available(cached.addresses());
         }
-        Set<String> ips = new LinkedHashSet<>();
+        List<String> addresses;
         try {
-            executeAdmin(instanceId, admin -> {
-                ConsumerConnection connection = 
admin.examineConsumerConnectionInfo(HEARTBEAT_SYNCER_CONSUMER_GROUP);
-                if (connection != null && connection.getConnectionSet() != 
null) {
-                    for (Connection conn : connection.getConnectionSet()) {
-                        String clientAddr = conn.getClientAddr();
-                        if (clientAddr == null || clientAddr.isBlank()) {
-                            continue;
-                        }
-                        int separator = clientAddr.lastIndexOf(':');
-                        ips.add(separator > 0 ? clientAddr.substring(0, 
separator) : clientAddr);
-                    }
-                }
-                return null;
-            });
+            addresses = executeAdmin(instanceId, admin -> 
toProxyRemotingAddresses(
+                    
admin.examineConsumerConnectionInfo(HEARTBEAT_SYNCER_CONSUMER_GROUP)));
         } catch (Exception e) {
             if (MqResponseCodes.hasResponseCode(e, 
ResponseCode.CONSUMER_NOT_ONLINE, ResponseCode.TOPIC_NOT_EXIST)) {
                 // A known absent heartbeat-syncer group means no proxy 
address is currently observable.
@@ -208,7 +200,6 @@ public class ProxyConsumerResolver {
             log.debug("Proxy discovery via heartbeat syncer failed for 
instance {}: {}", instanceId, e.getMessage());
             return ProxyAddressResolution.unavailable();
         }
-        List<String> addresses = ips.stream().map(ip -> ip + ":" + 
PROXY_REMOTING_PORT).toList();
         proxyAddressCache.put(cacheKey, new CachedProxyAddresses(addresses, 
System.currentTimeMillis() + PROXY_ADDRESS_CACHE_TTL_MILLIS));
         return ProxyAddressResolution.available(addresses);
     }
@@ -220,6 +211,34 @@ public class ProxyConsumerResolver {
         return adminFactory.execute(properties.getNamesrvAddr(), null, action);
     }
 
+    /**
+     * Discovers proxies through a broker in the selected topology, using the 
caller's pooled
+     * admin (and credentials). Callers cache this only within their current 
inventory request;
+     * the legacy instance cache must not mix NameServers or clusters with 
identical group names.
+     */
+    List<String> discoverProxyAddresses(MQAdminExt admin, String brokerAddress)
+            throws MQClientException, MQBrokerException, RemotingException, 
InterruptedException {
+        return toProxyRemotingAddresses(
+                
admin.examineConsumerConnectionInfo(HEARTBEAT_SYNCER_CONSUMER_GROUP, 
brokerAddress));
+    }
+
+    private static List<String> toProxyRemotingAddresses(ConsumerConnection 
syncer) {
+        if (syncer == null || syncer.getConnectionSet() == null) {
+            return List.of();
+        }
+        Set<String> addresses = new LinkedHashSet<>();
+        for (Connection connection : syncer.getConnectionSet()) {
+            String clientAddr = connection == null ? null : 
connection.getClientAddr();
+            if (clientAddr == null || clientAddr.isBlank()) {
+                continue;
+            }
+            int separator = clientAddr.lastIndexOf(':');
+            addresses.add((separator > 0 ? clientAddr.substring(0, separator) 
: clientAddr)
+                    + ":" + PROXY_REMOTING_PORT);
+        }
+        return List.copyOf(addresses);
+    }
+
     private NettyRemotingClient remotingClient() {
         NettyRemotingClient client = remotingClient;
         if (client == null) {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQClientProvider.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQClientProvider.java
index f89a84401..cd9e9a6ed 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQClientProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQClientProvider.java
@@ -17,6 +17,8 @@
 package org.apache.rocketmq.studio.provider.apache;
 
 import org.apache.rocketmq.client.exception.MQClientException;
+import org.apache.rocketmq.client.exception.MQBrokerException;
+import org.apache.rocketmq.remoting.exception.RemotingException;
 import org.apache.rocketmq.common.MQVersion;
 import org.apache.rocketmq.remoting.protocol.LanguageCode;
 import org.apache.rocketmq.remoting.protocol.ResponseCode;
@@ -69,6 +71,7 @@ public class RocketMQClientProvider implements ClientProvider 
{
 
     private final RuntimeAdminClientResolver runtimeAdminClientResolver;
     private final MqAdminExtFactory adminFactory;
+    private final ProxyConsumerResolver proxyConsumerResolver;
 
     @Override
     public List<ClientConnectionVO> findConnections(String instanceId, String 
clusterId, String type) {
@@ -193,7 +196,7 @@ public class RocketMQClientProvider implements 
ClientProvider {
             return producerConnection.getConnectionSet().stream()
                     .filter(Objects::nonNull)
                     .map(connection -> toConnectionVO(
-                            connection, ClientType.Producer, topic, 
producerGroup, null))
+                            connection, ClientType.Producer, topic, 
producerGroup, null, false))
                     .toList();
         } catch (MQClientException e) {
             if (isTopicNotExist(e) || isGroupConnectionAbsent(e)) {
@@ -210,13 +213,16 @@ public class RocketMQClientProvider implements 
ClientProvider {
         }
     }
 
-    private boolean isGroupConnectionAbsent(MQClientException e) {
+    private boolean isGroupConnectionAbsent(Throwable e) {
         if (MqResponseCodes.hasResponseCode(e, 
ResponseCode.CONSUMER_NOT_ONLINE)) {
             // rocketmq-tools examineConsumerConnectionInfo throws 
CONSUMER_NOT_ONLINE (206)
             // for a group with no online connections.
             return true;
         }
-        String message = e.getErrorMessage() == null ? e.getMessage() : 
e.getErrorMessage();
+        String message = e instanceof MQClientException clientException
+                && clientException.getErrorMessage() != null
+                        ? clientException.getErrorMessage()
+                        : e.getMessage();
         return message != null && (message.contains("Not found the consumer 
group connection")
                 || message.contains("Not found the producer group 
connection"));
     }
@@ -337,49 +343,94 @@ public class RocketMQClientProvider implements 
ClientProvider {
     }
 
     private List<ClientConnectionVO> findConsumerConnections(MQAdminExt 
adminExt, String clusterId) {
-        List<ClientConnectionVO> result = new ArrayList<>();
-        Map<String, String> groups = collectSubscriptionGroups(adminExt, 
clusterId);
+        Map<String, ClientConnectionVO> result = new LinkedHashMap<>();
+        Map<ConsumerGroup, List<String>> groups = 
collectSubscriptionGroups(adminExt, clusterId);
+        Map<String, List<String>> proxiesByBroker = new LinkedHashMap<>();
         int attemptedGroupQueries = 0;
         int successfulGroupQueries = 0;
-        for (Map.Entry<String, String> groupEntry : groups.entrySet()) {
-            String group = groupEntry.getKey();
-            if (isSystemGroup(group)) {
+        for (Map.Entry<ConsumerGroup, List<String>> entry : groups.entrySet()) 
{
+            ConsumerGroup group = entry.getKey();
+            if (isSystemGroup(group.name())) {
                 continue;
             }
             attemptedGroupQueries++;
-            try {
-                ConsumerConnection consumerConnection = 
adminExt.examineConsumerConnectionInfo(group);
-                successfulGroupQueries++;
-                if (consumerConnection == null || 
consumerConnection.getConnectionSet() == null) {
-                    continue;
+            boolean complete = true;
+            LinkedHashSet<String> proxies = new LinkedHashSet<>();
+            for (String broker : entry.getValue()) {
+                complete &= addConsumerConnections(adminExt, broker, group, 
false, result);
+                try {
+                    List<String> addresses = proxiesByBroker.get(broker);
+                    if (addresses == null) {
+                        addresses = 
proxyConsumerResolver.discoverProxyAddresses(adminExt, broker);
+                        proxiesByBroker.put(broker, addresses);
+                    }
+                    proxies.addAll(addresses);
+                } catch (MQClientException | MQBrokerException | 
RemotingException e) {
+                    // Cache failures for the rest of this request too, so an 
unreachable
+                    // broker is not re-probed once per remaining group.
+                    proxiesByBroker.put(broker, List.of());
+                    if (!MqResponseCodes.hasResponseCode(e, 
ResponseCode.CONSUMER_NOT_ONLINE,
+                            ResponseCode.TOPIC_NOT_EXIST)) {
+                        log.warn("Failed to discover proxies for broker={}", 
broker, e);
+                        complete = false;
+                    }
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                    throw new BusinessException(502, "Consumer connection 
query interrupted");
                 }
-                for (Connection connection : 
consumerConnection.getConnectionSet()) {
+            }
+            for (String proxy : proxies) {
+                complete &= addConsumerConnections(adminExt, proxy, group, 
true, result);
+            }
+            if (complete) {
+                successfulGroupQueries++;
+            }
+        }
+        // An empty response cannot carry the existing per-row partial flag.
+        if (successfulGroupQueries < attemptedGroupQueries && 
result.isEmpty()) {
+            throw new BusinessException(502, "Failed to query consumer 
connections from all groups");
+        }
+        if (successfulGroupQueries < attemptedGroupQueries) {
+            result.values().forEach(connection -> connection.setPartial(true));
+        }
+        return new ArrayList<>(result.values());
+    }
+
+    private boolean addConsumerConnections(MQAdminExt admin, String address, 
ConsumerGroup group,
+                                            boolean proxy, Map<String, 
ClientConnectionVO> result) {
+        try {
+            // Explicit addresses keep both Broker and Proxy queries inside 
the selected cluster.
+            ConsumerConnection connections = 
admin.examineConsumerConnectionInfo(group.name(), address);
+            if (connections != null && connections.getConnectionSet() != null) 
{
+                for (Connection connection : connections.getConnectionSet()) {
                     if (connection == null) {
                         continue;
                     }
-                    result.add(toConnectionVO(connection, ClientType.Consumer, 
group, null,
-                            groupEntry.getValue()));
+                    ClientConnectionVO row = toConnectionVO(connection, 
ClientType.Consumer,
+                            group.name(), null, group.cluster(), proxy);
+                    String key = group.cluster() + '\0' + group.name() + '\0'
+                            + connection.getClientId() + '\0' + 
connection.getClientAddr();
+                    result.putIfAbsent(key, row);
                 }
-            } catch (Exception e) {
-                if (e instanceof MQClientException clientException && 
isGroupConnectionAbsent(clientException)) {
-                    // An offline group is a normal "no connections right now" 
answer, not a
-                    // broken scan — count it so an all-offline cluster stays 
an empty result.
-                    successfulGroupQueries++;
-                    continue;
-                }
-                log.warn("Failed to examine consumer connection for group={}, 
skipping", group, e);
             }
+            return true;
+        } catch (MQClientException | MQBrokerException | RemotingException e) {
+            if (isGroupConnectionAbsent(e)) {
+                return true;
+            }
+            log.warn("Failed to examine consumer connection for group={} via 
{}", group.name(), address, e);
+            return false;
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new BusinessException(502, "Consumer connection query 
interrupted");
         }
-        // Only fail when at least one non-system group was actually attempted 
and all of them
-        // failed; a cluster whose subscription table holds only system groups 
is not an error.
-        if (attemptedGroupQueries > 0 && successfulGroupQueries == 0) {
-            throw new BusinessException(502, "Failed to query consumer 
connections from all groups");
-        }
-        return result;
     }
 
-    private Map<String, String> collectSubscriptionGroups(MQAdminExt adminExt, 
String clusterId) {
-        Map<String, String> groups = new LinkedHashMap<>();
+    private record ConsumerGroup(String name, String cluster) {
+    }
+
+    private Map<ConsumerGroup, List<String>> 
collectSubscriptionGroups(MQAdminExt adminExt, String clusterId) {
+        Map<ConsumerGroup, List<String>> groups = new LinkedHashMap<>();
         BrokerTopology topology = discoverBrokerTopology(adminExt, clusterId, 
"consumer connections");
         int attemptedBrokerQueries = 0;
         int successfulBrokerQueries = 0;
@@ -391,7 +442,8 @@ public class RocketMQClientProvider implements 
ClientProvider {
                 successfulBrokerQueries++;
                 if (wrapper != null && wrapper.getSubscriptionGroupTable() != 
null) {
                     wrapper.getSubscriptionGroupTable().keySet().forEach(group 
->
-                            groups.putIfAbsent(group, 
topology.clusterFor(brokerAddr)));
+                            groups.computeIfAbsent(new ConsumerGroup(group, 
topology.clusterFor(brokerAddr)),
+                                    key -> new ArrayList<>()).add(brokerAddr));
                 }
             } catch (Exception e) {
                 log.warn("Failed to fetch subscription groups from broker={}, 
skipping", brokerAddr, e);
@@ -404,16 +456,17 @@ public class RocketMQClientProvider implements 
ClientProvider {
     }
 
     private ClientConnectionVO toConnectionVO(Connection connection, 
ClientType type, String groupOrTopic,
-                                              String producerGroup, String 
clusterId) {
+                                              String producerGroup, String 
clusterId, boolean proxy) {
         return ClientConnectionVO.builder()
                 .clientId(connection.getClientId())
                 .type(type)
                 .groupOrTopic(groupOrTopic)
                 .producerGroup(producerGroup)
-                .protocol(Protocol.Remoting)
+                // The Proxy response has no protocol; gRPC may carry a fixed 
compatibility version.
+                .protocol(proxy ? null : Protocol.Remoting)
                 .address(connection.getClientAddr())
                 .language(mapLanguage(connection.getLanguage()))
-                .version(MQVersion.getVersionDesc(connection.getVersion()))
+                .version(proxy ? null : 
MQVersion.getVersionDesc(connection.getVersion()))
                 .clusterName(clusterId)
                 .build();
     }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQClientProviderTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQClientProviderTest.java
index a6ac4bff6..5464bbce6 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQClientProviderTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQClientProviderTest.java
@@ -17,6 +17,8 @@
 package org.apache.rocketmq.studio.provider.apache;
 
 import org.apache.rocketmq.client.exception.MQClientException;
+import org.apache.rocketmq.client.exception.MQBrokerException;
+import org.apache.rocketmq.remoting.exception.RemotingConnectException;
 import org.apache.rocketmq.remoting.protocol.LanguageCode;
 import org.apache.rocketmq.remoting.protocol.ResponseCode;
 import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
@@ -29,7 +31,14 @@ import 
org.apache.rocketmq.remoting.protocol.body.SubscriptionGroupWrapper;
 import org.apache.rocketmq.remoting.protocol.route.BrokerData;
 import 
org.apache.rocketmq.remoting.protocol.subscription.SubscriptionGroupConfig;
 import org.apache.rocketmq.studio.cluster.client.ClientConnectionVO;
+import org.apache.rocketmq.studio.cluster.client.ClientController;
+import org.apache.rocketmq.studio.cluster.client.ClientService;
 import org.apache.rocketmq.studio.cluster.client.ProducerConnectionScanResult;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import static 
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 import org.apache.rocketmq.studio.cluster.broker.MqAdminExtFactory;
 import org.apache.rocketmq.studio.cluster.broker.RuntimeAdminClientResolver;
 import org.apache.rocketmq.studio.common.domain.enums.ClientLanguage;
@@ -75,8 +84,11 @@ class RocketMQClientProviderTest {
     private RocketMQClientProvider provider;
 
     @BeforeEach
-    void setUp() {
-        provider = new RocketMQClientProvider(runtimeAdminClientResolver, 
adminFactory);
+    void setUp() throws Exception {
+        lenient().when(adminExt.examineConsumerConnectionInfo(anyString(), 
anyString()))
+                .thenReturn(new ConsumerConnection());
+        provider = new RocketMQClientProvider(runtimeAdminClientResolver, 
adminFactory,
+                new ProxyConsumerResolver(adminFactory, 
runtimeAdminClientResolver, new RocketMQProperties()));
         lenient().when(runtimeAdminClientResolver.execute(anyString(), 
any())).thenAnswer(invocation ->
                 invocation.<MqAdminExtFactory.AdminAction<Object>>
                         getArgument(1).apply(adminExt));
@@ -86,6 +98,261 @@ class RocketMQClientProviderTest {
                                 getArgument(2).apply(adminExt));
     }
 
+    @Test
+    void clientsEndpointShowsProxyConsumersFromSelectedNameserverTest() throws 
Exception {
+        
when(adminExt.examineBrokerClusterInfo()).thenReturn(clusterInfo("127.0.0.1:10911"));
+        when(adminExt.getAllSubscriptionGroup("127.0.0.1:10911", 5000L))
+                .thenReturn(subscriptionGroups("group-a"));
+        when(adminExt.examineConsumerConnectionInfo("group-a", 
"127.0.0.1:10911"))
+                .thenThrow(new 
MQClientException(ResponseCode.CONSUMER_NOT_ONLINE, "offline"));
+        when(adminExt.examineConsumerConnectionInfo(
+                "CID_DefaultHeartBeatSyncerTopic", "127.0.0.1:10911"))
+                .thenReturn(consumerConnections(connection("syncer", 
"10.0.0.8:40000")));
+        Connection proxyClient = connection("proxy-client", "10.0.0.9:50000");
+        
proxyClient.setVersion(org.apache.rocketmq.common.MQVersion.Version.V5_0_0.ordinal());
+        when(adminExt.examineConsumerConnectionInfo("group-a", 
"10.0.0.8:8080"))
+                .thenReturn(consumerConnections(proxyClient));
+
+        
org.apache.rocketmq.studio.cluster.nameserver.NameserverRegistryService 
registry =
+                
org.mockito.Mockito.mock(org.apache.rocketmq.studio.cluster.nameserver.NameserverRegistryService.class);
+        
when(registry.requireRegisteredAddress("selected:9876")).thenReturn("selected:9876");
+        MockMvcBuilders.standaloneSetup(new ClientController(new 
ClientService(provider, registry))).build()
+                .perform(get("/api/clients").param("namesrvAddr", 
"selected:9876")
+                        .param("clusterId", "cluster-a").param("type", 
"Consumer"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.length()").value(1))
+                
.andExpect(jsonPath("$.data[0].clientId").value("proxy-client"))
+                
.andExpect(jsonPath("$.data[0].clusterName").value("cluster-a"))
+                .andExpect(jsonPath("$.data[0].language").value("Java"))
+                .andExpect(jsonPath("$.data[0].protocol").doesNotExist())
+                .andExpect(jsonPath("$.data[0].version").doesNotExist());
+        verify(adminFactory).execute(eq("selected:9876"), any(), 
any(MqAdminExtFactory.AdminAction.class));
+        verify(runtimeAdminClientResolver, never()).execute(anyString(), 
any());
+    }
+
+    @Test
+    void consumerScanMergesDirectAndAllProxyConnectionsTest() throws Exception 
{
+        prepareProxyGroup(adminExt, "127.0.0.1:10911", "10.0.0.8", "10.0.0.9");
+        Connection direct = connection("direct", "10.0.0.1:40000");
+        when(adminExt.examineConsumerConnectionInfo("group-a", 
"127.0.0.1:10911"))
+                .thenReturn(consumerConnections(direct));
+        Connection unknown = connection("proxy-two", "10.0.0.3:40000");
+        unknown.setLanguage(null);
+        when(adminExt.examineConsumerConnectionInfo("group-a", 
"10.0.0.8:8080"))
+                .thenReturn(consumerConnections(direct, 
connection("proxy-one", "10.0.0.2:40000")));
+        when(adminExt.examineConsumerConnectionInfo("group-a", 
"10.0.0.9:8080"))
+                .thenReturn(consumerConnections(unknown));
+
+        List<ClientConnectionVO> rows = provider.findConnections("instance-a", 
"cluster-a", "Consumer");
+
+        assertThat(rows).extracting(ClientConnectionVO::getClientId)
+                .containsExactlyInAnyOrder("direct", "proxy-one", "proxy-two");
+        assertThat(rows).filteredOn(row -> 
row.getClientId().equals("direct")).singleElement()
+                .satisfies(row -> {
+                    
assertThat(row.getProtocol()).isEqualTo(org.apache.rocketmq.studio.common.domain.enums.Protocol.Remoting);
+                    
assertThat(row.getVersion()).isEqualTo(org.apache.rocketmq.common.MQVersion.getVersionDesc(direct.getVersion()));
+                });
+        assertThat(rows).filteredOn(row -> 
row.getClientId().equals("proxy-two")).singleElement()
+                .satisfies(row -> {
+                    assertThat(row.getLanguage()).isNull();
+                    assertThat(row.getProtocol()).isNull();
+                    assertThat(row.getVersion()).isNull();
+                    assertThat(row.isPartial()).isFalse();
+                });
+    }
+
+    @Test
+    void proxyDiscoveryCannotLeakAcrossNameserversSharingBrokerAddressesTest() 
throws Exception {
+        DefaultMQAdminExt other = 
org.mockito.Mockito.mock(DefaultMQAdminExt.class);
+        prepareProxyGroup(adminExt, "127.0.0.1:10911", "10.0.0.8");
+        prepareProxyGroup(other, "127.0.0.1:10911", "10.1.0.8");
+        when(adminFactory.execute(eq("other:9876"), any(), any()))
+                .thenAnswer(invocation -> 
invocation.<MqAdminExtFactory.AdminAction<Object>>getArgument(2).apply(other));
+        when(adminExt.examineConsumerConnectionInfo("group-a", 
"10.0.0.8:8080"))
+                .thenReturn(consumerConnections(connection("selected-client", 
"10.0.0.1:40000")));
+        when(other.examineConsumerConnectionInfo("group-a", "10.1.0.8:8080"))
+                .thenReturn(consumerConnections(connection("other-client", 
"10.1.0.1:40000")));
+
+        assertThat(provider.findConnectionsAt("selected:9876", null, 
"Consumer"))
+                
.extracting(ClientConnectionVO::getClientId).containsExactly("selected-client");
+        assertThat(provider.findConnectionsAt("other:9876", null, "Consumer"))
+                
.extracting(ClientConnectionVO::getClientId).containsExactly("other-client");
+        assertThat(provider.findConnectionsAt("selected:9876", null, 
"Consumer"))
+                
.extracting(ClientConnectionVO::getClientId).containsExactly("selected-client");
+    }
+
+    @Test
+    void proxyScanPreservesSameGroupInDifferentClustersAndFiltersTest() throws 
Exception {
+        prepareProxyGroup(adminExt, "127.0.0.1:10911", "10.0.0.8");
+        
when(adminExt.examineBrokerClusterInfo()).thenReturn(clusterInfo(Map.of(
+                "127.0.0.1:10911", "cluster-a", "127.0.0.2:10911", 
"cluster-b")));
+        when(adminExt.getAllSubscriptionGroup("127.0.0.2:10911", 5000L))
+                .thenReturn(subscriptionGroups("group-a"));
+        
when(adminExt.examineConsumerConnectionInfo("CID_DefaultHeartBeatSyncerTopic", 
"127.0.0.2:10911"))
+                .thenReturn(consumerConnections(connection("syncer-b", 
"10.1.0.8:40000")));
+        when(adminExt.examineConsumerConnectionInfo("group-a", 
"10.0.0.8:8080"))
+                .thenReturn(consumerConnections(connection("client-a", 
"10.0.0.1:40000")));
+        when(adminExt.examineConsumerConnectionInfo("group-a", 
"10.1.0.8:8080"))
+                .thenReturn(consumerConnections(connection("client-b", 
"10.1.0.1:40000")));
+
+        assertThat(provider.findConnectionsAt("selected:9876", null, 
"Consumer"))
+                
.extracting(ClientConnectionVO::getClusterName).containsExactlyInAnyOrder("cluster-a",
 "cluster-b");
+        assertThat(provider.findConnectionsAt("selected:9876", "cluster-b", 
"Consumer")).singleElement()
+                .satisfies(row -> {
+                    assertThat(row.getClientId()).isEqualTo("client-b");
+                    assertThat(row.getClusterName()).isEqualTo("cluster-b");
+                });
+        verify(adminExt, times(1)).examineConsumerConnectionInfo("group-a", 
"10.0.0.8:8080");
+    }
+
+    @Test
+    void proxyFailureKeepsPartialRowsButCannotClaimOfflineTest() throws 
Exception {
+        prepareProxyGroup(adminExt, "127.0.0.1:10911", "10.0.0.8");
+        when(adminExt.examineConsumerConnectionInfo("group-a", 
"10.0.0.8:8080"))
+                .thenThrow(new RemotingConnectException("proxy down"));
+        when(adminExt.examineConsumerConnectionInfo("group-a", 
"127.0.0.1:10911"))
+                .thenReturn(consumerConnections(connection("direct", 
"10.0.0.1:40000")))
+                .thenThrow(new 
MQBrokerException(ResponseCode.CONSUMER_NOT_ONLINE, "offline"));
+
+        assertThat(provider.findConnectionsAt("selected:9876", null, 
"Consumer")).singleElement()
+                .satisfies(row -> {
+                    assertThat(row.getClientId()).isEqualTo("direct");
+                    assertThat(row.isPartial()).isTrue();
+                });
+        assertThatThrownBy(() -> provider.findConnectionsAt("selected:9876", 
null, "Consumer"))
+                .isInstanceOf(BusinessException.class).hasMessage("Failed to 
query consumer connections from all groups");
+        org.mockito.Mockito.doThrow(new 
MQBrokerException(ResponseCode.CONSUMER_NOT_ONLINE, "offline"))
+                .when(adminExt).examineConsumerConnectionInfo("group-a", 
"10.0.0.8:8080");
+        assertThat(provider.findConnectionsAt("selected:9876", null, 
"Consumer")).isEmpty();
+    }
+
+    @Test
+    void proxyDiscoveryRetriesFailuresAndIsSharedOnlyWithinRequestTest() 
throws Exception {
+        prepareProxyGroup(adminExt, "127.0.0.1:10911", "10.0.0.8");
+        
when(adminExt.examineConsumerConnectionInfo("CID_DefaultHeartBeatSyncerTopic", 
"127.0.0.1:10911"))
+                .thenThrow(new RemotingConnectException("discovery down"))
+                .thenReturn(consumerConnections(connection("syncer", 
"10.0.0.8:40000")));
+        assertThatThrownBy(() -> provider.findConnectionsAt("selected:9876", 
null, "Consumer"))
+                .isInstanceOf(BusinessException.class);
+        when(adminExt.getAllSubscriptionGroup("127.0.0.1:10911", 5000L))
+                .thenReturn(subscriptionGroups("group-a", "group-b"));
+        when(adminExt.examineConsumerConnectionInfo("group-a", 
"10.0.0.8:8080"))
+                .thenReturn(consumerConnections(connection("proxy-client", 
"10.0.0.1:40000")));
+        assertThat(provider.findConnectionsAt("selected:9876", null, 
"Consumer"))
+                
.extracting(ClientConnectionVO::getClientId).containsExactly("proxy-client");
+        verify(adminExt, times(2)).examineConsumerConnectionInfo(
+                "CID_DefaultHeartBeatSyncerTopic", "127.0.0.1:10911");
+    }
+
+    @Test
+    void directConsumerOnAnotherBrokerInTheClusterRemainsVisibleTest() throws 
Exception {
+        ClusterInfo topology = clusterInfo("127.0.0.1:10911", 
"127.0.0.2:10911");
+        String lastBroker = new 
java.util.ArrayList<>(topology.getBrokerAddrTable().values())
+                .getLast().selectBrokerAddr();
+        when(adminExt.examineBrokerClusterInfo()).thenReturn(topology);
+        when(adminExt.getAllSubscriptionGroup(anyString(), 
anyLong())).thenReturn(subscriptionGroups("group-a"));
+        when(adminExt.examineConsumerConnectionInfo("group-a", lastBroker))
+                .thenReturn(consumerConnections(connection("direct", 
"10.0.0.1:40000")));
+        assertThat(provider.findConnectionsAt("selected:9876", "cluster-a", 
"Consumer"))
+                
.extracting(ClientConnectionVO::getClientId).containsExactly("direct");
+    }
+
+    @Test
+    void emptyPartialProxyInventoryIsAnErrorTest() throws Exception {
+        prepareProxyGroup(adminExt, "127.0.0.1:10911", "10.0.0.8");
+        when(adminExt.getAllSubscriptionGroup("127.0.0.1:10911", 5000L))
+                .thenReturn(subscriptionGroups("group-a", "group-b"));
+        when(adminExt.examineConsumerConnectionInfo("group-a", 
"10.0.0.8:8080"))
+                .thenThrow(new RemotingConnectException("proxy down"));
+        assertThatThrownBy(() -> provider.findConnectionsAt("selected:9876", 
null, "Consumer"))
+                .isInstanceOf(BusinessException.class);
+    }
+
+    @Test
+    void proxyQueriesPreserveInterruptsAndDoNotSwallowProgrammingErrorsTest() 
throws Exception {
+        prepareProxyGroup(adminExt, "127.0.0.1:10911", "10.0.0.8");
+        when(adminExt.examineConsumerConnectionInfo("group-a", 
"10.0.0.8:8080"))
+                .thenThrow(new InterruptedException("cancelled"))
+                .thenThrow(new IllegalArgumentException("invalid state"));
+        try {
+            assertThatThrownBy(() -> 
provider.findConnectionsAt("selected:9876", null, "Consumer"))
+                    
.isInstanceOf(BusinessException.class).hasMessage("Consumer connection query 
interrupted");
+            assertThat(Thread.currentThread().isInterrupted()).isTrue();
+        } finally {
+            Thread.interrupted();
+        }
+        assertThatThrownBy(() -> provider.findConnectionsAt("selected:9876", 
null, "Consumer"))
+                
.isInstanceOf(IllegalArgumentException.class).hasMessage("invalid state");
+    }
+
+    @Test
+    void consumerOfflineMessageWithoutNotOnlineCodeIsNotAScanFailureTest() 
throws Exception {
+        
when(adminExt.examineBrokerClusterInfo()).thenReturn(clusterInfo("127.0.0.1:10911"));
+        when(adminExt.getAllSubscriptionGroup("127.0.0.1:10911", 5000L))
+                .thenReturn(subscriptionGroups("group-a"));
+        when(adminExt.examineConsumerConnectionInfo("group-a", 
"127.0.0.1:10911"))
+                .thenThrow(new MQBrokerException(ResponseCode.SYSTEM_ERROR,
+                        "Not found the consumer group connection"));
+
+        assertThat(provider.findConnectionsAt("selected:9876", null, 
"Consumer")).isEmpty();
+    }
+
+    @Test
+    void heartbeatSyncerTopicNotExistIsTreatedAsNoObservableProxyTest() throws 
Exception {
+        
when(adminExt.examineBrokerClusterInfo()).thenReturn(clusterInfo("127.0.0.1:10911"));
+        when(adminExt.getAllSubscriptionGroup("127.0.0.1:10911", 5000L))
+                .thenReturn(subscriptionGroups("group-a"));
+        when(adminExt.examineConsumerConnectionInfo("group-a", 
"127.0.0.1:10911"))
+                .thenReturn(consumerConnections(connection("direct", 
"10.0.0.1:40000")));
+        
when(adminExt.examineConsumerConnectionInfo("CID_DefaultHeartBeatSyncerTopic", 
"127.0.0.1:10911"))
+                .thenThrow(new MQBrokerException(ResponseCode.TOPIC_NOT_EXIST,
+                        "No route info of this topic: 
CID_DefaultHeartBeatSyncerTopic"));
+
+        assertThat(provider.findConnectionsAt("selected:9876", null, 
"Consumer")).singleElement()
+                .satisfies(row -> {
+                    assertThat(row.getClientId()).isEqualTo("direct");
+                    assertThat(row.isPartial()).isFalse();
+                });
+    }
+
+    @Test
+    void proxyDiscoveryFailureIsCachedForTheRestOfTheRequestTest() throws 
Exception {
+        
when(adminExt.examineBrokerClusterInfo()).thenReturn(clusterInfo("127.0.0.1:10911"));
+        when(adminExt.getAllSubscriptionGroup("127.0.0.1:10911", 5000L))
+                .thenReturn(subscriptionGroups("group-a", "group-b"));
+        
when(adminExt.examineConsumerConnectionInfo("CID_DefaultHeartBeatSyncerTopic", 
"127.0.0.1:10911"))
+                .thenThrow(new RemotingConnectException("discovery down"));
+        when(adminExt.examineConsumerConnectionInfo("group-a", 
"127.0.0.1:10911"))
+                .thenReturn(consumerConnections(connection("direct", 
"10.0.0.1:40000")));
+        when(adminExt.examineConsumerConnectionInfo("group-b", 
"127.0.0.1:10911"))
+                .thenReturn(new ConsumerConnection());
+
+        assertThat(provider.findConnectionsAt("selected:9876", null, 
"Consumer")).singleElement()
+                .satisfies(row -> {
+                    assertThat(row.getClientId()).isEqualTo("direct");
+                    assertThat(row.isPartial()).isTrue();
+                });
+        verify(adminExt, times(1)).examineConsumerConnectionInfo(
+                "CID_DefaultHeartBeatSyncerTopic", "127.0.0.1:10911");
+    }
+
+    private void prepareProxyGroup(DefaultMQAdminExt admin, String broker, 
String... proxies) throws Exception {
+        lenient().when(admin.examineConsumerConnectionInfo(anyString(), 
anyString()))
+                .thenReturn(new ConsumerConnection());
+        when(admin.examineBrokerClusterInfo()).thenReturn(clusterInfo(broker));
+        when(admin.getAllSubscriptionGroup(broker, 
5000L)).thenReturn(subscriptionGroups("group-a"));
+        Connection[] syncers = java.util.Arrays.stream(proxies)
+                .map(proxy -> connection("syncer-" + proxy, proxy + 
":40000")).toArray(Connection[]::new);
+        
when(admin.examineConsumerConnectionInfo("CID_DefaultHeartBeatSyncerTopic", 
broker))
+                .thenReturn(consumerConnections(syncers));
+    }
+
+    private static ConsumerConnection consumerConnections(Connection... 
connections) {
+        ConsumerConnection result = new ConsumerConnection();
+        result.setConnectionSet(new HashSet<>(List.of(connections)));
+        return result;
+    }
+
     @Test
     void findConnectionsAtShouldUseNameserverScopedAdminClientTest() throws 
Exception {
         ClusterInfo clusterInfo = new ClusterInfo();
@@ -496,7 +763,7 @@ class RocketMQClientProviderTest {
                 .thenReturn(subscriptionGroups("group-b"));
         ConsumerConnection consumerConnection = new ConsumerConnection();
         consumerConnection.setConnectionSet(new 
HashSet<>(List.of(connection("consumer-b", "10.0.0.2:1000"))));
-        
when(adminExt.examineConsumerConnectionInfo("group-b")).thenReturn(consumerConnection);
+        when(adminExt.examineConsumerConnectionInfo("group-b", 
"127.0.0.2:10911")).thenReturn(consumerConnection);
 
         List<ClientConnectionVO> connections = 
provider.findConnections("instance-a", "cluster-b", "Consumer");
 
@@ -534,7 +801,7 @@ class RocketMQClientProviderTest {
         consumerConnection.setConnectionSet(connectionSet);
         when(adminExt.examineBrokerClusterInfo()).thenReturn(clusterInfo);
         when(adminExt.getAllSubscriptionGroup("127.0.0.1:10911", 
5000L)).thenReturn(wrapper);
-        
when(adminExt.examineConsumerConnectionInfo("group-a")).thenReturn(consumerConnection);
+        when(adminExt.examineConsumerConnectionInfo("group-a", 
"127.0.0.1:10911")).thenReturn(consumerConnection);
 
         List<ClientConnectionVO> connections = 
provider.findConnections("instance-a", "cluster-a", "Consumer");
 
@@ -560,8 +827,8 @@ class RocketMQClientProviderTest {
         SubscriptionGroupWrapper wrapper = subscriptionGroups("group-a", 
"group-b");
         
when(adminExt.examineBrokerClusterInfo()).thenReturn(clusterInfo("127.0.0.1:10911"));
         when(adminExt.getAllSubscriptionGroup("127.0.0.1:10911", 
5000L)).thenReturn(wrapper);
-        when(adminExt.examineConsumerConnectionInfo(anyString()))
-                .thenThrow(new IllegalStateException("broker unavailable"));
+        when(adminExt.examineConsumerConnectionInfo(anyString(), anyString()))
+                .thenThrow(new RemotingConnectException("broker unavailable"));
 
         assertThatThrownBy(() -> provider.findConnections("instance-a", 
"cluster-a", "Consumer"))
                 .isInstanceOf(BusinessException.class)
@@ -578,7 +845,7 @@ class RocketMQClientProviderTest {
         List<ClientConnectionVO> connections = 
provider.findConnections("instance-a", "cluster-a", "Consumer");
 
         assertThat(connections).isEmpty();
-        verify(adminExt, never()).examineConsumerConnectionInfo(anyString());
+        verify(adminExt, never()).examineConsumerConnectionInfo(anyString(), 
anyString());
     }
 
     @Test
@@ -588,9 +855,9 @@ class RocketMQClientProviderTest {
         consumerConnection.setConnectionSet(new 
HashSet<>(List.of(connection("consumer-client", "10.0.0.2:1000"))));
         
when(adminExt.examineBrokerClusterInfo()).thenReturn(clusterInfo("127.0.0.1:10911"));
         when(adminExt.getAllSubscriptionGroup("127.0.0.1:10911", 
5000L)).thenReturn(wrapper);
-        when(adminExt.examineConsumerConnectionInfo("group-a"))
-                .thenThrow(new IllegalStateException("broker unavailable"));
-        
when(adminExt.examineConsumerConnectionInfo("group-b")).thenReturn(consumerConnection);
+        when(adminExt.examineConsumerConnectionInfo("group-a", 
"127.0.0.1:10911"))
+                .thenThrow(new RemotingConnectException("broker unavailable"));
+        when(adminExt.examineConsumerConnectionInfo("group-b", 
"127.0.0.1:10911")).thenReturn(consumerConnection);
 
         List<ClientConnectionVO> connections = 
provider.findConnections("instance-a", "cluster-a", "Consumer");
 
@@ -605,7 +872,7 @@ class RocketMQClientProviderTest {
         SubscriptionGroupWrapper wrapper = subscriptionGroups("group-a", 
"group-b");
         
when(adminExt.examineBrokerClusterInfo()).thenReturn(clusterInfo("127.0.0.1:10911"));
         when(adminExt.getAllSubscriptionGroup("127.0.0.1:10911", 
5000L)).thenReturn(wrapper);
-        
when(adminExt.examineConsumerConnectionInfo(anyString())).thenThrow(new 
MQClientException(
+        when(adminExt.examineConsumerConnectionInfo(anyString(), 
anyString())).thenThrow(new MQClientException(
                 206, "Not found the consumer group connection"));
 
         List<ClientConnectionVO> connections = 
provider.findConnections("instance-a", "cluster-a", null);
@@ -620,9 +887,9 @@ class RocketMQClientProviderTest {
         consumerConnection.setConnectionSet(new 
HashSet<>(List.of(connection("consumer-client", "10.0.0.2:1000"))));
         
when(adminExt.examineBrokerClusterInfo()).thenReturn(clusterInfo("127.0.0.1:10911"));
         when(adminExt.getAllSubscriptionGroup("127.0.0.1:10911", 
5000L)).thenReturn(wrapper);
-        when(adminExt.examineConsumerConnectionInfo("group-a")).thenThrow(new 
MQClientException(
+        when(adminExt.examineConsumerConnectionInfo("group-a", 
"127.0.0.1:10911")).thenThrow(new MQClientException(
                 206, "Not found the consumer group connection"));
-        
when(adminExt.examineConsumerConnectionInfo("group-b")).thenReturn(consumerConnection);
+        when(adminExt.examineConsumerConnectionInfo("group-b", 
"127.0.0.1:10911")).thenReturn(consumerConnection);
 
         List<ClientConnectionVO> connections = 
provider.findConnections("instance-a", "cluster-a", "Consumer");
 
diff --git a/web/src/api/connections.ts b/web/src/api/connections.ts
index 38e163604..68e71d86b 100644
--- a/web/src/api/connections.ts
+++ b/web/src/api/connections.ts
@@ -5,10 +5,10 @@ export interface ClientConnection {
   clientId?: string | null;
   type: string;
   groupOrTopic: string;
-  protocol: string;
+  protocol: string | null;
   address?: string | null;
   language?: string | null;
-  version: string;
+  version: string | null;
   connectedAt?: string | null;
   partial?: boolean;
   clusterName: string;
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index ad9145450..558aaec0d 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -210,6 +210,10 @@ const translations: Record<string, Record<Lang, string>> = 
{
   },
   'cluster.k8sNamespace': { zh: 'K8s Namespace', en: 'K8s Namespace' },
   'cluster.k8sId': { zh: 'k8s ID', en: 'k8s ID' },
+  'clients.partialScan': {
+    zh: '客户端连接列表不完整:部分查询失败。',
+    en: 'Client connections are incomplete: some queries failed.',
+  },
   'clients.selectClusterPlaceholder': { zh: '选择 Broker 集群', en: 'Select Broker 
cluster' },
   'clients.selectNameserverPlaceholder': { zh: '选择 NameServer', en: 'Select 
NameServer' },
   'cluster.k8sIdExtra': {
diff --git a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx 
b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
index ecae50abd..86fd3adf7 100644
--- a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
@@ -350,6 +350,95 @@ describe('Clients page', () => {
     expect(screen.queryByText('[email protected]:49154')).toBeNull();
   });
 
+  it('renders missing Proxy metadata in the table, distributions and details', 
async () => {
+    const user = userEvent.setup();
+    vi.mocked(connectionsService.listConnections).mockResolvedValue([
+      {
+        ...connection,
+        type: 'Consumer',
+        protocol: null,
+        language: null,
+        version: null,
+        partial: true,
+      },
+      { ...connections[2], clusterName: 'ns-prod' },
+    ]);
+    renderWithProviders(<ClientsPage />);
+
+    const rows = await screen.findAllByRole('row', { name: 
/order-svc-0@10\.0\.1\.12:49152/ });
+    const row = rows.find((candidate) =>
+      within(candidate).queryByRole('button', { name: /详情/ }),
+    )!;
+    // Protocol and version keep the neutral '-' placeholder; a missing 
language renders
+    // through the page's renderLanguageTag helper as the localized "unknown" 
label (#4850).
+    expect(within(row).getAllByText('-')).toHaveLength(2);
+    expect(within(row).getByText('未知')).toBeInTheDocument();
+    expect(within(row).queryByText('gRPC')).not.toBeInTheDocument();
+    expect(within(row).queryByText('Remoting')).not.toBeInTheDocument();
+    expect(
+      within(screen.getByTestId('protocol-distribution')).getByText('-: 1'),
+    ).toBeInTheDocument();
+    expect(
+      
within(screen.getByTestId('language-version-distribution')).getByText('未知 
unknown: 1'),
+    ).toBeInTheDocument();
+    expect(screen.getByText('客户端连接列表不完整:部分查询失败。')).toBeInTheDocument();
+    await user.click(within(row).getByRole('button', { name: /详情/ }));
+    const dialog = await screen.findByRole('dialog');
+    expect(within(dialog).getAllByText('-')).toHaveLength(2);
+    expect(within(dialog).getByText('未知')).toBeInTheDocument();
+    expect(within(dialog).queryByText('null')).not.toBeInTheDocument();
+  });
+
+  it('filters protocol-less proxy rows through the unknown protocol option', 
async () => {
+    const createObjectURL = vi.fn((blob: Blob | MediaSource) => {
+      expect(blob).toBeInstanceOf(Blob);
+      return 'blob:unknown-protocol-connections';
+    });
+    Object.defineProperty(URL, 'createObjectURL', {
+      writable: true,
+      value: createObjectURL,
+    });
+    Object.defineProperty(URL, 'revokeObjectURL', {
+      writable: true,
+      value: vi.fn(),
+    });
+    vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => 
{});
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    vi.mocked(connectionsService.listConnections).mockResolvedValue([
+      { ...connection, type: 'Consumer', protocol: null },
+      connections[1],
+    ]);
+    renderWithProviders(<ClientsPage />);
+
+    await screen.findAllByText('[email protected]:49153');
+    expect(
+      within(screen.getByTestId('protocol-distribution')).getByText('-: 1'),
+    ).toBeInTheDocument();
+    const filterTriggers = 
document.querySelectorAll<HTMLElement>('.ant-table-filter-trigger');
+    await user.click(filterTriggers[2]);
+    const filterDropdown = 
document.querySelector<HTMLElement>('.ant-table-filter-dropdown');
+    expect(filterDropdown).not.toBeNull();
+    await user.click(within(filterDropdown!).getByText('未知'));
+    await user.click(within(filterDropdown!).getByRole('button', { name: 'OK' 
}));
+
+    const hasDetailButton = (candidate: HTMLElement) =>
+      within(candidate).queryByRole('button', { name: /详情/ }) !== null;
+    const orderRows = await screen.findAllByRole('row', {
+      name: /order-svc-0@10\.0\.1\.12:49152/,
+    });
+    expect(orderRows.some(hasDetailButton)).toBe(true);
+    const paymentRows = screen.queryAllByRole('row', {
+      name: /payment-svc-0@10\.0\.1\.13:49153/,
+    });
+    expect(paymentRows.some(hasDetailButton)).toBe(false);
+
+    await user.click(screen.getByRole('button', { name: /导出/ }));
+    const blob = createObjectURL.mock.calls[0][0] as Blob;
+    const csv = await blob.text();
+    expect(csv).toContain('[email protected]:49152');
+    expect(csv).not.toContain('[email protected]:49153');
+  });
+
   it('keeps incomplete client metadata searchable by address', async () => {
     const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(connectionsService.listConnections).mockResolvedValue([
diff --git a/web/src/pages/cluster/clients.tsx 
b/web/src/pages/cluster/clients.tsx
index c6e51538d..8c2e67b1f 100644
--- a/web/src/pages/cluster/clients.tsx
+++ b/web/src/pages/cluster/clients.tsx
@@ -133,12 +133,14 @@ const CLIENT_CONNECTION_EXPORT_COLUMNS: 
CsvColumn<ClientConnection>[] = [
 
 type ClientTableFilters = 
Parameters<NonNullable<TableProps<ClientConnection>['onChange']>>[1];
 
-const countBy = (values: string[]) =>
+const countBy = (values: Array<string | null>) =>
   [
-    ...values.reduce(
-      (counts, value) => counts.set(value, (counts.get(value) ?? 0) + 1),
-      new Map<string, number>(),
-    ),
+    ...values
+      .map(displayMetadata)
+      .reduce(
+        (counts, value) => counts.set(value, (counts.get(value) ?? 0) + 1),
+        new Map<string, number>(),
+      ),
   ]
     .map(([label, count]) => ({ label, count }))
     .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
@@ -151,6 +153,9 @@ const displayMetadata = (value: string | null | undefined) 
=> value || '-';
  */
 const UNKNOWN_LANGUAGE = 'unknown';
 
+/** Filter sentinel for rows whose protocol the Proxy response could not 
confirm. */
+const UNKNOWN_PROTOCOL = 'unknown';
+
 /* ═══════════════════════════════════════════
    ClientsPage
    ═══════════════════════════════════════════ */
@@ -301,7 +306,8 @@ const ClientsPage = () => {
       protocols: countBy(instances.map((connection) => connection.protocol)),
       languageVersions: countBy(
         instances.map(
-          (connection) => `${connection.language ?? UNKNOWN_LANGUAGE} 
${connection.version}`,
+          (connection) =>
+            `${connection.language ?? UNKNOWN_LANGUAGE} ${connection.version 
?? UNKNOWN_LANGUAGE}`,
         ),
       ),
     };
@@ -356,7 +362,7 @@ const ClientsPage = () => {
   );
 
   const exportConnections = useMemo(() => {
-    const matches = (key: string, value: string) => {
+    const matches = (key: string, value: string | null) => {
       const selected = columnFilters[key];
       return !selected?.length || selected.some((filterValue) => 
String(filterValue) === value);
     };
@@ -364,7 +370,7 @@ const ClientsPage = () => {
       (connection) =>
         matches('clusterName', connection.clusterName) &&
         matches('type', connection.type) &&
-        matches('protocol', connection.protocol) &&
+        matches('protocol', connection.protocol ?? UNKNOWN_PROTOCOL) &&
         matches('language', connection.language ?? ''),
     );
   }, [columnFilters, filtered]);
@@ -459,11 +465,16 @@ const ClientsPage = () => {
       filters: [
         { text: 'gRPC', value: 'gRPC' },
         { text: 'Remoting', value: 'Remoting' },
+        { text: t('common.unknown'), value: UNKNOWN_PROTOCOL },
       ],
       filteredValue: columnFilters.protocol ?? null,
-      onFilter: (value, record) => record.protocol === value,
-      render: (protocol: string) => {
-        const cfg = protocolConfig[protocol] ?? { color: 'default', label: 
protocol };
+      onFilter: (value, record) =>
+        value === UNKNOWN_PROTOCOL ? !record.protocol : record.protocol === 
value,
+      render: (protocol: string | null) => {
+        const cfg = protocolConfig[protocol ?? ''] ?? {
+          color: 'default',
+          label: displayMetadata(protocol),
+        };
         return <Tag color={cfg.color}>{cfg.label}</Tag>;
       },
     },
@@ -494,6 +505,7 @@ const ClientsPage = () => {
       dataIndex: 'version',
       key: 'version',
       width: 90,
+      render: displayMetadata,
     },
     {
       title: t('cluster.heartbeat'),
@@ -721,7 +733,7 @@ const ClientsPage = () => {
         <Alert
           showIcon
           type="warning"
-          message="Producer connections are sampled because the topic scan 
limit was reached."
+          message={t('clients.partialScan')}
           style={{ marginBottom: 16 }}
         />
       )}
@@ -995,8 +1007,9 @@ const ClientsPage = () => {
               {selectedConnection.groupOrTopic}
             </Descriptions.Item>
             <Descriptions.Item label={t('clients.protocol')}>
-              <Tag color={protocolConfig[selectedConnection.protocol]?.color 
?? 'default'}>
-                {protocolConfig[selectedConnection.protocol]?.label ?? 
selectedConnection.protocol}
+              <Tag color={protocolConfig[selectedConnection.protocol ?? 
'']?.color ?? 'default'}>
+                {protocolConfig[selectedConnection.protocol ?? '']?.label ??
+                  displayMetadata(selectedConnection.protocol)}
               </Tag>
             </Descriptions.Item>
             <Descriptions.Item label={t('common.address')}>
@@ -1008,7 +1021,7 @@ const ClientsPage = () => {
               {renderLanguageTag(selectedConnection.language)}
             </Descriptions.Item>
             <Descriptions.Item label={t('common.version')}>
-              {selectedConnection.version}
+              {displayMetadata(selectedConnection.version)}
             </Descriptions.Item>
             <Descriptions.Item label={t('cluster.heartbeat')}>
               {selectedConnection.connectedAt ?? '-'}

Reply via email to