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 e45708056 fix(group): preview offset resets for offline consumer 
groups (#5291)
e45708056 is described below

commit e457080569850efb7d7de374bbaa30a24701536f
Author: Apulupie <[email protected]>
AuthorDate: Tue Oct 27 15:46:50 2026 +0800

    fix(group): preview offset resets for offline consumer groups (#5291)
    
    * fix(group): preview the offset reset from the topic route for stopped 
groups
    
    The preview called examineConsumeStats and, on CONSUMER_NOT_ONLINE, 
returned an
    empty preview with allowReset=false - so a stopped group could never be 
previewed
    even though POST /api/groups/reset-offset (which does not validate a 
preview)
    reset it regardless. In rocketmq-tools 5.5.0, resetOffsetNew calls
    resetOffsetByTimestamp(force=true) and, on response code 206, falls back to
    resetOffsetByTimestampOld(force=true), which reads the broker offset store 
and
    writes searchOffset() for each queue of the topic route. The preview now 
walks
    that same route and issues the same per-queue search, so an offline group 
sees
    the targets the reset will actually commit.
    
    Broadcast groups (213) and groups that never consumed (17 on 
%RETRY%<group>) are
    still blocked up front and keep returning the empty, non-resettable preview.
    
    * fix(group): keep the offline reset preview free of the -1 sentinel
    
    Current offsets genuinely cannot be read for a group that is not connected, 
so the
    lag and delta fields carry the same unknown sentinels the online path 
already
    uses. They must not reach user-facing prose: the tool plan read "projected 
lag
    changes from -1 to 500", and the Chinese description column echoed an 
English
    sentence built around a delta. Both now render an explicit unavailable 
state, and
    the aggregate net-delta field renders '-' when any queue had no current 
offset,
    matching what the per-queue column already did.
    
    Disclosure - this unblocks two gates derived from the preview. 
rmq.group.reset_offset
    is riskLevel L2 with permission group:write and requires a confirm_token 
that only a
    successful preview() can mint, and the UI's confirm-reset button keys off 
the same
    allowReset, so an offline group could not confirm a reset through either 
path before.
    No new capability is granted: the REST endpoint never validated a preview, 
and the
    server-side execution path has used resetOffsetNew since #3310. The gates 
themselves
    are unchanged - !complete || !allowReset still throws 409, and any queue 
that fails
    to preview still marks the whole preview incomplete. The plan gains a 
warning that
    current offsets are unavailable so deltas cannot be computed.
    
    Known residual case, not introduced here: a broadcast group that is also 
offline
    raises 206 before the broadcast check can run, so it reaches the offline 
preview and
    is let through. resetOffsetNew then writes broker-side offsets that 
broadcast
    consumers never read - identical to `mqadmin resetOffsetByTime` on the same 
input,
    and already reachable through the REST endpoint. The subscription mode 
lives on the
    client while a group is offline, so the server cannot rule this out; the 
warning
    hands the uncertainty to whoever confirms the reset.
---
 .../handler/group/GroupResetOffsetToolHandler.java |  19 +-
 .../provider/apache/RocketMQAdminClientImpl.java   | 195 ++++++++++++++++++++-
 .../group/GroupResetOffsetToolHandlerTest.java     |  62 +++++++
 .../apache/RocketMQAdminClientImplTest.java        | 111 ++++++++++++
 web/src/i18n/translations.ts                       |   9 +
 .../pages/instance/__tests__/ConsumerPage.test.tsx |  84 +++++++++
 web/src/pages/instance/consumer.tsx                |  43 +++--
 7 files changed, 505 insertions(+), 18 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/handler/group/GroupResetOffsetToolHandler.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/handler/group/GroupResetOffsetToolHandler.java
index 6b2c5f0bf..78fa26f03 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/handler/group/GroupResetOffsetToolHandler.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/handler/group/GroupResetOffsetToolHandler.java
@@ -66,8 +66,8 @@ public class GroupResetOffsetToolHandler extends 
MutationToolHandler<GroupResetO
                 .before(OffsetState.current(input, preview))
                 .after(OffsetState.proposed(input, preview, timestamp))
                 .impact("Moves offsets for " + preview.getQueueCount()
-                        + " queues; projected lag changes from " + 
preview.getCurrentTotalLag()
-                        + " to " + preview.getProjectedTotalLag() + ".")
+                        + " queues; projected lag changes from " + 
lagPhrase(preview.getCurrentTotalLag())
+                        + " to " + lagPhrase(preview.getProjectedTotalLag()) + 
".")
                 .warnings(warnings)
                 .build();
     }
@@ -108,6 +108,12 @@ public class GroupResetOffsetToolHandler extends 
MutationToolHandler<GroupResetO
         }
     }
 
+    /**
+     * One queue's offsets. {@code offset} and {@code lag} carry the {@code 
-1} sentinel the preview
+     * reports for a value the broker could not determine — an offline group 
has no current offset,
+     * so its "before" side is {@code -1} on both fields. Structured output 
keeps the sentinel the
+     * way {@code GroupListItem} documents its own, and {@link #lagPhrase} 
keeps it out of prose.
+     */
     private record QueueState(String topic, String broker, int queueId, long 
offset, long lag) {
 
         static QueueState current(ResetConsumerOffsetQueuePreviewVO queue) {
@@ -120,4 +126,13 @@ public class GroupResetOffsetToolHandler extends 
MutationToolHandler<GroupResetO
                     queue.getTargetOffset(), queue.getProjectedLag());
         }
     }
