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 aaf1d367c fix(studio): report unknown size and delay instead of zero 
(#5649)
aaf1d367c is described below

commit aaf1d367c58af9d7508ebb28d726c25b79c601fe
Author: Apulupie <[email protected]>
AuthorDate: Tue Oct 27 23:44:04 2026 +0800

    fix(studio): report unknown size and delay instead of zero (#5649)
    
    * fix(message): report an unknown cloud message size instead of a 
fabricated zero (#5649)
    Three call sites rendered a message whose body size the vendor API never 
reported
    as 0 bytes: Aliyun's ListMessages returns a nullable Integer BodySize, and
    Tencent's DescribeMessage / MessageItem models have no size getter at all
    (verified against aliyun 5.0.8 and trocket 3.1.1498). An operator reading
    "0 B" was told the message was empty. Carry MessageRecordVO.UNKNOWN_SIZE 
(-1) -
    the sentinel the repository already uses for QueueProgressVO.UNKNOWN_OFFSET 
and
    in web/src/utils/consumerLag.ts - and keep it out of every user-facing 
surface:
    format.ts gains UNKNOWN_MESSAGE_SIZE and formatOptionalBytes next to the 
existing
    formatOptionalTimeMs, wired into the message table, the detail drawer and
    QueueBrowser; message.yaml declares minimum: -1 with a description on all 
three
    size properties, following group.yaml; and docs/api-spec.md states that -1 
means
    the vendor did not report a size. The AI contract records (MessageItem,
    MessageQueryOutput, RedeliverySource) keep the sentinel in structured 
output and
    document it, the same split #5291 applied to the reset-offset plan: 
structured
    fields carry the sentinel, prose never does.
    * fix(ai): stop publishing an unmeasurable consume delay as zero seconds 
(#6106)
    group.detail's delaySeconds was a primitive int, so a group whose delay 
could not
    be measured was published to the model as "0 seconds" - a fabricated fact 
rather
    than an absent one. Make it Integer and omit it when unknown, following
    retryMaxTimes in the same record, which is already an Integer that is null 
and
    absent from required. The catalog is regenerated for both this and the 
message.yaml
    change above in a single pass; make catalog-verify reports no drift.
    * fix(instance): surface the unique-name race as a duplicate-name error 
(#5297)
    Two concurrent creates can both pass requireUniqueInstanceName; the loser 
hits the
    uk_instance_name unique key and the catch in InstanceService only 
translated the
    cloud-credential foreign-key violation, rethrowing everything else into a 
bare 500.
    Walk the cause chain for that key and throw DuplicateInstanceNameException, 
so the
    raced path answers with the same 400 as the sequential one.
    
    Two of these needed work to land on the current baseline. #5297's test 
fixture
    predates #7353, which made nameserverId mandatory on the APACHE create 
path, so
    the fixture now supplies it; the new test method also gained the 
repository's Test
    suffix and the java.sql.SQLException import moved into the java.* group. 
#5649
    conflicted with #7033 in TencentInstanceProvider (both kept, verified line 
by
    line) and in MessagePage.test.tsx, where this change and trunk each append 
a test
    to the same describe block - both are kept.
    
    Batch integration of three related fixes by the same contributor; #5297 and 
#6106
    are closed as merged into this one. Verified on the combined branch after 
rebasing
    onto the current trunk: 4161 backend tests, 2019 frontend tests across 166 
files,
    the production build and type check, license gate 8/8, eslint with no new 
warnings,
    and make catalog-verify / go build / go test / go vet / make fmt all clean.
---
 docs/api-spec.md                                   |  2 +-
 rmqctl/internal/catalog/catalog_gen.go             |  2 +-
 .../rocketmq/studio/instance/InstanceService.java  | 18 +++++++++
 .../studio/instance/message/MessageRecordVO.java   | 10 +++++
 .../ai/tool/contract/group/GroupDetailOutput.java  |  9 ++++-
 .../ops/ai/tool/contract/message/MessageItem.java  |  1 +
 .../tool/contract/message/MessageQueryOutput.java  |  1 +
 .../message/MessageRedeliveryToolHandler.java      |  7 ++++
 .../studio/provider/alibaba/AliyunConverters.java  |  3 +-
 .../provider/tencent/TencentInstanceProvider.java  |  6 ++-
 .../main/resources/tool-catalog/tools/group.yaml   |  5 ++-
 .../main/resources/tool-catalog/tools/message.yaml |  6 +++
 .../studio/instance/InstanceServiceTest.java       | 20 ++++++++++
 .../group/ConsumerGroupReadToolHandlersTest.java   | 43 ++++++++++++++++++++++
 .../alibaba/AliyunConvertersBodyEncodingTest.java  |  2 +
 .../tencent/TencentInstanceProviderTest.java       | 17 +++++++++
 web/src/api/message.ts                             |  1 +
 web/src/components/QueueBrowser.tsx                |  4 +-
 .../pages/instance/__tests__/MessagePage.test.tsx  | 27 ++++++++++++++
 web/src/pages/instance/message.tsx                 |  6 +--
 web/src/utils/format.test.ts                       | 13 +++++++
 web/src/utils/format.ts                            | 12 ++++++
 22 files changed, 202 insertions(+), 13 deletions(-)

diff --git a/docs/api-spec.md b/docs/api-spec.md
index 8f124bf49..a761e1f03 100644
--- a/docs/api-spec.md
+++ b/docs/api-spec.md
@@ -2085,7 +2085,7 @@ GET /api/messages
 | `storeHost` | `string` | 存储 Broker 地址 |
 | `properties` | `Record<string, string>` | 消息属性键值对 |
 | `propertiesTruncated` | `boolean` | 消息属性是否被截断 |
-| `size` | `number` | 消息大小(字节) |
+| `size` | `number` | 消息大小(字节);`-1` 表示云厂商 API 未返回大小(未知,不是 0 字节),前端渲染为 `-` |
 
 ### 8.2 分页查询消息
 
diff --git a/rmqctl/internal/catalog/catalog_gen.go 
b/rmqctl/internal/catalog/catalog_gen.go
index ffdc4f97a..39960494c 100644
--- a/rmqctl/internal/catalog/catalog_gen.go
+++ b/rmqctl/internal/catalog/catalog_gen.go
@@ -21,7 +21,7 @@ package catalog
 var defaultDocument = Document{
        Version:              "2.0.0",
        MinimumClientVersion: "2.0.0",
-       Digest:               
"e2ebdf66500503301025ed12f41ada2176e63249dce4732ee0e98c57300be682",
+       Digest:               
"3b4db152a6ca5e7dd92de7d4394d434b4b8448d14844198eb2fc2bc235e3d0c5",
        Tools: []Tool{
                {
                        Name:                 "rmq.acl.list",
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java 
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
index 633766836..06256316a 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
@@ -265,6 +265,12 @@ public class InstanceService {
         try {
             saved = 
ownershipGuard.withInstanceRegistration(instance.getName(), () -> 
instanceRepository.save(instance));
         } catch (DataIntegrityViolationException exception) {
+            // Two concurrent creates can both pass requireUniqueInstanceName; 
the loser hits the
+            // uk_instance_name unique key and must surface as the same 
duplicate-name error as
+            // the sequential case instead of a raw 500.
+            if (isUniqueInstanceNameViolation(exception)) {
+                throw new DuplicateInstanceNameException(instance.getName());
+            }
             if (vendor != InstanceVendor.APACHE && 
isCloudCredentialReferenceViolation(exception)) {
                 throw new BusinessException(409, "Cloud credential no longer 
exists: " + instance.getCredentialId());
             }
@@ -855,6 +861,18 @@ public class InstanceService {
         return "name=" + instance.getName() + ", vendor=" + vendor + ", type=" 
+ instance.getType();
     }
 
+    private boolean isUniqueInstanceNameViolation(Throwable exception) {
+        Throwable current = exception;
+        while (current != null) {
+            String message = current.getMessage();
+            if (message != null && 
message.toLowerCase(Locale.ROOT).contains("uk_instance_name")) {
+                return true;
+            }
+            current = current.getCause();
+        }
+        return false;
+    }
+
     private boolean isCloudCredentialReferenceViolation(Throwable exception) {
         Throwable current = exception;
         while (current != null) {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageRecordVO.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageRecordVO.java
index 8869f9b56..4e456d80b 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageRecordVO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageRecordVO.java
@@ -28,6 +28,16 @@ import java.util.Map;
 @NoArgsConstructor
 @AllArgsConstructor
 public class MessageRecordVO {
+    /**
+     * Sentinel for a body size the vendor API does not report: Aliyun {@code 
ListMessages} leaves
+     * {@code BodySize} null, and Tencent {@code DescribeMessage} / {@code 
MessageItem} carry no size
+     * field at all. It matches the {@code -1} the broker uses for an 
undeterminable value, the
+     * console renders a negative size as unavailable instead of a number that 
would read like a
+     * measurement, and the tool catalog declares {@code minimum: -1} so an AI 
caller learns the
+     * sentinel means unknown rather than zero bytes.
+     */
+    public static final int UNKNOWN_SIZE = -1;
+
     private String msgId;
     private String topic;
     private String tag;
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/group/GroupDetailOutput.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/group/GroupDetailOutput.java
index ca5216136..b334a8a37 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/group/GroupDetailOutput.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/group/GroupDetailOutput.java
@@ -47,7 +47,12 @@ public record GroupDetailOutput(
         String subscriptionDataType,
         String deliveryOrderType,
         Integer retryMaxTimes,
-        int delaySeconds,
+        /**
+         * Seconds since the group last consumed a message; null when the 
broker reported no
+         * consumed-message timestamp, so no delay can be measured. Never a 
zero the caller could
+         * read as "caught up".
+         */
+        Integer delaySeconds,
         List<Subscription> subscriptions,
         List<Instance> instances,
         Health health,
@@ -82,7 +87,7 @@ public record GroupDetailOutput(
                 source.getSubscriptionDataType(),
                 source.getDeliveryOrderType(),
                 configurations.size() == 1 ? source.getRetryMaxTimes() : null,
-                source.getDelaySeconds(),
+                source.isConsumptionTimestampAvailable() ? 
source.getDelaySeconds() : null,
                 entries.stream()
                         .filter(Objects::nonNull)
                         .map(Subscription::from)
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageItem.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageItem.java
index e3bca48a1..f06d8509c 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageItem.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageItem.java
@@ -31,6 +31,7 @@ public record MessageItem(
         String body,
         String bodyEncoding,
         boolean bodyTruncated,
+        /** Body size in bytes, or -1 when the vendor API reports none 
(unknown, not zero). */
         int size) {
 
     public static MessageItem from(MessageRecordVO message) {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageQueryOutput.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageQueryOutput.java
index cb3c33063..56cf7dd73 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageQueryOutput.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageQueryOutput.java
@@ -61,6 +61,7 @@ public record MessageQueryOutput(
             String body,
             String bodyEncoding,
             Boolean bodyTruncated,
+            /** Body size in bytes, or -1 when the vendor API reports none 
(unknown, not zero). */
             int size) {
 
         static Item from(MessageRecordVO message, boolean includeBody) {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageRedeliveryToolHandler.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageRedeliveryToolHandler.java
index b9001ae90..fc71506de 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageRedeliveryToolHandler.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageRedeliveryToolHandler.java
@@ -98,6 +98,13 @@ public class MessageRedeliveryToolHandler extends 
MutationToolHandler<MessageRed
                 : RETRY_TOPIC_PREFIX + groupName;
     }
 
+    /**
+     * The source message the plan snapshots. {@code size} carries
+     * {@link MessageRecordVO#UNKNOWN_SIZE} when the vendor API reports no 
body size: structured
+     * output keeps the sentinel the way {@code 
GroupResetOffsetToolHandler.QueueState} keeps its
+     * own, and the catalog declares the plan's {@code before} side as a 
free-form object, so no
+     * per-property description travels with it.
+     */
     private record RedeliverySource(String msgId, String topic, String tag, 
String key, long storeTime, int size) {
     }
 
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConverters.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConverters.java
index 96406534b..4dc43d0b8 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConverters.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConverters.java
@@ -262,7 +262,8 @@ final class AliyunConverters {
                 .storeHost(data.getStoreHost())
                 .storeTime(parseTimeMillis(data.getStoreTime()))
                 .properties(data.getUserProperties())
-                .size(data.getBodySize() == null ? 0 : data.getBodySize());
+                // BodySize is optional in the ListMessages response; report 
the unknown sentinel instead of a fabricated zero.
+                .size(data.getBodySize() == null ? 
MessageRecordVO.UNKNOWN_SIZE : data.getBodySize());
         if (body != null) {
             builder.body(body.value()).bodyEncoding(body.encoding());
         }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProvider.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProvider.java
index 5b6de1d07..7b2f0093d 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProvider.java
@@ -858,7 +858,8 @@ public class TencentInstanceProvider implements 
InstanceProvider {
                 .bornHost(response.getProducerAddr())
                 .properties(properties)
                 .propertiesTruncated(false)
-                .size(0)
+                // DescribeMessage/MessageItem carry no body size; report the 
unknown sentinel instead of a fabricated zero.
+                .size(MessageRecordVO.UNKNOWN_SIZE)
                 .build();
     }
 
@@ -878,7 +879,8 @@ public class TencentInstanceProvider implements 
InstanceProvider {
                 .bornHost(item.getProducerAddr())
                 .properties(Collections.emptyMap())
                 .propertiesTruncated(false)
-                .size(0)
+                // DescribeMessage/MessageItem carry no body size; report the 
unknown sentinel instead of a fabricated zero.
+                .size(MessageRecordVO.UNKNOWN_SIZE)
                 .build();
     }
 
diff --git a/server/src/main/resources/tool-catalog/tools/group.yaml 
b/server/src/main/resources/tool-catalog/tools/group.yaml
index 3ac090089..6c7bf36d4 100644
--- a/server/src/main/resources/tool-catalog/tools/group.yaml
+++ b/server/src/main/resources/tool-catalog/tools/group.yaml
@@ -104,7 +104,6 @@ tools:
         - totalLag
         - subscribedTopics
         - subscriptions
-        - delaySeconds
         - health
       additionalProperties: false
       properties:
@@ -143,6 +142,10 @@ tools:
         delaySeconds:
           type: integer
           minimum: 0
+          description: >-
+            Seconds since the group last consumed a message. Absent when the 
broker reported no
+            consumed-message timestamp, so the delay cannot be measured - the 
value is not a
+            measured zero in that case.
         subscriptions:
           type: array
           items:
diff --git a/server/src/main/resources/tool-catalog/tools/message.yaml 
b/server/src/main/resources/tool-catalog/tools/message.yaml
index c43e04b15..aa14d3bdb 100644
--- a/server/src/main/resources/tool-catalog/tools/message.yaml
+++ b/server/src/main/resources/tool-catalog/tools/message.yaml
@@ -89,6 +89,8 @@ tools:
                 type: boolean
               size:
                 type: integer
+                minimum: -1
+                description: Message body size in bytes; -1 when the vendor 
API reports none (unknown, not zero).
         resultMayBeTruncated:
           type: boolean
           description: True when rows were omitted by limit or the provider 
may have stopped at its own scan bound.
@@ -174,6 +176,8 @@ tools:
                 type: boolean
               size:
                 type: integer
+                minimum: -1
+                description: Message body size in bytes; -1 when the vendor 
API reports none (unknown, not zero).
         resultMayBeTruncated:
           type: boolean
           description: True when rows were omitted by limit or the provider 
may have stopped at its own scan bound.
@@ -257,6 +261,8 @@ tools:
                 type: boolean
               size:
                 type: integer
+                minimum: -1
+                description: Message body size in bytes; -1 when the vendor 
API reports none (unknown, not zero).
     viewHint: table
     deprecated: false
   - name: rmq.message.query_dlq
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
index b1b041cdf..8a2551979 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
@@ -47,6 +47,7 @@ import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 import org.springframework.dao.DataIntegrityViolationException;
 
+import java.sql.SQLException;
 import java.time.LocalDateTime;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -577,6 +578,25 @@ class InstanceServiceTest {
                 .hasMessage("InstanceVO name is required");
     }
 
+    @Test
+    void createInstanceTranslatesUniqueKeyViolationToDuplicateNameTest() {
+        // Two concurrent creates can both pass the pre-check; the loser hits 
uk_instance_name and
+        // must surface as the duplicate-name error, not a raw 500.
+        InstanceVO input = InstanceVO.builder()
+                .name("racing-name")
+                .endpoint("10.0.1.1:8080")
+                .type(InstanceType.PROXY_CLUSTER)
+                .nameserverId(1L).build();
+        DataIntegrityViolationException violation = new 
DataIntegrityViolationException(
+                "could not execute statement",
+                new SQLException("Duplicate entry 'racing-name' for key 
'uk_instance_name'"));
+        
when(instanceRepository.save(any(InstanceVO.class))).thenThrow(violation);
+
+        assertThatThrownBy(() -> instanceService.createInstance(input))
+                .isInstanceOf(DuplicateInstanceNameException.class)
+                .hasMessage("Instance name already exists: racing-name");
+    }
+
     @Test
     void createInstanceShouldThrowWhenNameIsBlank() {
         InstanceVO input = InstanceVO.builder()
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/group/ConsumerGroupReadToolHandlersTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/group/ConsumerGroupReadToolHandlersTest.java
index 1f7eedabd..faaf9061e 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/group/ConsumerGroupReadToolHandlersTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/group/ConsumerGroupReadToolHandlersTest.java
@@ -288,6 +288,49 @@ class ConsumerGroupReadToolHandlersTest {
         assertThat(output.instances()).isEmpty();
     }
 
+    /**
+     * The provider leaves {@code delaySeconds} at its zero default when it 
cannot compute a delay and
+     * records that fact in {@code consumptionTimestampAvailable}. Publishing 
the zero tells the model
+     * the group is fully caught up, so an unmeasurable delay must not carry a 
number at all - the
+     * sibling lag field publishes its -1 sentinel and the health ladder 
answers UNKNOWN for the same
+     * class of "unknown collapsed into a measurement".
+     */
+    @Test
+    void detailOmitsTheDelayWhenNoConsumedMessageTimestampIsAvailableTest() {
+        group.setOnlineInstances(1);
+        group.setTotalLag(0L);
+        group.setConsumptionTimestampAvailable(false);
+        group.setDelaySeconds(0);
+        when(metadataService.consumerGroupRuntimeView("instance-a", 
"group-a")).thenReturn(group);
+        when(metadataService.consumerGroupConfigurations("instance-a", 
"group-a")).thenReturn(List.of(group));
+        when(metadataService.getGroupSubscriptions("instance-a", 
"group-a")).thenReturn(List.of());
+        when(metadataService.getGroupProgress("instance-a", 
"group-a")).thenReturn(List.of());
+
+        GroupDetailOutput output = new GroupDetailToolHandler(metadataService)
+                .execute(new GroupDetailInput("instance-a", "group-a", null), 
context());
+        JsonNode detail = new 
LegacyJackson2Config().jackson2ObjectMapper().valueToTree(output);
+
+        assertThat(detail.has("delaySeconds")).isFalse();
+    }
+
+    @Test
+    void detailKeepsTheDelayWhenTheConsumedMessageTimestampIsAvailableTest() {
+        group.setOnlineInstances(1);
+        group.setTotalLag(0L);
+        group.setConsumptionTimestampAvailable(true);
+        group.setDelaySeconds(42);
+        when(metadataService.consumerGroupRuntimeView("instance-a", 
"group-a")).thenReturn(group);
+        when(metadataService.consumerGroupConfigurations("instance-a", 
"group-a")).thenReturn(List.of(group));
+        when(metadataService.getGroupSubscriptions("instance-a", 
"group-a")).thenReturn(List.of());
+        when(metadataService.getGroupProgress("instance-a", 
"group-a")).thenReturn(List.of());
+
+        GroupDetailOutput output = new GroupDetailToolHandler(metadataService)
+                .execute(new GroupDetailInput("instance-a", "group-a", null), 
context());
+        JsonNode detail = new 
LegacyJackson2Config().jackson2ObjectMapper().valueToTree(output);
+
+        assertThat(detail.path("delaySeconds").asInt()).isEqualTo(42);
+    }
+
     private static ToolExecutionContext context() {
         return ToolExecutionContext.of(
                 "instance-a", null, Map.of("instanceId", "instance-a"));
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConvertersBodyEncodingTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConvertersBodyEncodingTest.java
index be7145463..203541fe7 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConvertersBodyEncodingTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConvertersBodyEncodingTest.java
@@ -49,6 +49,8 @@ class AliyunConvertersBodyEncodingTest {
 
         assertThat(record.getBody()).isEqualTo("{\"orderId\":42}");
         assertThat(record.getBodyEncoding()).isEqualTo("UTF-8");
+        // ListMessages without BodySize reports the unknown sentinel, not a 
fabricated zero.
+        assertThat(record.getSize()).isEqualTo(MessageRecordVO.UNKNOWN_SIZE);
     }
 
     @Test
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProviderTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProviderTest.java
index 656da42a7..7c0a0d48f 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProviderTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProviderTest.java
@@ -1014,6 +1014,23 @@ class TencentInstanceProviderTest {
         assertThat(captor.getValue().getMsgId()).isEqualTo("MSG-1");
     }
 
+    @Test
+    void queryMessagesByMsgIdShouldReportUnknownSizeTest() throws Exception {
+        DescribeMessageResponse detail = new DescribeMessageResponse();
+        detail.setMessageId("MSG-1");
+        detail.setShowTopicName("orders");
+        detail.setBody("hello body");
+        detail.setProduceTime("2024-09-12 14:06:55,591");
+        when(client.DescribeMessage(any())).thenReturn(detail);
+
+        List<MessageRecordVO> messages =
+                provider.queryMessages(STUDIO_INSTANCE_ID, "orders", "MSG-1", 
null, null, null, null);
+
+        // DescribeMessage carries no body size; the record must not claim a 
fabricated zero.
+        assertThat(messages).singleElement()
+                .satisfies(record -> 
assertThat(record.getSize()).isEqualTo(MessageRecordVO.UNKNOWN_SIZE));
+    }
+
     @Test
     void getMessageTraceShouldPreserveOffsetsForAllTimestampedStagesTest() 
throws Exception {
         MessageTraceItem produce = new MessageTraceItem();
diff --git a/web/src/api/message.ts b/web/src/api/message.ts
index b1d7dd2b7..39205d872 100644
--- a/web/src/api/message.ts
+++ b/web/src/api/message.ts
@@ -20,6 +20,7 @@ export interface MessageRecord {
   reconsumeTimes?: number;
   properties: Record<string, string>;
   propertiesTruncated?: boolean;
+  /** Body size in bytes; -1 when the vendor API does not report one (unknown, 
not zero). */
   size: number;
 }
 
diff --git a/web/src/components/QueueBrowser.tsx 
b/web/src/components/QueueBrowser.tsx
index 7a583292b..87deed2ab 100644
--- a/web/src/components/QueueBrowser.tsx
+++ b/web/src/components/QueueBrowser.tsx
@@ -36,7 +36,7 @@ import type { MessageRecord, QueueOffset } from 
'../api/message';
 import { getQueueOffsets, pullMessageAtOffset } from '../api/message';
 import { useLang } from '../i18n/LangContext';
 import { tableScrollX } from '../utils/table';
-import { formatBytes, formatTimeMs } from '../utils/format';
+import { formatOptionalBytes, formatTimeMs } from '../utils/format';
 
 const { Text } = Typography;
 
@@ -372,7 +372,7 @@ export const QueueBrowserResults = ({ state, onOpenMessage 
}: QueueBrowserResult
       align: 'right',
       render: (_: unknown, entry: PulledEntry) => (
         <span style={{ fontSize: 14 }}>
-          {entry.message ? formatBytes(entry.message.size) : '-'}
+          {entry.message ? formatOptionalBytes(entry.message.size) : '-'}
         </span>
       ),
     },
diff --git a/web/src/pages/instance/__tests__/MessagePage.test.tsx 
b/web/src/pages/instance/__tests__/MessagePage.test.tsx
index e1188bf63..1f430dc28 100644
--- a/web/src/pages/instance/__tests__/MessagePage.test.tsx
+++ b/web/src/pages/instance/__tests__/MessagePage.test.tsx
@@ -24,6 +24,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, 
it, vi } from 'vite
 import type { MessageRecord } from '../../../api/message';
 import { LangProvider } from '../../../i18n/LangContext';
 import * as downloadUtils from '../../../utils/download';
+import { UNKNOWN_MESSAGE_SIZE } from '../../../utils/format';
 
 const messageServiceMocks = vi.hoisted(() => ({
   consumeMessageDirectly: vi.fn(),
@@ -923,4 +924,30 @@ describe('Message page query history', () => {
     // snap back and silently reuse the previous window. It must not be 
offered.
     expect(container.querySelector('.ant-picker-clear')).toBeNull();
   });
+
+  it('renders an unknown message size as unavailable instead of 0 B', async () 
=> {
+    const user = userEvent.setup();
+    messageServiceMocks.queryMessages.mockResolvedValue([
+      { ...createMessage('MID-UNKNOWN-SIZE'), size: UNKNOWN_MESSAGE_SIZE },
+    ]);
+    renderWithProviders(<MessagePage />);
+
+    await user.click(lastElement(screen.getAllByRole('combobox')));
+    await user.click(lastElement(await screen.findAllByText('order-create')));
+    await user.click(screen.getByRole('button', { name: /^search查询$/ }));
+    const row = await screen.findByRole('row', { name: /MID-UNKNOWN-SIZE/ });
+    // -1 is the server's unknown sentinel: the size column must not claim a 
fabricated
+    // "0 B", and the sentinel itself must not leak into the table either.
+    expect(within(row).queryByText('0 B')).not.toBeInTheDocument();
+    expect(within(row).queryByText('-1 B')).not.toBeInTheDocument();
+    await user.click(within(row).getByRole('button', { name: /详情/ }));
+
+    const dialog = await screen.findByRole('dialog', { name: '消息详情' });
+    const sizeItems = within(dialog)
+      .getAllByText('大小')
+      .map((label) => label.closest('.ant-descriptions-item'));
+    expect(sizeItems).toHaveLength(1);
+    const content = 
sizeItems[0]?.querySelector('.ant-descriptions-item-content');
+    expect(content?.textContent?.trim()).toBe('-');
+  });
 });
diff --git a/web/src/pages/instance/message.tsx 
b/web/src/pages/instance/message.tsx
index 75064eaea..1fa14f6c7 100644
--- a/web/src/pages/instance/message.tsx
+++ b/web/src/pages/instance/message.tsx
@@ -79,7 +79,7 @@ import { useInstanceFilter } from 
'../../hooks/useInstanceFilter';
 import useAuthStore from '../../stores/authStore';
 import { downloadBlob } from '../../utils/download';
 import { describeThrownMessage } from '../../utils/apiError';
-import { formatBytes, formatOptionalTimeMs, formatTimeMs } from 
'../../utils/format';
+import { formatOptionalBytes, formatOptionalTimeMs, formatTimeMs } from 
'../../utils/format';
 import {
   readMessageTraceTopic,
   writeMessageTraceTopic,
@@ -904,7 +904,7 @@ const MessagePageContent = ({
       // 「12.3 MB」实测 48px + 左右 padding 16px;原来给 80 是把宽度让给了溢出的操作列。
       width: 68,
       align: 'right',
-      render: (size: number) => formatBytes(size),
+      render: (size: number) => formatOptionalBytes(size),
     },
     {
       title: t('common.actions'),
@@ -1027,7 +1027,7 @@ const MessagePageContent = ({
               <span style={{ fontFamily: 'monospace' 
}}>{selectedMsg.key}</span>
             </Descriptions.Item>
             <Descriptions.Item label={t('messagePage.size')}>
-              {formatBytes(selectedMsg.size)}
+              {formatOptionalBytes(selectedMsg.size)}
             </Descriptions.Item>
             <Descriptions.Item label={t('messagePage.reconsumeTimes')}>
               <span style={{ fontFamily: 'monospace' 
}}>{selectedMsg.reconsumeTimes ?? '-'}</span>
diff --git a/web/src/utils/format.test.ts b/web/src/utils/format.test.ts
index 8e7b992b0..6cc438881 100644
--- a/web/src/utils/format.test.ts
+++ b/web/src/utils/format.test.ts
@@ -1,12 +1,14 @@
 // SPDX-License-Identifier: Apache-2.0
 import { describe, expect, it } from 'vitest';
 import {
+  UNKNOWN_MESSAGE_SIZE,
   formatBytes,
   formatDate,
   formatDateTime,
   formatUtcDateTime,
   formatDelay,
   formatNumber,
+  formatOptionalBytes,
   formatPercent,
   formatRelativeTime,
   formatTimeOfDay,
@@ -121,6 +123,17 @@ describe('formatBytes', () => {
   });
 });
 
+describe('formatOptionalBytes', () => {
+  it('renders the unknown-size sentinel as the placeholder instead of -1 B', 
() => {
+    expect(formatOptionalBytes(UNKNOWN_MESSAGE_SIZE)).toBe('-');
+  });
+
+  it('keeps a measured zero as a real byte count', () => {
+    expect(formatOptionalBytes(0)).toBe('0 B');
+    expect(formatOptionalBytes(1536)).toBe('1.5 KB');
+  });
+});
+
 describe('parseAlertTimestamp', () => {
   it('parses an offset-less alert timestamp as UTC regardless of the browser 
zone', () => {
     // Expressed against Date.parse with an explicit Z so the expectation 
cannot drift with TZ.
diff --git a/web/src/utils/format.ts b/web/src/utils/format.ts
index 1f4f9cc95..8252092d1 100644
--- a/web/src/utils/format.ts
+++ b/web/src/utils/format.ts
@@ -301,3 +301,15 @@ export function ratioToPercent(value: number): number {
 export function formatOptionalTimeMs(value: number | string | null | 
undefined): string {
   return value === 0 || value === '0' ? '-' : formatTimeMs(value);
 }
+
+/** The server's sentinel for a message body size the vendor API does not 
report: unknown, not zero. */
+export const UNKNOWN_MESSAGE_SIZE = -1;
+
+/**
+ * Message records report "no body size" as {@link UNKNOWN_MESSAGE_SIZE}. 
{@link formatBytes}
+ * deliberately formats negatives as real byte counts, so the message-size 
call sites map the
+ * sentinel to the placeholder themselves.
+ */
+export function formatOptionalBytes(bytes: number): string {
+  return bytes < 0 ? '-' : formatBytes(bytes);
+}

Reply via email to