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 cf0b6784 fix: RIP-1 defect fixes — ACL validation, consumer lag 
resolver, proxy fallback policy and read-only proxy page (#1037)
cf0b6784 is described below

commit cf0b6784e9f87fe6083e00f5a7fd30dc4d207acd
Author: zhaohai <[email protected]>
AuthorDate: Tue Aug 11 14:27:39 2026 +0800

    fix: RIP-1 defect fixes — ACL validation, consumer lag resolver, proxy 
fallback policy and read-only proxy page (#1037)
---
 .../rocketmq/studio/instance/acl/AclService.java   |  45 ++++++
 .../studio/instance/acl/IpRangeMatcher.java        | 151 +++++++++++++++++++++
 .../provider/apache/ConsumerLagResolver.java       |  52 +++++++
 .../provider/apache/NoopProxyStatsProvider.java    |  33 +++++
 .../provider/apache/ProxyFallbackPolicy.java       |  70 ++++++++++
 .../studio/provider/apache/ProxyStatsProvider.java |  34 +++++
 .../provider/apache/RocketMQAdminClientImpl.java   |  28 +++-
 .../provider/apache/RocketMQMetadataProvider.java  |  19 ++-
 .../studio/cluster/proxy/ProxyControllerTest.java  |   3 +
 .../studio/instance/acl/AclServiceTest.java        |  87 ++++++++++++
 .../studio/instance/acl/IpRangeMatcherTest.java    | 119 ++++++++++++++++
 .../provider/apache/ConsumerLagResolverTest.java   |  59 ++++++++
 .../provider/apache/ProxyFallbackPolicyTest.java   |  92 +++++++++++++
 web/src/api/proxy.test.ts                          |  31 +----
 web/src/api/proxy.ts                               |  16 ---
 web/src/config.test.ts                             |  35 +++++
 web/src/i18n/translations.ts                       |  13 --
 web/src/pages/studio/Proxy.tsx                     |  91 +------------
 web/src/pages/studio/__tests__/Proxy.test.tsx      |   2 -
 19 files changed, 822 insertions(+), 158 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java 
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
index 8d0e3490..237259ac 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
@@ -21,6 +21,7 @@ import org.springframework.util.StringUtils;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.apache.rocketmq.studio.common.util.CredentialUtils;
 import org.apache.rocketmq.studio.audit.OperationAuditService;
+import org.apache.rocketmq.studio.model.Acl2PolicyContext;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
@@ -171,6 +172,50 @@ public class AclService {
                 .orElseThrow(() -> new BusinessException(404, "ACL user not 
found: " + id));
     }
 
+    /**
+     * Validates an ACL 2.0 RBAC policy context so the model can be used by 
callers before it is
+     * persisted. This makes the {@link 
org.apache.rocketmq.studio.model.Acl2PolicyContext} model
+     * operational without duplicating the cluster-config endpoints owned by 
the separate ACL 2.0
+     * functional change.
+     *
+     * <p>Checks: non-blank policy name, a valid binding type, a non-null 
rules list, and that every
+     * IP whitelist entry is a well-formed range (validated through {@link 
IpRangeMatcher}).
+     *
+     * @param policy the ACL 2.0 policy to validate
+     * @throws BusinessException with HTTP 400 when a required field is 
missing or malformed
+     */
+    public void validateAcl2Policy(Acl2PolicyContext policy) {
+        if (policy == null) {
+            throw new BusinessException(400, "ACL 2.0 policy is required");
+        }
+        if (!StringUtils.hasText(policy.getPolicyName())) {
+            throw new BusinessException(400, "ACL 2.0 policyName is required");
+        }
+        if (!StringUtils.hasText(policy.getBoundType()) || 
!isValidAcl2BoundType(policy.getBoundType())) {
+            throw new BusinessException(400,
+                    "ACL 2.0 boundType must be one of TOPIC, GROUP, *, USER, 
SERVICE_ACCOUNT (got: "
+                            + policy.getBoundType() + ")");
+        }
+        if (policy.getRules() == null) {
+            throw new BusinessException(400, "ACL 2.0 policy rules are 
required");
+        }
+        if (policy.getWhiteSet() != null) {
+            for (String entry : policy.getWhiteSet()) {
+                if (!IpRangeMatcher.isValidRange(entry)) {
+                    throw new BusinessException(400,
+                            "ACL 2.0 whiteSet entry is not a valid IP/CIDR 
range: " + entry);
+                }
+            }
+        }
+    }
+
+    private boolean isValidAcl2BoundType(String boundType) {
+        return switch (boundType.trim().toUpperCase()) {
+            case "TOPIC", "GROUP", "*", "USER", "SERVICE_ACCOUNT" -> true;
+            default -> false;
+        };
+    }
+
     private AclUserVO maskCredentials(AclUserVO user) {
         return AclUserVO.builder()
                 .id(user.getId())
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/IpRangeMatcher.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/IpRangeMatcher.java
new file mode 100644
index 00000000..e9d9d74a
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/IpRangeMatcher.java
@@ -0,0 +1,151 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.rocketmq.studio.instance.acl;
+
+import java.util.regex.Pattern;
+
+/**
+ * Matches an IP address against a CIDR block or a single IP.
+ *
+ * <p>Supports the ACL 2.0 IP whitelist semantics where a bare {@code 0.0.0.0} 
is treated as a
+ * wildcard that matches any address (IPv4 or IPv6), instead of being compared 
as a literal
+ * string. This fixes the old behaviour where {@code 0.0.0.0} only matched 
itself.
+ */
+public final class IpRangeMatcher {
+
+    private static final String WILDCARD_V4 = "0.0.0.0";
+    private static final String WILDCARD_V4_CIDR = "0.0.0.0/0";
+    private static final String WILDCARD_V6_CIDR = "::/0";
+
+    private IpRangeMatcher() {
+    }
+
+    /**
+     * Matches a strict dotted-quad IPv4 literal (each octet 0-255). Avoids 
{@link InetAddress#getByName}
+     * on purpose: that method performs DNS resolution, which would make ACL 
whitelist validation
+     * network-dependent and non-deterministic.
+     */
+    private static final Pattern IPV4_LITERAL =
+            
Pattern.compile("^(25[0-5]|2[0-4]\\d|1?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|1?\\d?\\d)){3}$");
+
+    private static boolean isIpv4Literal(String value) {
+        return value != null && IPV4_LITERAL.matcher(value).matches();
+    }
+
+    /**
+     * Returns {@code true} when {@code ip} is within {@code cidrOrIp}.
+     *
+     * <ul>
+     *   <li>{@code 0.0.0.0}, {@code 0.0.0.0/0} or {@code ::/0} match any 
non-blank ip.</li>
+     *   <li>An entry without a {@code /} is matched by exact equality.</li>
+     *   <li>An entry of the form {@code x.x.x.x/n} is matched against the 
IPv4 subnet.</li>
+     *   <li>Any unparseable input (or IPv6 CIDR other than {@code ::/0}) 
returns {@code false}.</li>
+     * </ul>
+     *
+     * @param ip       the address being checked (IPv4 or IPv6)
+     * @param cidrOrIp the whitelist entry to match against
+     * @return whether the address is in range
+     */
+    public static boolean isInRange(String ip, String cidrOrIp) {
+        if (ip == null || ip.isBlank() || cidrOrIp == null || 
cidrOrIp.isBlank()) {
+            return false;
+        }
+        String entry = cidrOrIp.trim();
+
+        if (WILDCARD_V4.equals(entry) || WILDCARD_V4_CIDR.equals(entry) || 
WILDCARD_V6_CIDR.equals(entry)) {
+            return true;
+        }
+
+        int slash = entry.indexOf('/');
+        if (slash < 0) {
+            return ip.trim().equals(entry);
+        }
+
+        String baseIp = entry.substring(0, slash);
+        String prefixStr = entry.substring(slash + 1);
+        int prefix;
+        try {
+            prefix = Integer.parseInt(prefixStr);
+        } catch (NumberFormatException e) {
+            return false;
+        }
+        if (prefix < 0 || prefix > 32) {
+            return false;
+        }
+        if (!isIpv4Literal(ip.trim()) || !isIpv4Literal(baseIp)) {
+            return false;
+        }
+
+        byte[] targetBytes = ipToBytes(ip.trim());
+        byte[] baseBytes = ipToBytes(baseIp);
+        int bits = prefix;
+        for (int i = 0; i < targetBytes.length && bits > 0; i++) {
+            int mask = (bits >= 8) ? 0xFF : (0xFF << (8 - bits)) & 0xFF;
+            if ((targetBytes[i] & mask) != (baseBytes[i] & mask)) {
+                return false;
+            }
+            bits -= 8;
+        }
+        return true;
+    }
+
+    /**
+     * Converts a validated dotted-quad IPv4 literal to its 4-byte 
representation.
+     * Callers must ensure the input passes {@link #isIpv4Literal(String)} 
first.
+     */
+    private static byte[] ipToBytes(String ip) {
+        String[] parts = ip.split("\\.");
+        byte[] bytes = new byte[4];
+        for (int i = 0; i < 4; i++) {
+            bytes[i] = (byte) Integer.parseInt(parts[i]);
+        }
+        return bytes;
+    }
+
+    /**
+     * Returns {@code true} when {@code cidrOrIp} is a well-formed whitelist 
entry: a wildcard
+     * ({@code 0.0.0.0}, {@code 0.0.0.0/0}, {@code ::/0}), a bare IPv4 
address, or an IPv4 CIDR with a
+     * prefix between 0 and 32. Used to validate ACL 2.0 {@code whiteSet} 
entries before they are
+     * applied.
+     */
+    public static boolean isValidRange(String cidrOrIp) {
+        if (cidrOrIp == null || cidrOrIp.isBlank()) {
+            return false;
+        }
+        String entry = cidrOrIp.trim();
+        if (WILDCARD_V4.equals(entry) || WILDCARD_V4_CIDR.equals(entry) || 
WILDCARD_V6_CIDR.equals(entry)) {
+            return true;
+        }
+        int slash = entry.indexOf('/');
+        if (slash < 0) {
+            return isIpv4Literal(entry);
+        }
+        String baseIp = entry.substring(0, slash);
+        String prefixStr = entry.substring(slash + 1);
+        int prefix;
+        try {
+            prefix = Integer.parseInt(prefixStr);
+        } catch (NumberFormatException e) {
+            return false;
+        }
+        if (prefix < 0 || prefix > 32) {
+            return false;
+        }
+        return isIpv4Literal(baseIp);
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/ConsumerLagResolver.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/ConsumerLagResolver.java
new file mode 100644
index 00000000..256d2588
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/ConsumerLagResolver.java
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.rocketmq.studio.provider.apache;
+
+/**
+ * Resolves the consumer lag (diff between broker offset and consumer offset) 
without masking the
+ * {@code -1} "unknown" sentinel that RocketMQ 5.0 gRPC consumers report.
+ *
+ * <p>The legacy dashboard clamped every negative diff to {@code 0} with 
{@code Math.max(0, ...)},
+ * which hid the unknown state and produced a misleading {@code 
NOT_CONSUME_YET} / zero-lag view.
+ * This resolver keeps the raw diff when it is valid, and falls back to the 
proxy transport when the
+ * broker reports {@code -1}. When no proxy is available it returns {@link 
#UNKNOWN} so the UI can
+ * show the genuine unknown state instead of a fabricated zero.
+ */
+public final class ConsumerLagResolver {
+
+    /** Sentinel meaning "lag cannot be determined" (matches the broker's own 
-1 for gRPC). */
+    public static final long UNKNOWN = -1;
+
+    private ConsumerLagResolver() {
+    }
+
+    /**
+     * @param brokerDiff raw brokerOffset - consumerOffset (may be negative 
for 5.0 gRPC consumers)
+     * @param proxy      optional proxy stats source; may be {@code null}
+     * @return the resolved lag, or {@link #UNKNOWN} when it cannot be 
determined
+     */
+    public static long resolve(long brokerDiff, ProxyStatsProvider proxy) {
+        if (brokerDiff >= 0) {
+            return brokerDiff;
+        }
+        if (proxy != null) {
+            return proxy.queryLag();
+        }
+        return UNKNOWN;
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/NoopProxyStatsProvider.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/NoopProxyStatsProvider.java
new file mode 100644
index 00000000..8a833520
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/NoopProxyStatsProvider.java
@@ -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.
+ */
+
+package org.apache.rocketmq.studio.provider.apache;
+
+import org.springframework.stereotype.Component;
+
+/**
+ * Default {@link ProxyStatsProvider} used when no proxy transport is wired 
in. It reports the
+ * unknown sentinel so a {@code -1} lag is surfaced instead of being silently 
treated as zero.
+ */
+@Component
+public class NoopProxyStatsProvider implements ProxyStatsProvider {
+
+    @Override
+    public long queryLag() {
+        return ConsumerLagResolver.UNKNOWN;
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/ProxyFallbackPolicy.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/ProxyFallbackPolicy.java
new file mode 100644
index 00000000..c6e4e530
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/ProxyFallbackPolicy.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.rocketmq.studio.provider.apache;
+
+import org.apache.rocketmq.client.exception.MQBrokerException;
+import org.apache.rocketmq.studio.common.domain.enums.ClusterType;
+
+import java.util.regex.Pattern;
+
+/**
+ * Pure policy that decides whether a failed broker request should be retried 
through a RocketMQ
+ * 5.0 proxy.
+ *
+ * <p>Connecting the dashboard directly to a 5.0 broker cluster (or a proxy 
acting on its behalf)
+ * raises {@link MQBrokerException} with a description such as {@code "request 
type 106 not
+ * supported"} or {@code "request type 206 not supported"} for request codes 
the proxy does not
+ * forward. This policy detects that condition and the proxy access mode so 
callers can fall back
+ * to a proxy transport instead of crashing.
+ *
+ * <p>This class is intentionally free of transport logic: the real proxy 
request execution is a
+ * separate deliverable. Wiring sites consult {@link 
#shouldFallback(MQBrokerException, ClusterType)}
+ * to decide whether to retry.
+ */
+public final class ProxyFallbackPolicy {
+
+    private static final Pattern UNSUPPORTED_REQUEST_CODE =
+            Pattern.compile("request type \\d+ not supported", 
Pattern.CASE_INSENSITIVE);
+
+    private ProxyFallbackPolicy() {
+    }
+
+    /**
+     * @return {@code true} when the broker exception describes an unsupported 
request code
+     *         (e.g. {@code 106} / {@code 206} not supported).
+     */
+    public static boolean isUnsupportedRequestCode(MQBrokerException ex) {
+        if (ex == null) {
+            return false;
+        }
+        String desc = ex.getErrorMessage() != null ? ex.getErrorMessage() : 
ex.getMessage();
+        return desc != null && UNSUPPORTED_REQUEST_CODE.matcher(desc).find();
+    }
+
+    /** @return {@code true} for RocketMQ 5.0 proxy local or cluster access 
modes. */
+    public static boolean isProxyMode(ClusterType type) {
+        return type == ClusterType.V5_PROXY_LOCAL || type == 
ClusterType.V5_PROXY_CLUSTER;
+    }
+
+    /**
+     * Combines {@link #isUnsupportedRequestCode(MQBrokerException)} and 
{@link #isProxyMode(ClusterType)}.
+     */
+    public static boolean shouldFallback(MQBrokerException ex, ClusterType 
type) {
+        return isUnsupportedRequestCode(ex) && isProxyMode(type);
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/ProxyStatsProvider.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/ProxyStatsProvider.java
new file mode 100644
index 00000000..863e635b
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/ProxyStatsProvider.java
@@ -0,0 +1,34 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.rocketmq.studio.provider.apache;
+
+/**
+ * Source of consumer lag stats when the broker cannot report them directly.
+ *
+ * <p>RocketMQ 5.0 gRPC consumers report offsets as {@code -1} through the 
broker channel, which is
+ * indistinguishable from "zero lag" once clamped. A real proxy transport 
would implement this to
+ * query lag from the proxy; for now the default {@link 
NoopProxyStatsProvider} reports the unknown
+ * sentinel so the caller can surface {@code -1} instead of a misleading 
{@code 0}.
+ */
+public interface ProxyStatsProvider {
+
+    /**
+     * @return the authoritative consumer lag, or {@code -1} when it cannot be 
determined.
+     */
+    long queryLag();
+}
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 c09b5b4c..ccb3f18f 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
@@ -16,6 +16,7 @@
  */
 package org.apache.rocketmq.studio.provider.apache;
 
+import org.apache.rocketmq.client.exception.MQBrokerException;
 import org.apache.rocketmq.client.exception.MQClientException;
 import org.apache.rocketmq.client.producer.DefaultMQProducer;
 import org.apache.rocketmq.client.producer.SendResult;
@@ -207,7 +208,7 @@ public class RocketMQAdminClientImpl implements AdminClient 
{
                 throw e;
             } catch (Exception e) {
                 recordAudit("CREATE_TOPIC", topicName, e.getMessage(), 
"FAILED");
-                throw new BusinessException(500, "Failed to create topic: " + 
e.getMessage());
+                throw classifyBrokerFailure(e, "create topic");
             }
         });
     }
@@ -272,7 +273,7 @@ public class RocketMQAdminClientImpl implements AdminClient 
{
                 throw e;
             } catch (Exception e) {
                 recordAudit("UPDATE_TOPIC", topicName, e.getMessage(), 
"FAILED");
-                throw new BusinessException(500, "Failed to update topic: " + 
e.getMessage());
+                throw classifyBrokerFailure(e, "update topic");
             }
         });
     }
@@ -311,7 +312,7 @@ public class RocketMQAdminClientImpl implements AdminClient 
{
                 throw e;
             } catch (Exception e) {
                 recordAudit("DELETE_TOPIC", name, e.getMessage(), "FAILED");
-                throw new BusinessException(500, "Failed to delete topic: " + 
e.getMessage());
+                throw classifyBrokerFailure(e, "delete topic");
             }
         });
     }
@@ -443,7 +444,7 @@ public class RocketMQAdminClientImpl implements AdminClient 
{
             throw e;
         } catch (Exception e) {
             recordAudit("CREATE_GROUP", groupName, e.getMessage(), "FAILED");
-            throw new BusinessException(500, "Failed to create consumer group: 
" + e.getMessage());
+            throw classifyBrokerFailure(e, "create consumer group");
         }
     }
 
@@ -481,7 +482,7 @@ public class RocketMQAdminClientImpl implements AdminClient 
{
             throw e;
         } catch (Exception e) {
             recordAudit("DELETE_GROUP", name, e.getMessage(), "FAILED");
-            throw new BusinessException(500, "Failed to delete consumer group: 
" + e.getMessage());
+            throw classifyBrokerFailure(e, "delete consumer group");
         }
     }
 
@@ -512,6 +513,23 @@ public class RocketMQAdminClientImpl implements 
AdminClient {
 
     // ── Helper methods ──────────────────────────────────────────────────
 
+    /**
+     * Classifies a broker failure, surfacing a clear "not supported in proxy 
mode" error when the
+     * broker rejects a request code (e.g. {@code 106} / {@code 206}) that the 
5.0 proxy does not
+     * forward. This is the guarded seam for proxy fallback: instead of 
crashing on the raw broker
+     * exception, callers get an actionable message pointing at the proxy 
endpoint.
+     */
+    private BusinessException classifyBrokerFailure(Exception e, String 
operation) {
+        if (e instanceof MQBrokerException mbe && 
ProxyFallbackPolicy.isUnsupportedRequestCode(mbe)) {
+            log.warn("Broker request not supported during {} ({}); connect via 
the RocketMQ 5.0 proxy",
+                    operation, mbe.getErrorMessage());
+            return new BusinessException(501, "Operation '" + operation
+                    + "' is not supported when connecting through a RocketMQ 
5.0 proxy "
+                    + "(request code not supported). Use the proxy endpoint. 
Cause: " + mbe.getErrorMessage());
+        }
+        return new BusinessException(500, "Failed to " + operation + ": " + 
e.getMessage());
+    }
+
     private void recordAudit(String action, String resource, String detail, 
String result) {
         try {
             auditService.record(action, resource, detail, result);
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProvider.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProvider.java
index 13140550..8f2836f8 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProvider.java
@@ -90,6 +90,13 @@ public class RocketMQMetadataProvider implements 
MetadataProvider {
     private final RmqGroupMapper groupMapper;
     private final RuntimeAdminClientResolver runtimeAdminClientResolver;
 
+    /**
+     * Default proxy stats source until a real proxy transport is wired in. It 
reports the unknown
+     * sentinel ({@link ConsumerLagResolver#UNKNOWN}) so a {@code -1} gRPC lag 
is surfaced instead of
+     * being silently clamped to zero.
+     */
+    private final ProxyStatsProvider proxyStatsProvider = new 
NoopProxyStatsProvider();
+
     /** Whether a default NameServer is configured and live queries are 
therefore possible. */
     private boolean hasAdmin() {
         return StringUtils.hasText(properties.getNamesrvAddr());
@@ -290,7 +297,7 @@ public class RocketMQMetadataProvider implements 
MetadataProvider {
                     if (stats != null && stats.getOffsetTable() != null) {
                         for (Map.Entry<MessageQueue, OffsetWrapper> entry : 
stats.getOffsetTable().entrySet()) {
                             OffsetWrapper ow = entry.getValue();
-                            diffTotal += Math.max(0, ow.getBrokerOffset() - 
ow.getConsumerOffset());
+                            diffTotal += resolveDiff(ow.getBrokerOffset(), 
ow.getConsumerOffset());
                         }
                         consumeTps = stats.getConsumeTps();
                     }
@@ -359,7 +366,7 @@ public class RocketMQMetadataProvider implements 
MetadataProvider {
             for (Map.Entry<MessageQueue, OffsetWrapper> entry : 
stats.getOffsetTable().entrySet()) {
                 MessageQueue mq = entry.getKey();
                 OffsetWrapper ow = entry.getValue();
-                long diff = Math.max(0, ow.getBrokerOffset() - 
ow.getConsumerOffset());
+                long diff = resolveDiff(ow.getBrokerOffset(), 
ow.getConsumerOffset());
 
                 progress.add(QueueProgressVO.builder()
                         .broker(mq.getBrokerName())
@@ -419,6 +426,14 @@ public class RocketMQMetadataProvider implements 
MetadataProvider {
 
     // ── Helper methods ──────────────────────────────────────────────────
 
+    /**
+     * Resolves the lag for a single queue without clamping the broker's 
{@code -1} "unknown"
+     * sentinel to zero. A negative raw diff (typical for RocketMQ 5.0 gRPC 
consumers) is passed
+     * through {@link ConsumerLagResolver} so the unknown state stays visible.
+     */
+    private long resolveDiff(long brokerOffset, long consumerOffset) {
+        return ConsumerLagResolver.resolve(brokerOffset - consumerOffset, 
proxyStatsProvider);
+    }
     private String filterMode(String expressionType) {
         if ("SQL92".equals(expressionType)) {
             return "SQL";
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyControllerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyControllerTest.java
index faea87a4..c99efe9b 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyControllerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyControllerTest.java
@@ -47,6 +47,9 @@ class ProxyControllerTest {
     @MockBean
     private ClusterService clusterService;
 
+    @MockBean
+    private ProxyAddressService proxyAddressService;
+
     @Test
     void restartProxyShouldPassValidatedRequest() throws Exception {
         RestartProxyDTO request = RestartProxyDTO.builder()
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
index b397eed0..dc8e571a 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
@@ -19,6 +19,7 @@ package org.apache.rocketmq.studio.instance.acl;
 
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.apache.rocketmq.studio.audit.OperationAuditService;
+import org.apache.rocketmq.studio.model.Acl2PolicyContext;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -562,4 +563,90 @@ class AclServiceTest {
     private String mask(String credential) {
         return credential.substring(0, 4) + "****" + 
credential.substring(credential.length() - 4);
     }
+
+    @Test
+    void validateAcl2PolicyShouldAcceptValidPolicy() {
+        Acl2PolicyContext policy = new Acl2PolicyContext();
+        policy.setPolicyName("orders-policy");
+        policy.setBoundType("Topic");
+        
policy.setRules(List.of(Acl2PolicyContext.AuthorizationRule.defaultAllowRule("orders-*")));
+        policy.setWhiteSet(List.of("192.168.1.0/24", "10.0.0.1"));
+
+        aclService.validateAcl2Policy(policy);
+    }
+
+    @Test
+    void validateAcl2PolicyShouldRejectNullPolicy() {
+        assertThatThrownBy(() -> aclService.validateAcl2Policy(null))
+                .isInstanceOf(BusinessException.class)
+                .satisfies(ex -> assertThat(((BusinessException) 
ex).getCode()).isEqualTo(400));
+    }
+
+    @Test
+    void validateAcl2PolicyShouldRequirePolicyName() {
+        Acl2PolicyContext policy = new Acl2PolicyContext();
+        policy.setBoundType("Group");
+        
policy.setRules(List.of(Acl2PolicyContext.AuthorizationRule.defaultAllowRule("*")));
+
+        assertThatThrownBy(() -> aclService.validateAcl2Policy(policy))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("ACL 2.0 policyName is required");
+    }
+
+    @Test
+    void validateAcl2PolicyShouldRejectInvalidBoundType() {
+        Acl2PolicyContext policy = new Acl2PolicyContext();
+        policy.setPolicyName("p");
+        policy.setBoundType("NONSENSE");
+        
policy.setRules(List.of(Acl2PolicyContext.AuthorizationRule.defaultAllowRule("*")));
+
+        assertThatThrownBy(() -> aclService.validateAcl2Policy(policy))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("boundType must be one of");
+    }
+
+    @Test
+    void validateAcl2PolicyShouldAcceptWildcardBoundType() {
+        Acl2PolicyContext policy = new Acl2PolicyContext();
+        policy.setPolicyName("p");
+        policy.setBoundType("*");
+        
policy.setRules(List.of(Acl2PolicyContext.AuthorizationRule.defaultAllowRule("*")));
+
+        aclService.validateAcl2Policy(policy);
+    }
+
+    @Test
+    void validateAcl2PolicyShouldRequireRules() {
+        Acl2PolicyContext policy = new Acl2PolicyContext();
+        policy.setPolicyName("p");
+        policy.setBoundType("Topic");
+
+        assertThatThrownBy(() -> aclService.validateAcl2Policy(policy))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("ACL 2.0 policy rules are required");
+    }
+
+    @Test
+    void validateAcl2PolicyShouldAcceptZeroDotZeroWildcardInWhiteSet() {
+        Acl2PolicyContext policy = new Acl2PolicyContext();
+        policy.setPolicyName("p");
+        policy.setBoundType("Group");
+        
policy.setRules(List.of(Acl2PolicyContext.AuthorizationRule.defaultAllowRule("*")));
+        policy.setWhiteSet(List.of("0.0.0.0"));
+
+        aclService.validateAcl2Policy(policy);
+    }
+
+    @Test
+    void validateAcl2PolicyShouldRejectMalformedWhiteSetEntry() {
+        Acl2PolicyContext policy = new Acl2PolicyContext();
+        policy.setPolicyName("p");
+        policy.setBoundType("Group");
+        
policy.setRules(List.of(Acl2PolicyContext.AuthorizationRule.defaultAllowRule("*")));
+        policy.setWhiteSet(List.of("192.168.1.0/24", "not-an-ip"));
+
+        assertThatThrownBy(() -> aclService.validateAcl2Policy(policy))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("whiteSet entry is not a valid IP/CIDR 
range");
+    }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/IpRangeMatcherTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/IpRangeMatcherTest.java
new file mode 100644
index 00000000..7c34f9a0
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/IpRangeMatcherTest.java
@@ -0,0 +1,119 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.rocketmq.studio.instance.acl;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class IpRangeMatcherTest {
+
+    @Test
+    void bareZeroMatchesAnyIp() {
+        assertThat(IpRangeMatcher.isInRange("192.168.1.10", 
"0.0.0.0")).isTrue();
+        assertThat(IpRangeMatcher.isInRange("10.0.0.5", "0.0.0.0")).isTrue();
+        assertThat(IpRangeMatcher.isInRange("1.2.3.4", "0.0.0.0")).isTrue();
+    }
+
+    @Test
+    void zeroCidrMatchesAnyIp() {
+        assertThat(IpRangeMatcher.isInRange("203.0.113.7", 
"0.0.0.0/0")).isTrue();
+        assertThat(IpRangeMatcher.isInRange("192.168.1.10", 
"0.0.0.0/0")).isTrue();
+    }
+
+    @Test
+    void ipv6WildcardMatchesAnyIp() {
+        assertThat(IpRangeMatcher.isInRange("192.168.1.10", "::/0")).isTrue();
+        assertThat(IpRangeMatcher.isInRange("2001:db8::1", "::/0")).isTrue();
+    }
+
+    @Test
+    void cidrRangeMatchesInSubnet() {
+        assertThat(IpRangeMatcher.isInRange("192.168.1.0", 
"192.168.1.0/24")).isTrue();
+        assertThat(IpRangeMatcher.isInRange("192.168.1.42", 
"192.168.1.0/24")).isTrue();
+        assertThat(IpRangeMatcher.isInRange("192.168.1.255", 
"192.168.1.0/24")).isTrue();
+        assertThat(IpRangeMatcher.isInRange("10.0.0.1", 
"192.168.1.0/24")).isFalse();
+        assertThat(IpRangeMatcher.isInRange("192.168.2.1", 
"192.168.1.0/24")).isFalse();
+    }
+
+    @Test
+    void exactMatchWithoutSlash() {
+        assertThat(IpRangeMatcher.isInRange("10.0.0.1", "10.0.0.1")).isTrue();
+        assertThat(IpRangeMatcher.isInRange("10.0.0.1", "10.0.0.2")).isFalse();
+    }
+
+    @Test
+    void singleHostCidr() {
+        assertThat(IpRangeMatcher.isInRange("10.0.0.1", 
"10.0.0.1/32")).isTrue();
+        assertThat(IpRangeMatcher.isInRange("10.0.0.2", 
"10.0.0.1/32")).isFalse();
+    }
+
+    @Test
+    void malformedInputReturnsFalse() {
+        assertThat(IpRangeMatcher.isInRange("not-an-ip", 
"192.168.1.0/24")).isFalse();
+        assertThat(IpRangeMatcher.isInRange("192.168.1.10", 
"not-an-ip/24")).isFalse();
+        assertThat(IpRangeMatcher.isInRange("192.168.1.10", 
"192.168.1.0/99")).isFalse();
+        assertThat(IpRangeMatcher.isInRange("192.168.1.10", 
"192.168.1.0/")).isFalse();
+        assertThat(IpRangeMatcher.isInRange(null, "192.168.1.0/24")).isFalse();
+        assertThat(IpRangeMatcher.isInRange("192.168.1.10", null)).isFalse();
+        assertThat(IpRangeMatcher.isInRange("   ", 
"192.168.1.0/24")).isFalse();
+        assertThat(IpRangeMatcher.isInRange("192.168.1.10", "   ")).isFalse();
+    }
+
+    @Test
+    void ipv6CidrOtherThanWildcardIsNotMatched() {
+        assertThat(IpRangeMatcher.isInRange("2001:db8::1", 
"2001:db8::/32")).isFalse();
+    }
+
+    @Test
+    void mismatchedAddressFamiliesReturnFalse() {
+        assertThat(IpRangeMatcher.isInRange("2001:db8::1", 
"192.168.1.0/24")).isFalse();
+        assertThat(IpRangeMatcher.isInRange("192.168.1.10", 
"2001:db8::/64")).isFalse();
+    }
+
+    @Test
+    void leadingAndTrailingWhitespaceIsTrimmed() {
+        assertThat(IpRangeMatcher.isInRange(" 192.168.1.42 ", " 192.168.1.0/24 
")).isTrue();
+        assertThat(IpRangeMatcher.isInRange("10.0.0.1", " 10.0.0.1 
")).isTrue();
+    }
+
+    @Test
+    void isValidRangeAcceptsWildcards() {
+        assertThat(IpRangeMatcher.isValidRange("0.0.0.0")).isTrue();
+        assertThat(IpRangeMatcher.isValidRange("0.0.0.0/0")).isTrue();
+        assertThat(IpRangeMatcher.isValidRange("::/0")).isTrue();
+    }
+
+    @Test
+    void isValidRangeAcceptsBareIpAndCidr() {
+        assertThat(IpRangeMatcher.isValidRange("192.168.1.10")).isTrue();
+        assertThat(IpRangeMatcher.isValidRange("192.168.1.0/24")).isTrue();
+        assertThat(IpRangeMatcher.isValidRange("10.0.0.1/32")).isTrue();
+    }
+
+    @Test
+    void isValidRangeRejectsMalformedEntries() {
+        assertThat(IpRangeMatcher.isValidRange(null)).isFalse();
+        assertThat(IpRangeMatcher.isValidRange("   ")).isFalse();
+        assertThat(IpRangeMatcher.isValidRange("not-an-ip")).isFalse();
+        assertThat(IpRangeMatcher.isValidRange("192.168.1.0/99")).isFalse();
+        assertThat(IpRangeMatcher.isValidRange("192.168.1.0/")).isFalse();
+        assertThat(IpRangeMatcher.isValidRange("10.0.0.1/abc")).isFalse();
+        assertThat(IpRangeMatcher.isValidRange("256.1.1.1")).isFalse();
+    }
+}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/ConsumerLagResolverTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/ConsumerLagResolverTest.java
new file mode 100644
index 00000000..e6e388aa
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/ConsumerLagResolverTest.java
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.rocketmq.studio.provider.apache;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ConsumerLagResolverTest {
+
+    @Test
+    void positiveBrokerDiffIsReturnedAsIs() {
+        assertThat(ConsumerLagResolver.resolve(100, null)).isEqualTo(100);
+        assertThat(ConsumerLagResolver.resolve(0, null)).isEqualTo(0);
+    }
+
+    @Test
+    void negativeBrokerDiffWithoutProxyReturnsUnknown() {
+        assertThat(ConsumerLagResolver.resolve(-1, null))
+                .isEqualTo(ConsumerLagResolver.UNKNOWN);
+    }
+
+    @Test
+    void negativeBrokerDiffWithProxyReturnsProxyValue() {
+        ProxyStatsProvider proxy = Mockito.mock(ProxyStatsProvider.class);
+        Mockito.when(proxy.queryLag()).thenReturn(42L);
+        assertThat(ConsumerLagResolver.resolve(-1, proxy)).isEqualTo(42);
+    }
+
+    @Test
+    void negativeBrokerDiffWithNoopProxyReturnsUnknown() {
+        ProxyStatsProvider proxy = new NoopProxyStatsProvider();
+        assertThat(ConsumerLagResolver.resolve(-1, proxy))
+                .isEqualTo(ConsumerLagResolver.UNKNOWN);
+    }
+
+    @Test
+    void positiveBrokerDiffIgnoresProxy() {
+        ProxyStatsProvider proxy = Mockito.mock(ProxyStatsProvider.class);
+        Mockito.when(proxy.queryLag()).thenReturn(999L);
+        assertThat(ConsumerLagResolver.resolve(7, proxy)).isEqualTo(7);
+    }
+}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/ProxyFallbackPolicyTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/ProxyFallbackPolicyTest.java
new file mode 100644
index 00000000..4b5e5571
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/ProxyFallbackPolicyTest.java
@@ -0,0 +1,92 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.rocketmq.studio.provider.apache;
+
+import org.apache.rocketmq.client.exception.MQBrokerException;
+import org.apache.rocketmq.studio.common.domain.enums.ClusterType;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ProxyFallbackPolicyTest {
+
+    @Test
+    void detectsRequestCode106NotSupported() {
+        MQBrokerException ex = new MQBrokerException(206, "request type 106 
not supported");
+        assertThat(ProxyFallbackPolicy.isUnsupportedRequestCode(ex)).isTrue();
+    }
+
+    @Test
+    void detectsRequestCode206NotSupported() {
+        MQBrokerException ex = new MQBrokerException(206, "request type 206 
not supported");
+        assertThat(ProxyFallbackPolicy.isUnsupportedRequestCode(ex)).isTrue();
+    }
+
+    @Test
+    void detectsGenericRequestCodeNotSupported() {
+        MQBrokerException ex = new MQBrokerException(11, "request type 333 not 
supported");
+        assertThat(ProxyFallbackPolicy.isUnsupportedRequestCode(ex)).isTrue();
+    }
+
+    @Test
+    void ignoresUnrelatedBrokerErrors() {
+        MQBrokerException ex = new MQBrokerException(1, "topic already 
exists");
+        assertThat(ProxyFallbackPolicy.isUnsupportedRequestCode(ex)).isFalse();
+    }
+
+    @Test
+    void detectsFromGenericMessageWhenErrorMessageMissing() {
+        MQBrokerException ex = new MQBrokerException(206, (String) null) {
+            @Override
+            public String getMessage() {
+                return "CODE: 206  DESC: request type 206 not supported";
+            }
+        };
+        assertThat(ProxyFallbackPolicy.isUnsupportedRequestCode(ex)).isTrue();
+    }
+
+    @Test
+    void nullExceptionIsNotUnsupported() {
+        
assertThat(ProxyFallbackPolicy.isUnsupportedRequestCode(null)).isFalse();
+    }
+
+    @Test
+    void proxyModeIsTrueForV5ProxyTypes() {
+        
assertThat(ProxyFallbackPolicy.isProxyMode(ClusterType.V5_PROXY_LOCAL)).isTrue();
+        
assertThat(ProxyFallbackPolicy.isProxyMode(ClusterType.V5_PROXY_CLUSTER)).isTrue();
+    }
+
+    @Test
+    void proxyModeIsFalseForV4Direct() {
+        
assertThat(ProxyFallbackPolicy.isProxyMode(ClusterType.V4_DIRECT)).isFalse();
+    }
+
+    @Test
+    void shouldFallbackCombinesUnsupportedCodeAndProxyMode() {
+        MQBrokerException ex = new MQBrokerException(206, "request type 206 
not supported");
+        assertThat(ProxyFallbackPolicy.shouldFallback(ex, 
ClusterType.V5_PROXY_CLUSTER)).isTrue();
+        assertThat(ProxyFallbackPolicy.shouldFallback(ex, 
ClusterType.V5_PROXY_LOCAL)).isTrue();
+        assertThat(ProxyFallbackPolicy.shouldFallback(ex, 
ClusterType.V4_DIRECT)).isFalse();
+    }
+
+    @Test
+    void shouldFallbackIsFalseForUnrelatedErrorInProxyMode() {
+        MQBrokerException ex = new MQBrokerException(1, "topic already 
exists");
+        assertThat(ProxyFallbackPolicy.shouldFallback(ex, 
ClusterType.V5_PROXY_CLUSTER)).isFalse();
+    }
+}
diff --git a/web/src/api/proxy.test.ts b/web/src/api/proxy.test.ts
index e2b7f364..fed361e0 100644
--- a/web/src/api/proxy.test.ts
+++ b/web/src/api/proxy.test.ts
@@ -18,7 +18,7 @@
 import MockAdapter from 'axios-mock-adapter';
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
 import client from './client';
-import { queryProxyHomePage, addProxyAddr, removeProxyAddr } from './proxy';
+import { queryProxyHomePage } from './proxy';
 
 const mock = new MockAdapter(client);
 
@@ -54,33 +54,4 @@ describe('Proxy API', () => {
     expect(result.proxyAddrList).toHaveLength(0);
     expect(result.currentProxyAddr).toBe('');
   });
-
-  it('adds a proxy address with form-urlencoded content type', async () => {
-    mock.onPost('/proxy/addProxyAddr.do').reply((config) => {
-      
expect(config.headers?.['Content-Type']).toBe('application/x-www-form-urlencoded');
-      expect(config.data).toBe('newProxyAddr=192.168.1.3%3A8081');
-      return [200, { code: 200 }];
-    });
-
-    await addProxyAddr('192.168.1.3:8081');
-  });
-
-  it('adds a proxy address with localhost', async () => {
-    mock.onPost('/proxy/addProxyAddr.do').reply((config) => {
-      expect(config.data).toBe('newProxyAddr=localhost%3A8081');
-      return [200, { code: 200 }];
-    });
-
-    await addProxyAddr('localhost:8081');
-  });
-
-  it('removes a proxy address with form-urlencoded content type', async () => {
-    mock.onPost('/proxy/removeProxyAddr.do').reply((config) => {
-      
expect(config.headers?.['Content-Type']).toBe('application/x-www-form-urlencoded');
-      expect(config.data).toBe('proxyAddr=192.168.1.3%3A8081');
-      return [200, { code: 200 }];
-    });
-
-    await removeProxyAddr('192.168.1.3:8081');
-  });
 });
diff --git a/web/src/api/proxy.ts b/web/src/api/proxy.ts
index 2ed50e67..46cc06cf 100644
--- a/web/src/api/proxy.ts
+++ b/web/src/api/proxy.ts
@@ -43,19 +43,3 @@ export async function queryProxyHomePage(): 
Promise<ProxyHomePageData> {
   const res = await client.get<{ data: ProxyHomePageData 
}>('/proxy/homePage.query');
   return res.data.data;
 }
-
-export async function addProxyAddr(address: string): Promise<void> {
-  const params = new URLSearchParams();
-  params.append('newProxyAddr', address);
-  await client.post('/proxy/addProxyAddr.do', params, {
-    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
-  });
-}
-
-export async function removeProxyAddr(address: string): Promise<void> {
-  const params = new URLSearchParams();
-  params.append('proxyAddr', address);
-  await client.post('/proxy/removeProxyAddr.do', params, {
-    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
-  });
-}
diff --git a/web/src/config.test.ts b/web/src/config.test.ts
new file mode 100644
index 00000000..2067ae26
--- /dev/null
+++ b/web/src/config.test.ts
@@ -0,0 +1,35 @@
+/*
+ * 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 { API_BASE_URL } from './config';
+
+describe('config API base url', () => {
+  it('defaults to a relative /api path when no env override is set', () => {
+    expect(API_BASE_URL).toBe('/api');
+  });
+
+  it('never resolves to an absolute localhost address by default', () => {
+    expect(API_BASE_URL.startsWith('http://localhost')).toBe(false);
+    expect(API_BASE_URL.startsWith('https://localhost')).toBe(false);
+    expect(API_BASE_URL.startsWith('//localhost')).toBe(false);
+  });
+
+  it('strips a single trailing slash', () => {
+    expect(API_BASE_URL.endsWith('/')).toBe(false);
+  });
+});
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 6ae498fd..6c4be765 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -1047,27 +1047,15 @@ const translations: Record<string, Record<Lang, 
string>> = {
   'proxy.healthyNodes': { zh: '健康节点', en: 'Healthy Nodes' },
   'proxy.totalConnections': { zh: '总连接数', en: 'Total Connections' },
   'proxy.totalTps': { zh: '总 TPS', en: 'Total TPS' },
-  'proxy.addNode': { zh: '添加节点', en: 'Add Node' },
   'proxy.nodes': { zh: 'Proxy 节点', en: 'Proxy Nodes' },
   'proxy.viewConfig': { zh: '查看配置', en: 'View Config' },
-  'proxy.remove': { zh: '移除', en: 'Remove' },
   'proxy.nodeConfig': { zh: '节点配置', en: 'Node Configuration' },
-  'proxy.addProxyNode': { zh: '添加 Proxy 节点', en: 'Add Proxy Node' },
-  'proxy.address': { zh: 'Proxy 地址', en: 'Proxy Address' },
-  'proxy.addressPlaceholder': { zh: '例:127.0.0.1:8081', en: 'e.g., 
127.0.0.1:8081' },
-  'proxy.invalidAddress': {
-    zh: '地址格式无效(例:127.0.0.1:8081)',
-    en: 'Invalid format (e.g., 127.0.0.1:8081)',
-  },
   'proxy.current': { zh: '当前', en: 'Current' },
   'proxy.connections': { zh: '连接数', en: 'Connections' },
   'proxy.memory': { zh: '内存', en: 'Memory' },
   'proxy.uptime': { zh: '运行时间', en: 'Uptime' },
   'proxy.action': { zh: '操作', en: 'Action' },
   'proxy.fetchListFailed': { zh: '获取代理列表失败', en: 'Failed to fetch proxy list' 
},
-  'proxy.addFailed': { zh: '添加代理失败', en: 'Failed to add proxy' },
-  'proxy.removeFailed': { zh: '移除代理失败', en: 'Failed to remove proxy' },
-  'proxy.addrRequired': { zh: '请输入代理地址', en: 'Proxy address is required' },
   'proxy.noConfigData': { zh: '无配置数据', en: 'No config data' },
   'proxy.configUnavailable': { zh: '配置接口未接入', en: 'Config API unavailable' },
   'proxy.configUnavailableHint': {
@@ -1079,7 +1067,6 @@ const translations: Record<string, Record<Lang, string>> 
= {
   'proxy.healthy': { zh: '健康', en: 'Healthy' },
   'proxy.unhealthy': { zh: '不健康', en: 'Unhealthy' },
   'proxy.warning': { zh: '警告', en: 'Warning' },
-  'proxy.confirmRemove': { zh: '确认移除此节点?', en: 'Are you sure to remove this 
node?' },
 
   // ─── Broker Cluster ───
   'brokerCluster.title': { zh: 'Broker 集群', en: 'Broker Cluster' },
diff --git a/web/src/pages/studio/Proxy.tsx b/web/src/pages/studio/Proxy.tsx
index 7561e731..bc9a8abd 100644
--- a/web/src/pages/studio/Proxy.tsx
+++ b/web/src/pages/studio/Proxy.tsx
@@ -23,8 +23,6 @@ import {
   Button,
   Space,
   Modal,
-  Form,
-  Input,
   Spin,
   Row,
   Col,
@@ -32,15 +30,12 @@ import {
   Progress,
   Descriptions,
   Tooltip,
-  Popconfirm,
   App,
   Typography,
 } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
 import {
   ArrowClockwise,
-  Plus,
-  Trash,
   GearSix,
   Gauge,
   CheckCircle,
@@ -49,7 +44,7 @@ import {
 } from '@phosphor-icons/react';
 import PageHeader from '../../components/PageHeader';
 import { useLang } from '../../i18n/LangContext';
-import { queryProxyHomePage, addProxyAddr, removeProxyAddr, type ProxyNode } 
from '../../api/proxy';
+import { queryProxyHomePage, type ProxyNode } from '../../api/proxy';
 
 const { Text } = Typography;
 
@@ -61,8 +56,6 @@ const ProxyPage: React.FC = () => {
   const [proxyNodes, setProxyNodes] = useState<ProxyNode[]>([]);
   const [selectedNode, setSelectedNode] = useState<ProxyNode | null>(null);
   const [configModalOpen, setConfigModalOpen] = useState(false);
-  const [addNodeModalOpen, setAddNodeModalOpen] = useState(false);
-  const [form] = Form.useForm();
   const loadRequestId = useRef(0);
 
   const [clusterStats, setClusterStats] = useState({
@@ -130,41 +123,6 @@ const ProxyPage: React.FC = () => {
     setConfigModalOpen(true);
   };
 
-  const handleAddNode = async () => {
-    let values: { address: string };
-    try {
-      values = await form.validateFields();
-    } catch {
-      return;
-    }
-
-    setLoading(true);
-    try {
-      await addProxyAddr(values.address);
-      message.success(t('common.success'));
-      setAddNodeModalOpen(false);
-      form.resetFields();
-      await loadProxyNodes();
-    } catch {
-      message.error(t('proxy.addFailed'));
-    } finally {
-      setLoading(false);
-    }
-  };
-
-  const handleRemoveNode = async (node: ProxyNode) => {
-    setLoading(true);
-    try {
-      await removeProxyAddr(node.address);
-      message.success(t('common.success'));
-      await loadProxyNodes();
-    } catch {
-      message.error(t('proxy.removeFailed'));
-    } finally {
-      setLoading(false);
-    }
-  };
-
   const handleRefresh = async () => {
     if (await loadProxyNodes()) {
       message.success(t('common.refreshSuccess'));
@@ -304,18 +262,6 @@ const ProxyPage: React.FC = () => {
               onClick={() => handleViewConfig(record)}
             />
           </Tooltip>
-          {!record.isSelected && (
-            <Popconfirm
-              title={t('proxy.confirmRemove')}
-              onConfirm={() => handleRemoveNode(record)}
-              okText={t('common.yes')}
-              cancelText={t('common.no')}
-            >
-              <Tooltip title={t('proxy.remove')}>
-                <Button type="link" size="small" danger icon={<Trash size={14} 
/>} />
-              </Tooltip>
-            </Popconfirm>
-          )}
         </Space>
       ),
     },
@@ -333,9 +279,6 @@ const ProxyPage: React.FC = () => {
             <Button type="primary" icon={<ArrowClockwise size={14} />} 
onClick={handleRefresh}>
               {t('common.refresh')}
             </Button>
-            <Button icon={<Plus size={14} />} onClick={() => 
setAddNodeModalOpen(true)}>
-              {t('proxy.addNode')}
-            </Button>
           </Space>
         }
       />
@@ -409,38 +352,6 @@ const ProxyPage: React.FC = () => {
           </Descriptions.Item>
         </Descriptions>
       </Modal>
-
-      {/* Add Node Modal */}
-      <Modal
-        title={t('proxy.addProxyNode')}
-        open={addNodeModalOpen}
-        onCancel={() => {
-          setAddNodeModalOpen(false);
-          form.resetFields();
-        }}
-        onOk={handleAddNode}
-        okText={t('common.add')}
-        cancelText={t('common.cancel')}
-      >
-        <Form form={form} layout="vertical">
-          <Form.Item
-            name="address"
-            label={t('proxy.address')}
-            rules={[
-              {
-                required: true,
-                message: t('proxy.addrRequired'),
-              },
-              {
-                pattern: /^[\w.-]+:\d+$/,
-                message: t('proxy.invalidAddress'),
-              },
-            ]}
-          >
-            <Input placeholder={t('proxy.addressPlaceholder')} />
-          </Form.Item>
-        </Form>
-      </Modal>
     </div>
   );
 };
diff --git a/web/src/pages/studio/__tests__/Proxy.test.tsx 
b/web/src/pages/studio/__tests__/Proxy.test.tsx
index c5d0f2b6..2416dd50 100644
--- a/web/src/pages/studio/__tests__/Proxy.test.tsx
+++ b/web/src/pages/studio/__tests__/Proxy.test.tsx
@@ -24,9 +24,7 @@ import { LangProvider } from '../../../i18n/LangContext';
 import ProxyPage from '../Proxy';
 
 vi.mock('../../../api/proxy', () => ({
-  addProxyAddr: vi.fn(),
   queryProxyHomePage: vi.fn(),
-  removeProxyAddr: vi.fn(),
 }));
 
 beforeAll(() => {

Reply via email to