+
+    /**
+     * Renders a lag for the plan prose. The {@code -1} "cannot be determined" 
sentinel must never
+     * be interpolated as a number, or the plan reads "projected lag changes 
from -1 to 500";
+     * {@code unavailable} is the wording {@code GroupDetailToolHandler} 
already uses for it.
+     */
+    private static String lagPhrase(long lag) {
+        return lag < 0 ? "unavailable" : Long.toString(lag);
+    }
 }
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 e7fb3378b..ca6016f58 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
@@ -26,6 +26,7 @@ import org.apache.rocketmq.client.producer.SendStatus;
 import org.apache.rocketmq.common.TopicConfig;
 import org.apache.rocketmq.common.MixAll;
 import org.apache.rocketmq.common.TopicAttributes;
+import org.apache.rocketmq.common.constant.PermName;
 import org.apache.rocketmq.common.message.Message;
 import org.apache.rocketmq.common.message.MessageAccessor;
 import org.apache.rocketmq.common.message.MessageConst;
@@ -34,6 +35,8 @@ import org.apache.rocketmq.common.message.MessageQueue;
 import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
 import org.apache.rocketmq.remoting.protocol.ResponseCode;
 import org.apache.rocketmq.remoting.protocol.route.BrokerData;
+import org.apache.rocketmq.remoting.protocol.route.QueueData;
+import org.apache.rocketmq.remoting.protocol.route.TopicRouteData;
 import 
org.apache.rocketmq.remoting.protocol.subscription.SubscriptionGroupConfig;
 import org.apache.rocketmq.studio.cluster.broker.MqAdminExtFactory;
 import org.apache.rocketmq.studio.cluster.broker.MqClientPool;
