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(() => {