@@ -91,6 +94,12 @@ public class RocketMQAdminClientImpl implements AdminClient {
 
     private static final int MAX_MESSAGE_SIZE = 4 * 1024 * 1024; // 4 MB 
default broker limit
     private static final long RESET_OFFSET_PREVIEW_TIMEOUT_MILLIS = 3_000L;
+    /**
+     * Warning every offset-reset preview path carries when the broker 
reported no consume offset:
+     * the group is not connected. The paths differ in what they can still 
compute from it.
+     */
+    private static final String OFFLINE_GROUP_PREVIEW_WARNING =
+            "Consumer group is not online and no consume offset data is 
available";
     private static final String RISK_INFO = "INFO";
     private static final String RISK_WARNING = "WARNING";
     private static final String RISK_ERROR = "ERROR";
@@ -1070,14 +1079,173 @@ public class RocketMQAdminClientImpl implements 
AdminClient {
                     .queues(queues)
                     .build();
         } catch (Exception e) {
-            if (isConsumerNotOnline(e)) {
+            if (isBroadcastOrNeverConsumedGroup(e)) {
+                // Broadcast groups keep offsets per consumer and a group with 
no %RETRY% route
+                // has never consumed: there is nothing meaningful to preview, 
so keep blocking.
                 return emptyResetOffsetPreview(instanceId, name, timestamp, 
topic,
-                        "Consumer group is not online and no consume offset 
data is available");
+                        OFFLINE_GROUP_PREVIEW_WARNING);
+            }
+            if (isConsumerNotOnline(e)) {
+                // CONSUMER_NOT_ONLINE only means the broker-side offset table 
came back empty for
+                // a group that is not connected; resetOffsetNew still applies 
to it, so preview
+                // from the topic route instead of permanently blocking the 
reset.
+                return offlineResetOffsetPreview(instanceId, admin, name, 
timestamp, topic);
             }
             throw new BusinessException(500, "Failed to preview reset offset: 
" + e.getMessage());
         }
     }
 
+    /**
+     * Grades the two "no data at all" exceptions {@code examineConsumeStats} 
raises as still
+     * blocking, so they do not reach {@link #offlineResetOffsetPreview}.
+     *
+     * <p>{@code BROADCAST_CONSUMPTION} marks a broadcast group: its offsets 
live in each client,
+     * so a broker-side write would not reset anything the group reads. {@code 
TOPIC_NOT_EXIST} on
+     * {@code %RETRY%<group>} means rocketmq-tools never resolved the group, 
i.e. it has never
+     * consumed, and creating broker-side offsets for it is not a preview.
+     */
+    private static boolean isBroadcastOrNeverConsumedGroup(Exception 
exception) {
+        if (MqResponseCodes.hasResponseCode(exception, 
ResponseCode.BROADCAST_CONSUMPTION)) {
+            return true;
+        }
+        String message = exception.getMessage();
+        return MqResponseCodes.hasResponseCode(exception, 
ResponseCode.TOPIC_NOT_EXIST)
+                && message != null && message.contains("%RETRY%");
+    }
+
+    /**
+     * Preview for a group that is not connected, mirroring what {@code 
resetOffsetNew} will do to
+     * it. {@code MQAdminExt.resetOffsetNew(group, topic, timestamp)} 
(rocketmq-tools 5.5.0) calls
+     * {@code resetOffsetByTimestamp} with {@code force = true} and, on
+     * {@code MQClientException(CONSUMER_NOT_ONLINE)}, falls back to
+     * {@code resetOffsetByTimestampOld(group, topic, timestamp, true)}. That 
fallback reads the
+     * broker offset store rather than the live consumers and, when it finds 
no consume stats for
+     * the topic, enumerates {@code queueData.getReadQueueNums()} queues of 
the topic route and
+     * writes {@code searchOffset(brokerAddr, queue, timestamp)} to each one 
unconditionally
+     * ({@code force}). This preview therefore walks the same route and the 
same per-queue search,
+     * so an offline group sees the targets the reset will actually commit.
+     *
+     * <p>Current consumer offsets are genuinely unavailable offline, so the 
lag and delta fields
+     * carry the {@link ConsumerLagResolver#UNKNOWN} / neutral sentinels the 
online path already
+     * uses and the preview adds an explicit offline warning; presentation 
layers render those
+     * sentinels as "unavailable" instead of as numbers. Falls back to the 
empty preview when the
+     * topic route is unavailable too.
+     */
+    private ResetConsumerOffsetPreviewVO offlineResetOffsetPreview(String 
instanceId, MQAdminExt admin,
+                                                                   String 
name, long timestamp,
+                                                                   String 
topic) {
+        List<ResetConsumerOffsetQueuePreviewVO> queues = new ArrayList<>();
+        try {
+            TopicRouteData route = admin.examineTopicRouteInfo(topic);
+            if (route != null && route.getQueueDatas() != null) {
+                for (QueueData queueData : route.getQueueDatas()) {
+                    // resetOffsetByTimestampOld enumerates the read queues of 
every routed broker;
+                    // a queue the route does not expose for reading is never 
consumed from, so the
+                    // reset cannot move it and the preview must not claim a 
target for it.
+                    if (queueData == null || 
!PermName.isReadable(queueData.getPerm())) {
+                        continue;
+                    }
+                    for (int queueId = 0; queueId < 
queueData.getReadQueueNums(); queueId++) {
+                        queues.add(offlineResetOffsetQueue(admin,
+                                new MessageQueue(topic, 
queueData.getBrokerName(), queueId), timestamp));
+                    }
+                }
+            }
+        } catch (Exception routeFailure) {
+            return emptyResetOffsetPreview(instanceId, name, timestamp, topic,
+                    OFFLINE_GROUP_PREVIEW_WARNING
+                            + "; the topic route is also unavailable: " + 
routeFailure.getMessage());
+        }
+        if (queues.isEmpty()) {
+            return emptyResetOffsetPreview(instanceId, name, timestamp, topic,
+                    OFFLINE_GROUP_PREVIEW_WARNING + "; no readable queues 
found for topic " + topic);
+        }
+        queues.sort(Comparator
+                .comparing((ResetConsumerOffsetQueuePreviewVO queue) ->
+                                queue.getBroker() == null ? "" : 
queue.getBroker(),
+                        String.CASE_INSENSITIVE_ORDER)
+                
.thenComparingInt(ResetConsumerOffsetQueuePreviewVO::getQueueId));
+        long currentTotalLag = ConsumerLagResolver.UNKNOWN;
+        long projectedTotalLag = aggregateResetPreviewLag(queues, true);
+        // Delta is unavailable offline (no current offset), so no queue is 
classified as a rewind
+        // or a fast-forward; the warnings only carry the unavailable-offset 
and reset-extent notes.
+        List<String> warnings = new 
ArrayList<>(buildResetOffsetPreviewWarnings(queues, 0, 0));
+        warnings.add(OFFLINE_GROUP_PREVIEW_WARNING
+                + "; current offsets are unavailable, so offset deltas cannot 
be computed");
+        boolean complete = queues.stream().noneMatch(queue -> 
RISK_ERROR.equals(queue.getRiskLevel()));
+        return ResetConsumerOffsetPreviewVO.builder()
+                .instanceId(instanceId)
+                .groupName(name)
+                .topic(topic)
+                .timestamp(timestamp)
+                .complete(complete)
+                .allowReset(complete)
+                .queueCount(queues.size())
+                .warningCount(warnings.size())
+                .rewindQueueCount(0)
+                .fastForwardQueueCount(0)
+                .currentTotalLag(currentTotalLag)
+                .projectedTotalLag(projectedTotalLag)
+                .totalOffsetDelta(0L)
+                .warnings(warnings)
+                .queues(queues)
+                .build();
+    }
+
+    /**
+     * Computes the target {@code resetOffsetNew} commits for one routed queue 
of an offline group:
+     * {@code searchOffset(brokerAddr, queue, timestamp)}, clamped to the 
retained range the same way
+     * {@link #previewResetOffsetQueue} clamps it. The broker maximum is the 
broker offset (nothing
+     * is being consumed), so the projected lag is real; the current offset 
and lag stay
+     * {@link ConsumerLagResolver#UNKNOWN} because no consumer reported them.
+     */
+    private ResetConsumerOffsetQueuePreviewVO 
offlineResetOffsetQueue(MQAdminExt admin, MessageQueue queue,
+                                                                      long 
timestamp) {
+        try {
+            long minOffset = admin.minOffset(queue);
+            long maxOffset = admin.maxOffset(queue);
+            String brokerAddr = resolveBrokerAddress(admin, 
queue.getBrokerName());
+            long targetOffset = admin.searchOffset(brokerAddr, 
queue.getTopic(), queue.getQueueId(),
+                    timestamp, RESET_OFFSET_PREVIEW_TIMEOUT_MILLIS);
+            targetOffset = clampOffset(targetOffset, minOffset, maxOffset);
+            return ResetConsumerOffsetQueuePreviewVO.builder()
+                    .topic(queue.getTopic())
+                    .broker(queue.getBrokerName())
+                    .queueId(queue.getQueueId())
+                    .minOffset(minOffset)
+                    .maxOffset(maxOffset)
+                    .brokerOffset(maxOffset)
+                    .consumerOffset(ConsumerLagResolver.UNKNOWN)
+                    .targetOffset(targetOffset)
+                    .currentLag(ConsumerLagResolver.UNKNOWN)
+                    .projectedLag(resolveLag(maxOffset, targetOffset))
+                    .offsetDelta(0L)
+                    .riskLevel(resetOffsetRiskLevel(0, targetOffset, 
minOffset, maxOffset))
+                    // Never splice resetOffsetPreviewMessage in here: its 
first clause states an
+                    // offset delta, and the delta is unavailable offline, 
which read as
+                    // "moves the offset to Offset unchanged".
+                    .message("Current offset unavailable (group offline); 
target offset " + targetOffset
+                            + resetOffsetExtentNote(targetOffset, minOffset, 
maxOffset))
+                    .build();
+        } catch (Exception e) {
+            return ResetConsumerOffsetQueuePreviewVO.builder()
+                    .topic(queue.getTopic())
+                    .broker(queue.getBrokerName())
+                    .queueId(queue.getQueueId())
+                    .minOffset(-1L)
+                    .maxOffset(-1L)
+                    .brokerOffset(-1L)
+                    .consumerOffset(ConsumerLagResolver.UNKNOWN)
+                    .targetOffset(-1L)
+                    .currentLag(ConsumerLagResolver.UNKNOWN)
+                    .projectedLag(ConsumerLagResolver.UNKNOWN)
+                    .offsetDelta(0L)
+                    .riskLevel(RISK_ERROR)
+                    .message("Failed to preview queue offset: " + 
e.getMessage())
+                    .build();
+        }
+    }
+
     private ResetConsumerOffsetQueuePreviewVO 
previewResetOffsetQueue(MQAdminExt admin, MessageQueue queue,
                                                                        
OffsetWrapper wrapper, long timestamp) {
         long brokerOffset = wrapper == null ? 0L : wrapper.getBrokerOffset();
@@ -1232,13 +1400,30 @@ public class RocketMQAdminClientImpl implements 
AdminClient {
         } else {
             messages.add("Offset unchanged");
         }
+        messages.addAll(resetOffsetExtentNotes(targetOffset, minOffset, 
maxOffset));
+        return String.join("; ", messages);
+    }
+
+    /**
+     * Notes stating that the target sits on an end of the retained range. 
Shared by the online
+     * message, whose first clause is the offset delta, and the offline 
message, which has no delta
+     * to state and therefore must not borrow one.
+     */
+    private List<String> resetOffsetExtentNotes(long targetOffset, long 
minOffset, long maxOffset) {
+        List<String> notes = new ArrayList<>();
         if (targetOffset == minOffset) {
-            messages.add("target is the minimum retained offset");
+            notes.add("target is the minimum retained offset");
         }
         if (targetOffset == maxOffset) {
-            messages.add("target is the latest offset");
+            notes.add("target is the latest offset");
         }
-        return String.join("; ", messages);
+        return notes;
+    }
+
+    /** The extent notes as a message suffix; empty when the target is inside 
the retained range. */
+    private String resetOffsetExtentNote(long targetOffset, long minOffset, 
long maxOffset) {
+        List<String> notes = resetOffsetExtentNotes(targetOffset, minOffset, 
maxOffset);
+        return notes.isEmpty() ? "" : "; " + String.join("; ", notes);
     }
 
     @Override
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/group/GroupResetOffsetToolHandlerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/group/GroupResetOffsetToolHandlerTest.java
index b3d9cdd00..816813500 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/group/GroupResetOffsetToolHandlerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/group/GroupResetOffsetToolHandlerTest.java
@@ -6,13 +6,17 @@
  */
 package org.apache.rocketmq.studio.ops.ai.tool.handler.group;
 
+import java.util.List;
 import java.util.Map;
 
 import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.instance.group.ResetConsumerOffsetPreviewVO;
+import 
org.apache.rocketmq.studio.instance.group.ResetConsumerOffsetQueuePreviewVO;
 import org.apache.rocketmq.studio.instance.topic.MetadataService;
 import org.apache.rocketmq.studio.ops.ai.tool.core.ToolExecutionContext;
 import 
org.apache.rocketmq.studio.ops.ai.tool.contract.group.GroupResetOffsetInput;
 import org.apache.rocketmq.studio.ops.ai.tool.contract.group.ResetOffsetOutput;
+import org.apache.rocketmq.studio.ops.ai.tool.contract.plan.ToolPlan;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.junit.jupiter.params.ParameterizedTest;
@@ -26,6 +30,7 @@ import static 
org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
 
 @ExtendWith(MockitoExtension.class)
 class GroupResetOffsetToolHandlerTest {
@@ -100,4 +105,61 @@ class GroupResetOffsetToolHandlerTest {
                 .satisfies(error -> assertThat(((BusinessException) 
error).getCode()).isEqualTo(400));
         verifyNoInteractions(metadataService);
     }
+
+    @Test
+    void previewShouldRenderAnUnavailableLagInsteadOfTheSentinelTest() {
+        // An offline group has no current offset, so the preview reports the 
-1 "cannot be
+        // determined" sentinel for the current lag. The plan prose must never 
interpolate it, or
+        // the operator is told "projected lag changes from -1 to 500".
+        GroupResetOffsetInput input = new GroupResetOffsetInput(
+                "untrusted-instance", "group-1", "TopicA", 1700000000000L);
+        ToolExecutionContext execution = ToolExecutionContext.of(
+                "instance-a", null, Map.of("instanceId", 
"untrusted-instance"));
+        ResetConsumerOffsetPreviewVO preview = 
ResetConsumerOffsetPreviewVO.builder()
+                .instanceId("instance-a")
+                .groupName("group-1")
+                .topic("TopicA")
+                .timestamp(1700000000000L)
+                .complete(true)
+                .allowReset(true)
+                .queueCount(1)
+                .warningCount(1)
+                .currentTotalLag(-1L)
+                .projectedTotalLag(500L)
+                .totalOffsetDelta(0L)
+                .warnings(List.of(
+                        "Consumer group is not online and no consume offset 
data is available"
+                                + "; current offsets are unavailable, so 
offset deltas cannot be computed"))
+                .queues(List.of(ResetConsumerOffsetQueuePreviewVO.builder()
+                        .topic("TopicA")
+                        .broker("broker-a")
+                        .queueId(0)
+                        .minOffset(10L)
+                        .maxOffset(600L)
+                        .brokerOffset(600L)
+                        .consumerOffset(-1L)
+                        .targetOffset(100L)
+                        .currentLag(-1L)
+                        .projectedLag(500L)
+                        .offsetDelta(0L)
+                        .riskLevel("INFO")
+                        .message("Current offset unavailable (group offline); 
target offset 100")
+                        .build()))
+                .build();
+        when(metadataService.previewResetOffset("instance-a", "group-1", 
1700000000000L, "TopicA"))
+                .thenReturn(preview);
+
+        ToolPlan plan = handler.preview(input, execution);
+
+        assertThat(plan.impact()).containsExactly(
+                "Moves offsets for 1 queues; projected lag changes from 
unavailable to 500.");
+        assertThat(plan.impact()).noneMatch(line -> line.contains("-1"));
+        assertThat(plan.warnings()).containsExactly(
+                "Consumer group is not online and no consume offset data is 
available"
+                        + "; current offsets are unavailable, so offset deltas 
cannot be computed");
+        // The structured before/after state keeps the documented sentinel 
rather than a fabricated
+        // zero, so the caller can still tell "unknown" from "measured as 
nothing".
+        assertThat(((Number) 
plan.before().get("totalLag")).longValue()).isEqualTo(-1L);
+        assertThat(((Number) 
plan.after().get("totalLag")).longValue()).isEqualTo(500L);
+    }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImplTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImplTest.java
index dbe9c512a..79476ca96 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImplTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImplTest.java
@@ -101,6 +101,7 @@ import 
org.apache.rocketmq.studio.common.domain.enums.TopicPerm;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.anyBoolean;
 import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.ArgumentMatchers.anyString;
@@ -531,6 +532,116 @@ class RocketMQAdminClientImplTest {
                 anyLong(), anyBoolean());
     }
 
+    @Test
+    void previewResetOffsetAllowsOfflineGroupFromTopicRouteTest() throws 
Exception {
+        // An offline group has no consume stats, but resetOffsetNew still 
applies offline: the
+        // preview must enumerate the topic route and allow the reset instead 
of blocking it.
+        long timestamp = 1784246400000L;
+        when(adminExt.examineConsumeStats("cg-orders"))
+                .thenThrow(new 
MQClientException(ResponseCode.CONSUMER_NOT_ONLINE,
+                        "Not found the consumer group connection"));
+        QueueData queueData = new QueueData();
+        queueData.setBrokerName("broker-1");
+        queueData.setReadQueueNums(2);
+        queueData.setPerm(6);
+        TopicRouteData routeData = new TopicRouteData();
+        routeData.setQueueDatas(List.of(queueData));
+        when(adminExt.examineTopicRouteInfo("orders")).thenReturn(routeData);
+        
when(adminExt.examineBrokerClusterInfo()).thenReturn(clusterInfoWithMaster());
+        when(adminExt.minOffset(any(MessageQueue.class))).thenReturn(10L);
+        when(adminExt.maxOffset(any(MessageQueue.class))).thenReturn(100L);
+        when(adminExt.searchOffset(eq("10.0.0.1:10911"), eq("orders"), 
anyInt(), eq(timestamp), anyLong()))
+                .thenReturn(50L);
+
+        ResetConsumerOffsetPreviewVO preview = adminClient.previewResetOffset(
+                "instance-a", "cg-orders", timestamp, "orders");
+
+        assertThat(preview.isAllowReset()).isTrue();
+        assertThat(preview.isComplete()).isTrue();
+        assertThat(preview.getQueueCount()).isEqualTo(2);
+        
assertThat(preview.getCurrentTotalLag()).isEqualTo(ConsumerLagResolver.UNKNOWN);
+        assertThat(preview.getProjectedTotalLag()).isEqualTo(100L);
+        assertThat(preview.getWarnings()).anyMatch(w -> w.contains("not 
online"));
+        assertThat(preview.getQueues())
+                .extracting(ResetConsumerOffsetQueuePreviewVO::getQueueId,
+                        ResetConsumerOffsetQueuePreviewVO::getTargetOffset,
+                        ResetConsumerOffsetQueuePreviewVO::getCurrentLag)
+                .containsExactly(
+                        org.assertj.core.groups.Tuple.tuple(0, 50L, 
ConsumerLagResolver.UNKNOWN),
+                        org.assertj.core.groups.Tuple.tuple(1, 50L, 
ConsumerLagResolver.UNKNOWN));
+    }
+
+    @Test
+    void 
previewResetOffsetReportsUnavailableCurrentOffsetForOfflineGroupTest() throws 
Exception {
+        // The offline preview must not splice the delta-based online message 
into its own: with no
+        // current offset there is no delta, and borrowing that clause read as 
the broken
+        // "the reset moves the offset to Offset unchanged".
+        long timestamp = 1784246400000L;
+        when(adminExt.examineConsumeStats("cg-orders"))
+                .thenThrow(new 
MQClientException(ResponseCode.CONSUMER_NOT_ONLINE,
+                        "Not found the consumer group connection"));
+        QueueData queueData = new QueueData();
+        queueData.setBrokerName("broker-1");
+        queueData.setReadQueueNums(1);
+        queueData.setPerm(6);
+        TopicRouteData routeData = new TopicRouteData();
+        routeData.setQueueDatas(List.of(queueData));
+        when(adminExt.examineTopicRouteInfo("orders")).thenReturn(routeData);
+        
when(adminExt.examineBrokerClusterInfo()).thenReturn(clusterInfoWithMaster());
+        when(adminExt.minOffset(any(MessageQueue.class))).thenReturn(10L);
+        when(adminExt.maxOffset(any(MessageQueue.class))).thenReturn(50L);
+        when(adminExt.searchOffset(eq("10.0.0.1:10911"), eq("orders"), 
anyInt(), eq(timestamp), anyLong()))
+                .thenReturn(900L);
+
+        ResetConsumerOffsetPreviewVO preview = adminClient.previewResetOffset(
+                "instance-a", "cg-orders", timestamp, "orders");
+
+        // searchOffset answered 900, beyond the retained range, so the target 
is clamped to the
+        // broker maximum and the message names which end of the range it 
landed on.
+        assertThat(preview.getQueues()).singleElement().satisfies(row -> {
+            assertThat(row.getTargetOffset()).isEqualTo(50L);
+            
assertThat(row.getConsumerOffset()).isEqualTo(ConsumerLagResolver.UNKNOWN);
+            
assertThat(row.getCurrentLag()).isEqualTo(ConsumerLagResolver.UNKNOWN);
+            assertThat(row.getProjectedLag()).isZero();
+            assertThat(row.getMessage()).isEqualTo(
+                    "Current offset unavailable (group offline); target offset 
50; "
+                            + "target is the latest offset");
+            assertThat(row.getMessage()).doesNotContain("Offset unchanged");
+        });
+        assertThat(preview.getWarnings()).anySatisfy(warning -> {
+            assertThat(warning).contains("not online");
+            assertThat(warning).contains("current offsets are unavailable");
+            assertThat(warning).doesNotContain("-1");
+        });
+    }
+
+    @Test
+    void previewResetOffsetSkipsUnreadableQueuesForOfflineGroupTest() throws 
Exception {
+        // resetOffsetByTimestampOld walks the read queues of the route; a 
write-only queue is never
+        // consumed from, so the preview must not invent a target the reset 
will not commit.
+        long timestamp = 1784246400000L;
+        when(adminExt.examineConsumeStats("cg-orders"))
+                .thenThrow(new 
MQClientException(ResponseCode.CONSUMER_NOT_ONLINE,
+                        "Not found the consumer group connection"));
+        QueueData writeOnly = new QueueData();
+        writeOnly.setBrokerName("broker-1");
+        writeOnly.setReadQueueNums(2);
+        writeOnly.setPerm(2);
+        TopicRouteData routeData = new TopicRouteData();
+        routeData.setQueueDatas(List.of(writeOnly));
+        when(adminExt.examineTopicRouteInfo("orders")).thenReturn(routeData);
+
+        ResetConsumerOffsetPreviewVO preview = adminClient.previewResetOffset(
+                "instance-a", "cg-orders", timestamp, "orders");
+
+        assertThat(preview.isAllowReset()).isFalse();
+        assertThat(preview.getQueueCount()).isZero();
+        assertThat(preview.getWarnings())
+                .containsExactly("Consumer group is not online and no consume 
offset data is available"
+                        + "; no readable queues found for topic orders");
+        verify(adminExt, never()).searchOffset(anyString(), anyString(), 
anyInt(), anyLong(), anyLong());
+    }
+
     @Test
     void previewResetOffsetShouldPreserveUnknownCurrentLagTest() throws 
Exception {
         long timestamp = 1784246400000L;
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index f244ec420..3ef910a1e 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -2587,6 +2587,15 @@ const translations: Record<string, Record<Lang, string>> 
= {
   'consumer.viewDistribution': { zh: '查看分布', en: 'View Distribution' },
   'consumer.resetToTime': { zh: '重置到指定时间', en: 'Reset to Time' },
   'consumer.resetSuccess': { zh: '位点重置成功', en: 'Offset reset successfully' },
+  'consumer.resetPreview.previewFailed': { zh: '预览失败', en: 'Preview failed' },
+  'consumer.resetPreview.targetOffsetUnavailable': {
+    zh: '目标位点不可用',
+    en: 'Target offset unavailable',
+  },
+  'consumer.resetPreview.currentOffsetUnavailable': {
+    zh: '消费组离线,当前位点不可用;重置将按目标时间定位',
+    en: 'Group offline, current offset unavailable; the reset targets the 
searched offset',
+  },
   'consumer.fetchListFailed': {
     zh: '消费组列表加载失败,请稍后重试',
     en: 'Failed to load consumer groups. Please try again later.',
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx 
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index 365c28a70..038661ddf 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -1228,6 +1228,90 @@ describe('Consumer page', () => {
     expect(consumerService.resetConsumerOffset).not.toHaveBeenCalled();
   });
 
+  it('previews an offline group without leaking the unknown sentinel', async 
() => {
+    vi.mocked(consumerService.previewConsumerOffsetReset).mockResolvedValue({
+      instanceId: 'instance-1',
+      groupName: 'remote-cg',
+      topic: 'remote-topic',
+      timestamp: 1784246400000,
+      complete: true,
+      allowReset: true,
+      queueCount: 1,
+      warningCount: 2,
+      rewindQueueCount: 0,
+      fastForwardQueueCount: 0,
+      currentTotalLag: -1,
+      projectedTotalLag: 50,
+      totalOffsetDelta: 0,
+      warnings: [
+        'At least one queue has unavailable lag; affected backlog totals are 
unavailable',
+        'Consumer group is not online and no consume offset data is available; 
' +
+          'current offsets are unavailable, so offset deltas cannot be 
computed',
+      ],
+      queues: [
+        {
+          topic: 'remote-topic',
+          broker: 'broker-a',
+          queueId: 0,
+          minOffset: 10,
+          maxOffset: 100,
+          brokerOffset: 100,
+          // The group is offline: the broker still reports the target, but no 
consumer reported a
+          // current offset, so the backend sends the -1 sentinel for the 
current offset and lag.
+          consumerOffset: -1,
+          targetOffset: 50,
+          currentLag: -1,
+          projectedLag: 50,
+          offsetDelta: 0,
+          riskLevel: 'INFO',
+          message: 'Current offset unavailable (group offline); target offset 
50',
+        },
+      ],
+    });
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderWithProviders(<ConsumerPage />);
+
+    await user.click(await screen.findByRole('button', { name: /重置位点/ }));
+    await user.click(screen.getByRole('combobox', { name: '目标 Topic' }));
+    const option = await waitFor(() => {
+      const element = screen
+        .getAllByText('remote-topic')
+        .find((candidate) => 
candidate.classList.contains('ant-select-item-option-content'));
+      if (!element) throw new Error('Missing target Topic option');
+      return element;
+    });
+    await user.click(option);
+    await user.click(screen.getByRole('button', { name: /预览影响/ }));
+
+    // The 说明 column speaks the page's own language; the English server 
message is built around a
+    // delta the offline preview cannot compute, so it must not be echoed here.
+    expect(
+      await screen.findByText('消费组离线,当前位点不可用;重置将按目标时间定位'),
+    ).toBeInTheDocument();
+    expect(
+      screen.queryByText('Current offset unavailable (group offline); target 
offset 50'),
+    ).not.toBeInTheDocument();
+
+    const dialog = screen.getByRole('dialog');
+    const row = within(dialog)
+      .getAllByRole('row')
+      .find((candidate) => within(candidate).queryByText('broker-a'));
+    expect(row).toBeDefined();
+    const cells = within(row!)
+      .getAllByRole('cell')
+      .map((cell) => cell.textContent?.trim());
+    // 当前位点 / 变化 / 当前堆积 render the sentinel as '-', the way the consumer 
progress table does.
+    expect(cells.filter((cell) => cell === '-')).toHaveLength(3);
+    expect(cells.join('|')).not.toContain('-1');
+    // The aggregate net delta is not a measurement either, so the summary 
must not show 0.
+    const netChangeLabel = within(dialog).getByText('位点净变化');
+    const netChange = (netChangeLabel.closest('th') ?? 
netChangeLabel).nextElementSibling;
+    expect(netChange?.textContent?.trim()).toBe('-');
+
+    await waitFor(() => expect(screen.getByRole('button', { name: '确认重置' 
})).toBeEnabled());
+    expect(consumerService.resetConsumerOffset).not.toHaveBeenCalled();
+  });
+
   it('reloads same-named group diagnostics after changing the selected 
instance', async () => {
     vi.mocked(instanceService.listInstances).mockResolvedValue([
       {
diff --git a/web/src/pages/instance/consumer.tsx 
b/web/src/pages/instance/consumer.tsx
index 92b59ba5b..fbc314ece 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -194,15 +194,23 @@ const issueSeverityLabel = (severity: 
ConsumerGroupHealthIssue['severity']) => {
   return '提示';
 };
 
-const resetPreviewQueueMessage = (queue: ResetConsumerOffsetQueuePreview) => {
+const resetPreviewQueueMessage = (
+  queue: ResetConsumerOffsetQueuePreview,
+  t: (key: string) => string,
+) => {
   const messages: string[] = [];
   if (queue.riskLevel === 'ERROR') {
-    return queue.message || '预览失败';
+    return queue.message || t('consumer.resetPreview.previewFailed');
   }
-  if (queue.targetOffset < 0 || queue.consumerOffset < 0) {
-    return queue.message || '目标位点不可用';
+  if (queue.targetOffset < 0) {
+    return t('consumer.resetPreview.targetOffsetUnavailable');
   }
-  if (queue.offsetDelta < 0) {
+  if (queue.consumerOffset < 0) {
+    // The group is offline: the broker still reports the target, but no 
consumer reported a
+    // current offset, so the delta is not a measurement. Never fall back to 
queue.message here —
+    // it is English prose built around a delta and would read as a broken 
sentence in this column.
+    messages.push(t('consumer.resetPreview.currentOffsetUnavailable'));
+  } else if (queue.offsetDelta < 0) {
     messages.push(`将回放 ${Math.abs(queue.offsetDelta).toLocaleString()} 条消息`);
   } else if (queue.offsetDelta > 0) {
     messages.push(`将跳过 ${queue.offsetDelta.toLocaleString()} 条未消费消息`);
@@ -218,6 +226,14 @@ const resetPreviewQueueMessage = (queue: 
ResetConsumerOffsetQueuePreview) => {
   return messages.join(';');
 };
 
+/**
+ * True when any previewed queue has no current offset the broker could report 
(an offline group),
+ * which makes the aggregate net delta a fabricated zero rather than a 
measurement. The per-queue
+ * 变化 column already renders such rows as '-', so the summary must not claim a 
number either.
+ */
+const hasUnavailableCurrentOffset = (queues: 
ResetConsumerOffsetQueuePreview[]) =>
+  queues.some((queue) => queue.consumerOffset < 0 || queue.targetOffset < 0);
+
 // Shared helper exported alongside the page component; fast-refresh rule 
waived.
 // eslint-disable-next-line react-refresh/only-export-components
 export const diagnosticCacheKey = (instanceId: string | undefined, groupName: 
string) =>
@@ -1487,11 +1503,14 @@ const ConsumerPageContent = ({
       key: 'message',
       width: 240,
       ellipsis: true,
-      render: (_: unknown, record: ResetConsumerOffsetQueuePreview) => (
-        <Text style={{ fontSize: 14 }} 
title={resetPreviewQueueMessage(record)}>
-          {resetPreviewQueueMessage(record)}
-        </Text>
-      ),
+      render: (_: unknown, record: ResetConsumerOffsetQueuePreview) => {
+        const message = resetPreviewQueueMessage(record, t);
+        return (
+          <Text style={{ fontSize: 14 }} title={message}>
+            {message}
+          </Text>
+        );
+      },
     },
   ];
 
@@ -2811,7 +2830,9 @@ const ConsumerPageContent = ({
                     {formatOffsetValue(resetPreview.projectedTotalLag)}
                   </Descriptions.Item>
                   <Descriptions.Item label="位点净变化">
-                    {formatOffsetDelta(resetPreview.totalOffsetDelta)}
+                    {hasUnavailableCurrentOffset(resetPreviewQueues)
+                      ? '-'
+                      : formatOffsetDelta(resetPreview.totalOffsetDelta)}
                   </Descriptions.Item>
                   <Descriptions.Item label="回放 Queue">
                     {resetPreview.rewindQueueCount}

Reply via email to