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 d3469e2c3 feat(group): preview consumer offset reset impact before
applying (#2816)
d3469e2c3 is described below
commit d3469e2c3b9c26eb30538af0d48a7a722785c8fc
Author: coder999o <[email protected]>
AuthorDate: Mon Aug 31 21:15:17 2026 +0800
feat(group): preview consumer offset reset impact before applying (#2816)
---
.../instance/group/ConsumerGroupController.java | 7 +
.../group/ResetConsumerOffsetPreviewVO.java | 46 +++
.../group/ResetConsumerOffsetQueuePreviewVO.java | 42 ++
.../studio/instance/topic/MetadataService.java | 9 +
.../rocketmq/studio/provider/InstanceProvider.java | 48 +++
.../studio/provider/apache/AdminClient.java | 2 +
.../provider/apache/ApacheInstanceProvider.java | 7 +
.../provider/apache/RocketMQAdminClientImpl.java | 214 +++++++++++
.../group/ConsumerGroupControllerTest.java | 59 +++
.../studio/instance/topic/MetadataServiceTest.java | 38 ++
.../alibaba/AliyunInstanceProviderTest.java | 32 ++
.../apache/RocketMQAdminClientImplTest.java | 115 ++++++
.../tencent/TencentInstanceProviderTest.java | 24 ++
web/src/api/consumerGroups.test.ts | 49 +++
web/src/api/metadata.ts | 42 ++
.../pages/instance/__tests__/ConsumerPage.test.tsx | 155 +++++++-
web/src/pages/instance/consumer.tsx | 427 ++++++++++++++++++---
web/src/services/consumerService.test.ts | 63 +++
web/src/services/consumerService.ts | 100 +++++
19 files changed, 1421 insertions(+), 58 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/group/ConsumerGroupController.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/group/ConsumerGroupController.java
index 8c965bede..99d727c27 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/group/ConsumerGroupController.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/group/ConsumerGroupController.java
@@ -157,4 +157,11 @@ public class ConsumerGroupController {
.distinct()
.toList();
}
+
+ @PostMapping("/reset-offset/preview")
+ public Result<ResetConsumerOffsetPreviewVO> previewResetOffset(@Valid
@RequestBody ResetConsumerOffsetDTO request) {
+ return
Result.ok(metadataService.previewResetOffset(request.getInstanceId(),
request.getName(),
+ request.getTimestamp(), request.getTopic()));
+
+ }
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/group/ResetConsumerOffsetPreviewVO.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/group/ResetConsumerOffsetPreviewVO.java
new file mode 100644
index 000000000..5533b09bb
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/group/ResetConsumerOffsetPreviewVO.java
@@ -0,0 +1,46 @@
+/*
+ * 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.group;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ResetConsumerOffsetPreviewVO {
+ private String instanceId;
+ private String groupName;
+ private String topic;
+ private long timestamp;
+ private boolean complete;
+ private boolean allowReset;
+ private int queueCount;
+ private int warningCount;
+ private int rewindQueueCount;
+ private int fastForwardQueueCount;
+ private long currentTotalLag;
+ private long projectedTotalLag;
+ private long totalOffsetDelta;
+ private List<String> warnings;
+ private List<ResetConsumerOffsetQueuePreviewVO> queues;
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/group/ResetConsumerOffsetQueuePreviewVO.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/group/ResetConsumerOffsetQueuePreviewVO.java
new file mode 100644
index 000000000..e37ae26c3
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/group/ResetConsumerOffsetQueuePreviewVO.java
@@ -0,0 +1,42 @@
+/*
+ * 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.group;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ResetConsumerOffsetQueuePreviewVO {
+ private String topic;
+ private String broker;
+ private int queueId;
+ private long minOffset;
+ private long maxOffset;
+ private long brokerOffset;
+ private long consumerOffset;
+ private long targetOffset;
+ private long currentLag;
+ private long projectedLag;
+ private long offsetDelta;
+ private String riskLevel;
+ private String message;
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
index f482401a4..3dee45338 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
@@ -34,6 +34,7 @@ import org.springframework.util.StringUtils;
import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO;
import org.apache.rocketmq.studio.instance.group.ConsumerGroupSettingsVO;
import org.apache.rocketmq.studio.instance.group.QueueProgressVO;
+import org.apache.rocketmq.studio.instance.group.ResetConsumerOffsetPreviewVO;
import org.apache.rocketmq.studio.instance.group.SubscriptionEntryVO;
import org.apache.rocketmq.studio.provider.InstanceProvider;
import org.apache.rocketmq.studio.provider.InstanceProviderRegistry;
@@ -317,6 +318,14 @@ public class MetadataService {
resetOffset(null, name, timestamp, topic);
}
+ public ResetConsumerOffsetPreviewVO previewResetOffset(String instanceId,
String name,
+ long timestamp,
String topic) {
+ instanceId = normalizeInstanceId(instanceId);
+ String groupName = requireName(name, "consumer group name");
+ String topicName = requireName(topic, "topic name");
+ return resolve(instanceId).previewResetOffset(instanceId, groupName,
timestamp, topicName);
+ }
+
public void resetOffset(String instanceId, String name, long timestamp,
String topic) {
instanceId = normalizeInstanceId(instanceId);
String groupName = requireName(name, "consumer group name");
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/InstanceProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/InstanceProvider.java
index e67849761..f12d3fcf7 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/provider/InstanceProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/InstanceProvider.java
@@ -22,6 +22,8 @@ import
org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.common.util.Pagination;
import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO;
import org.apache.rocketmq.studio.instance.group.QueueProgressVO;
+import org.apache.rocketmq.studio.instance.group.ResetConsumerOffsetPreviewVO;
+import
org.apache.rocketmq.studio.instance.group.ResetConsumerOffsetQueuePreviewVO;
import org.apache.rocketmq.studio.instance.group.SubscriptionEntryVO;
import org.apache.rocketmq.studio.instance.message.MessageRecordVO;
import org.apache.rocketmq.studio.instance.message.DirectConsumeMessageDTO;
@@ -33,6 +35,7 @@ import org.apache.rocketmq.studio.instance.topic.TopicVO;
import java.util.List;
import java.util.Set;
+import java.util.ArrayList;
/**
* Unified instance-scoped operations SPI. Every method takes the Studio
instance id as its
@@ -105,6 +108,51 @@ public interface InstanceProvider {
List<SubscriptionEntryVO> getGroupSubscriptions(String instanceId, String
groupName);
+ default ResetConsumerOffsetPreviewVO previewResetOffset(String instanceId,
String groupName,
+ long timestamp,
String topic) {
+ List<QueueProgressVO> progressRows = getGroupProgress(instanceId,
groupName);
+ List<ResetConsumerOffsetQueuePreviewVO> queues = new ArrayList<>();
+ for (QueueProgressVO progress : progressRows) {
+ if (progress == null || !topic.equals(progress.getTopic())) {
+ continue;
+ }
+ queues.add(ResetConsumerOffsetQueuePreviewVO.builder()
+ .topic(progress.getTopic())
+ .broker(progress.getBroker())
+ .queueId(progress.getQueueId())
+ .minOffset(-1L)
+ .maxOffset(-1L)
+ .brokerOffset(progress.getBrokerOffset())
+ .consumerOffset(progress.getConsumerOffset())
+ .targetOffset(-1L)
+ .currentLag(progress.getDiffTotal())
+ .projectedLag(-1L)
+ .offsetDelta(0L)
+ .riskLevel("WARNING")
+ .message("Provider does not expose target offset preview;
current lag is shown before reset")
+ .build());
+ }
+ List<String> warnings = List.of(
+ "Provider does not expose per-queue target offset preview;
confirm with current lag only");
+ return ResetConsumerOffsetPreviewVO.builder()
+ .instanceId(instanceId)
+ .groupName(groupName)
+ .topic(topic)
+ .timestamp(timestamp)
+ .complete(false)
+ .allowReset(!queues.isEmpty())
+ .queueCount(queues.size())
+ .warningCount(warnings.size())
+ .rewindQueueCount(0)
+ .fastForwardQueueCount(0)
+
.currentTotalLag(queues.stream().mapToLong(ResetConsumerOffsetQueuePreviewVO::getCurrentLag).sum())
+ .projectedTotalLag(-1L)
+ .totalOffsetDelta(0L)
+ .warnings(warnings)
+ .queues(queues)
+ .build();
+ }
+
void resetOffset(String instanceId, String groupName, long timestamp,
String topic);
List<MessageRecordVO> queryMessages(String instanceId, String topic,
String msgId,
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/AdminClient.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/AdminClient.java
index 36ecea467..1c4f91c29 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/AdminClient.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/AdminClient.java
@@ -21,6 +21,7 @@ import
org.apache.rocketmq.studio.instance.topic.SendMessageDTO;
import org.apache.rocketmq.studio.instance.topic.TopicVO;
import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO;
import org.apache.rocketmq.studio.instance.group.ConsumerGroupSettingsVO;
+import org.apache.rocketmq.studio.instance.group.ResetConsumerOffsetPreviewVO;
public interface AdminClient {
@@ -35,5 +36,6 @@ public interface AdminClient {
ConsumerGroupSettingsVO updateConsumerGroupSettings(String instanceId,
String name, int retryQueueNums,
int retryMaxTimes);
void deleteConsumerGroup(String instanceId, String name);
+ ResetConsumerOffsetPreviewVO previewResetOffset(String instanceId, String
name, long timestamp, String topic);
void resetOffset(String instanceId, String name, long timestamp, String
topic);
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/ApacheInstanceProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/ApacheInstanceProvider.java
index 49c32786a..d80d1fba2 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/ApacheInstanceProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/ApacheInstanceProvider.java
@@ -21,6 +21,7 @@ import
org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
import org.apache.rocketmq.studio.instance.InstanceRepository;
import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO;
import org.apache.rocketmq.studio.instance.group.QueueProgressVO;
+import org.apache.rocketmq.studio.instance.group.ResetConsumerOffsetPreviewVO;
import org.apache.rocketmq.studio.instance.group.SubscriptionEntryVO;
import org.apache.rocketmq.studio.instance.message.MessageProvider;
import org.apache.rocketmq.studio.instance.message.DirectConsumeMessageDTO;
@@ -148,6 +149,12 @@ public class ApacheInstanceProvider implements
InstanceProvider {
return metadataProvider.getGroupSubscriptions(instanceId, groupName);
}
+ @Override
+ public ResetConsumerOffsetPreviewVO previewResetOffset(String instanceId,
String groupName,
+ long timestamp,
String topic) {
+ return adminClient.previewResetOffset(instanceId, groupName,
timestamp, topic);
+ }
+
@Override
public void resetOffset(String instanceId, String groupName, long
timestamp, String topic) {
adminClient.resetOffset(instanceId, groupName, timestamp, topic);
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 db403c947..cd1ae9aa3 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.TopicAttributes;
import org.apache.rocketmq.common.message.Message;
+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;
@@ -38,6 +39,8 @@ import
org.apache.rocketmq.studio.common.domain.enums.TopicPerm;
import org.apache.rocketmq.studio.common.domain.enums.TopicType;
import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO;
import org.apache.rocketmq.studio.instance.group.ConsumerGroupSettingsVO;
+import org.apache.rocketmq.studio.instance.group.ResetConsumerOffsetPreviewVO;
+import
org.apache.rocketmq.studio.instance.group.ResetConsumerOffsetQueuePreviewVO;
import org.apache.rocketmq.studio.instance.topic.SendMessageDTO;
import org.apache.rocketmq.studio.instance.topic.SendMessageVO;
import org.apache.rocketmq.studio.instance.topic.TopicVO;
@@ -57,7 +60,9 @@ import lombok.extern.slf4j.Slf4j;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.ArrayList;
+import java.util.Comparator;
import java.util.HashSet;
+import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -73,6 +78,10 @@ public class RocketMQAdminClientImpl implements AdminClient {
private static final int MAX_MESSAGE_SIZE = 4 * 1024 * 1024; // 4 MB
default broker limit
private static final String LEGACY_METADATA_SCOPE = "";
+ private static final long RESET_OFFSET_PREVIEW_TIMEOUT_MILLIS = 3_000L;
+ private static final String RISK_INFO = "INFO";
+ private static final String RISK_WARNING = "WARNING";
+ private static final String RISK_ERROR = "ERROR";
private final MqAdminExtFactory adminFactory;
private final RocketMQProperties properties;
@@ -660,6 +669,211 @@ public class RocketMQAdminClientImpl implements
AdminClient {
}
}
+ @Override
+ public ResetConsumerOffsetPreviewVO previewResetOffset(String instanceId,
String name,
+ long timestamp,
String topic) {
+ if (!StringUtils.hasText(topic)) {
+ throw new BusinessException(400, "topic is required for offset
reset preview");
+ }
+ String topicName = topic.trim();
+ try {
+ return executeForInstance(instanceId, admin ->
doPreviewResetOffset(
+ instanceId, admin, name, timestamp, topicName));
+ } catch (BusinessException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new BusinessException(500, "Failed to preview reset offset:
" + e.getMessage());
+ }
+ }
+
+ private ResetConsumerOffsetPreviewVO doPreviewResetOffset(String
instanceId, MQAdminExt admin,
+ String name,
long timestamp, String topic) {
+ try {
+ ConsumeStats stats = admin.examineConsumeStats(name);
+ if (stats == null || stats.getOffsetTable() == null ||
stats.getOffsetTable().isEmpty()) {
+ return emptyResetOffsetPreview(instanceId, name, timestamp,
topic,
+ "No consume offset data found for consumer group " +
name);
+ }
+
+ List<ResetConsumerOffsetQueuePreviewVO> queues = new ArrayList<>();
+ for (Map.Entry<MessageQueue, OffsetWrapper> entry :
stats.getOffsetTable().entrySet()) {
+ MessageQueue queue = entry.getKey();
+ if (queue == null || !topic.equals(queue.getTopic())) {
+ continue;
+ }
+ queues.add(previewResetOffsetQueue(admin, queue,
entry.getValue(), timestamp));
+ }
+ queues.sort(Comparator
+ .comparing((ResetConsumerOffsetQueuePreviewVO queue) ->
+ queue.getBroker() == null ? "" :
queue.getBroker(),
+ String.CASE_INSENSITIVE_ORDER)
+
.thenComparingInt(ResetConsumerOffsetQueuePreviewVO::getQueueId));
+ if (queues.isEmpty()) {
+ return emptyResetOffsetPreview(instanceId, name, timestamp,
topic,
+ "No consume offset data found for topic " + topic);
+ }
+
+ long currentTotalLag =
queues.stream().mapToLong(ResetConsumerOffsetQueuePreviewVO::getCurrentLag).sum();
+ long projectedTotalLag = queues.stream()
+
.mapToLong(ResetConsumerOffsetQueuePreviewVO::getProjectedLag)
+ .sum();
+ long totalOffsetDelta =
queues.stream().mapToLong(ResetConsumerOffsetQueuePreviewVO::getOffsetDelta).sum();
+ int rewindQueueCount = (int) queues.stream().filter(queue ->
queue.getOffsetDelta() < 0).count();
+ int fastForwardQueueCount = (int) queues.stream().filter(queue ->
queue.getOffsetDelta() > 0).count();
+ List<String> warnings = buildResetOffsetPreviewWarnings(queues,
rewindQueueCount, fastForwardQueueCount);
+ 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(rewindQueueCount)
+ .fastForwardQueueCount(fastForwardQueueCount)
+ .currentTotalLag(currentTotalLag)
+ .projectedTotalLag(projectedTotalLag)
+ .totalOffsetDelta(totalOffsetDelta)
+ .warnings(warnings)
+ .queues(queues)
+ .build();
+ } catch (Exception e) {
+ if (isConsumerNotOnline(e)) {
+ return emptyResetOffsetPreview(instanceId, name, timestamp,
topic,
+ "Consumer group is not online and no consume offset
data is available");
+ }
+ throw new BusinessException(500, "Failed to preview reset offset:
" + e.getMessage());
+ }
+ }
+
+ private ResetConsumerOffsetQueuePreviewVO
previewResetOffsetQueue(MQAdminExt admin, MessageQueue queue,
+
OffsetWrapper wrapper, long timestamp) {
+ long brokerOffset = wrapper == null ? 0L : wrapper.getBrokerOffset();
+ long consumerOffset = wrapper == null ? 0L :
wrapper.getConsumerOffset();
+ long currentLag = resolveLag(brokerOffset, consumerOffset);
+ try {
+ long minOffset = admin.minOffset(queue);
+ long maxOffset = admin.maxOffset(queue);
+ long targetOffset = admin.searchOffset(queue.getBrokerName(),
queue.getTopic(), queue.getQueueId(),
+ timestamp, RESET_OFFSET_PREVIEW_TIMEOUT_MILLIS);
+ targetOffset = clampOffset(targetOffset, minOffset, maxOffset);
+ long offsetDelta = targetOffset - consumerOffset;
+ long projectedLag = resolveLag(brokerOffset, targetOffset);
+ return ResetConsumerOffsetQueuePreviewVO.builder()
+ .topic(queue.getTopic())
+ .broker(queue.getBrokerName())
+ .queueId(queue.getQueueId())
+ .minOffset(minOffset)
+ .maxOffset(maxOffset)
+ .brokerOffset(brokerOffset)
+ .consumerOffset(consumerOffset)
+ .targetOffset(targetOffset)
+ .currentLag(currentLag)
+ .projectedLag(projectedLag)
+ .offsetDelta(offsetDelta)
+ .riskLevel(resetOffsetRiskLevel(offsetDelta, targetOffset,
minOffset, maxOffset))
+ .message(resetOffsetPreviewMessage(offsetDelta,
targetOffset, minOffset, maxOffset))
+ .build();
+ } catch (Exception e) {
+ return ResetConsumerOffsetQueuePreviewVO.builder()
+ .topic(queue.getTopic())
+ .broker(queue.getBrokerName())
+ .queueId(queue.getQueueId())
+ .minOffset(-1L)
+ .maxOffset(-1L)
+ .brokerOffset(brokerOffset)
+ .consumerOffset(consumerOffset)
+ .targetOffset(consumerOffset)
+ .currentLag(currentLag)
+ .projectedLag(currentLag)
+ .offsetDelta(0L)
+ .riskLevel(RISK_ERROR)
+ .message("Failed to preview queue offset: " +
e.getMessage())
+ .build();
+ }
+ }
+
+ private ResetConsumerOffsetPreviewVO emptyResetOffsetPreview(String
instanceId, String name,
+ long
timestamp, String topic, String warning) {
+ return ResetConsumerOffsetPreviewVO.builder()
+ .instanceId(instanceId)
+ .groupName(name)
+ .topic(topic)
+ .timestamp(timestamp)
+ .complete(false)
+ .allowReset(false)
+ .queueCount(0)
+ .warningCount(1)
+ .rewindQueueCount(0)
+ .fastForwardQueueCount(0)
+ .currentTotalLag(0)
+ .projectedTotalLag(0)
+ .totalOffsetDelta(0)
+ .warnings(List.of(warning))
+ .queues(List.of())
+ .build();
+ }
+
+ private List<String>
buildResetOffsetPreviewWarnings(List<ResetConsumerOffsetQueuePreviewVO> queues,
+ int rewindQueueCount,
int fastForwardQueueCount) {
+ List<String> warnings = new ArrayList<>();
+ long failedQueueCount = queues.stream().filter(queue ->
RISK_ERROR.equals(queue.getRiskLevel())).count();
+ if (failedQueueCount > 0) {
+ warnings.add("Failed to preview " + failedQueueCount + " queue(s);
retry before applying the reset");
+ }
+ if (fastForwardQueueCount > 0) {
+ warnings.add(fastForwardQueueCount + " queue(s) will move forward
and may skip unconsumed messages");
+ }
+ if (rewindQueueCount > 0) {
+ warnings.add(rewindQueueCount + " queue(s) will move backward and
may replay consumed messages");
+ }
+ if (queues.stream().anyMatch(queue -> queue.getMinOffset() >= 0
+ && queue.getTargetOffset() == queue.getMinOffset())) {
+ warnings.add("At least one queue will reset to the minimum
retained offset");
+ }
+ if (queues.stream().anyMatch(queue -> queue.getMaxOffset() >= 0
+ && queue.getTargetOffset() == queue.getMaxOffset())) {
+ warnings.add("At least one queue will reset to the latest offset");
+ }
+ return warnings;
+ }
+
+ private long resolveLag(long brokerOffset, long consumerOffset) {
+ return Math.max(0L, brokerOffset - consumerOffset);
+ }
+
+ private long clampOffset(long offset, long minOffset, long maxOffset) {
+ return Math.max(minOffset, Math.min(offset, maxOffset));
+ }
+
+ private String resetOffsetRiskLevel(long offsetDelta, long targetOffset,
long minOffset, long maxOffset) {
+ if (offsetDelta != 0 || targetOffset == minOffset || targetOffset ==
maxOffset) {
+ return RISK_WARNING;
+ }
+ return RISK_INFO;
+ }
+
+ private String resetOffsetPreviewMessage(long offsetDelta, long
targetOffset, long minOffset, long maxOffset) {
+ List<String> messages = new ArrayList<>();
+ if (offsetDelta < 0) {
+ messages.add("Replays " + Math.abs(offsetDelta) + " message(s)");
+ } else if (offsetDelta > 0) {
+ messages.add("Skips " + offsetDelta + " unconsumed message(s)");
+ } else {
+ messages.add("Offset unchanged");
+ }
+ if (targetOffset == minOffset) {
+ messages.add("target is the minimum retained offset");
+ }
+ if (targetOffset == maxOffset) {
+ messages.add("target is the latest offset");
+ }
+ return String.join("; ", messages);
+ }
+
@Override
public void resetOffset(String instanceId, String name, long timestamp,
String topic) {
if (!StringUtils.hasText(topic)) {
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/group/ConsumerGroupControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/group/ConsumerGroupControllerTest.java
index 04a23a7f7..736dd3136 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/group/ConsumerGroupControllerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/group/ConsumerGroupControllerTest.java
@@ -319,6 +319,65 @@ class ConsumerGroupControllerTest {
verify(metadataService).resetOffset(eq("instance-a"), eq("cg-orders"),
eq(1784246400000L), eq("orders"));
}
+ @Test
+ void previewResetOffsetShouldPassValidatedRequestAndReturnQueueImpact()
throws Exception {
+ Map<String, Object> body = Map.of(
+ "instanceId", "instance-a",
+ "name", "cg-orders",
+ "topic", "orders",
+ "timestamp", 1784246400000L
+ );
+ ResetConsumerOffsetQueuePreviewVO queue =
ResetConsumerOffsetQueuePreviewVO.builder()
+ .topic("orders")
+ .broker("broker-a")
+ .queueId(0)
+ .minOffset(0L)
+ .maxOffset(200L)
+ .brokerOffset(120L)
+ .consumerOffset(90L)
+ .targetOffset(80L)
+ .currentLag(30L)
+ .projectedLag(40L)
+ .offsetDelta(-10L)
+ .riskLevel("WARNING")
+ .message("Replays 10 message(s)")
+ .build();
+ ResetConsumerOffsetPreviewVO preview =
ResetConsumerOffsetPreviewVO.builder()
+ .instanceId("instance-a")
+ .groupName("cg-orders")
+ .topic("orders")
+ .timestamp(1784246400000L)
+ .complete(true)
+ .allowReset(true)
+ .queueCount(1)
+ .warningCount(1)
+ .rewindQueueCount(1)
+ .fastForwardQueueCount(0)
+ .currentTotalLag(30L)
+ .projectedTotalLag(40L)
+ .totalOffsetDelta(-10L)
+ .warnings(List.of("1 queue(s) will move backward and may
replay consumed messages"))
+ .queues(List.of(queue))
+ .build();
+ when(metadataService.previewResetOffset("instance-a", "cg-orders",
1784246400000L, "orders"))
+ .thenReturn(preview);
+
+ mockMvc.perform(post("/api/groups/reset-offset/preview")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(body)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.groupName").value("cg-orders"))
+ .andExpect(jsonPath("$.data.allowReset").value(true))
+ .andExpect(jsonPath("$.data.queueCount").value(1))
+ .andExpect(jsonPath("$.data.projectedTotalLag").value(40))
+ .andExpect(jsonPath("$.data.queues[0].targetOffset").value(80))
+
.andExpect(jsonPath("$.data.queues[0].riskLevel").value("WARNING"));
+
+ verify(metadataService).previewResetOffset(eq("instance-a"),
eq("cg-orders"),
+ eq(1784246400000L), eq("orders"));
+ }
+
@Test
void deleteConsumerGroupShouldReturnSuccess() throws Exception {
mockMvc.perform(post("/api/groups/delete")
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
index 82207638a..ab6205bce 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
@@ -28,6 +28,8 @@ import
org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO;
import org.apache.rocketmq.studio.instance.group.CreateConsumerGroupDTO;
import org.apache.rocketmq.studio.instance.group.ImportConsumerGroupsResultVO;
+import org.apache.rocketmq.studio.instance.group.ResetConsumerOffsetPreviewVO;
+
import org.apache.rocketmq.studio.provider.InstanceProvider;
import org.apache.rocketmq.studio.provider.InstanceProviderRegistry;
import org.apache.rocketmq.studio.provider.apache.AdminClient;
@@ -609,6 +611,41 @@ class MetadataServiceTest {
verifyNoInteractions(apacheProvider);
}
+ @Test
+ void previewResetOffsetShouldNormalizeAndDelegateToProvider() {
+ ResetConsumerOffsetPreviewVO preview =
ResetConsumerOffsetPreviewVO.builder()
+ .instanceId("instance-a")
+ .groupName("cg-orders")
+ .topic("orders")
+ .timestamp(1784246400000L)
+ .complete(true)
+ .allowReset(true)
+ .queueCount(0)
+ .warnings(List.of())
+ .queues(List.of())
+ .build();
+ when(apacheProvider.previewResetOffset("instance-a", "cg-orders",
1784246400000L, "orders"))
+ .thenReturn(preview);
+
+ ResetConsumerOffsetPreviewVO result =
metadataService.previewResetOffset(
+ "instance-a", " cg-orders ", 1784246400000L, " orders ");
+
+ assertThat(result).isSameAs(preview);
+ verify(apacheProvider).previewResetOffset("instance-a", "cg-orders",
1784246400000L, "orders");
+ }
+
+ @Test
+ void previewResetOffsetShouldRejectBlankTopicBeforeProviderResolution() {
+ assertThatThrownBy(() ->
metadataService.previewResetOffset("instance-a", "cg-orders",
+ 1784246400000L, " "))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("topic name is required")
+ .satisfies(error -> assertThat(((BusinessException)
error).getCode()).isEqualTo(400));
+
+
+ verifyNoInteractions(apacheProvider);
+ }
+
private ConsumerGroupVO consumerGroup(String name, String namespace, long
lag, SubscriptionMode mode) {
ConsumerGroupVO group = new ConsumerGroupVO();
group.setName(name);
@@ -669,4 +706,5 @@ class MetadataServiceTest {
}
+
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunInstanceProviderTest.java
b/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunInstanceProviderTest.java
index 676ecf12b..5b43f55c6 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunInstanceProviderTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunInstanceProviderTest.java
@@ -44,6 +44,7 @@ import org.apache.rocketmq.studio.instance.InstanceRepository;
import org.apache.rocketmq.studio.instance.InstanceVO;
import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO;
import org.apache.rocketmq.studio.instance.group.QueueProgressVO;
+import org.apache.rocketmq.studio.instance.group.ResetConsumerOffsetPreviewVO;
import org.apache.rocketmq.studio.instance.message.MessageRecordVO;
import org.apache.rocketmq.studio.instance.message.TraceNodeVO;
import org.apache.rocketmq.studio.instance.message.TraceRecordVO;
@@ -258,6 +259,37 @@ class AliyunInstanceProviderTest {
assertThat(totalRow.getDiffTotal()).isEqualTo(100L);
}
+ @Test
+ void previewResetOffsetShouldAllowLimitedCloudPreviewTest() {
+ stubInstance();
+ stubCallThrough();
+ GetConsumerGroupLagResponse response =
GetConsumerGroupLagResponse.create().toBuilder()
+ .statusCode(200)
+ .body(GetConsumerGroupLagResponseBody.builder()
+ .data(GetConsumerGroupLagResponseBody.Data.builder()
+ .consumerGroupId("GID_test")
+ .topicLagMap(Map.of("topic-a",
+
DataTopicLagMapValue.builder().readyCount(42L).build()))
+ .build())
+ .build())
+ .build();
+ when(asyncClient.getConsumerGroupLag(any()))
+ .thenReturn(CompletableFuture.completedFuture(response));
+
+ ResetConsumerOffsetPreviewVO preview = provider.previewResetOffset(
+ STUDIO_INSTANCE_ID, "GID_test", 1679458628000L, "topic-a");
+
+ assertThat(preview.isComplete()).isFalse();
+ assertThat(preview.isAllowReset()).isTrue();
+ assertThat(preview.getQueueCount()).isEqualTo(1);
+ assertThat(preview.getCurrentTotalLag()).isEqualTo(42L);
+ assertThat(preview.getProjectedTotalLag()).isEqualTo(-1L);
+ assertThat(preview.getWarnings())
+ .containsExactly("Provider does not expose per-queue target
offset preview; confirm with current lag only");
+
assertThat(preview.getQueues().get(0).getTargetOffset()).isEqualTo(-1L);
+
assertThat(preview.getQueues().get(0).getRiskLevel()).isEqualTo("WARNING");
+ }
+
@Test
void queryMessagesShouldMapFieldsAndDecodeBase64BodyTest() {
stubInstance();
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 df80091c7..1ced0f60e 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
@@ -22,7 +22,10 @@ import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.client.producer.SendStatus;
import org.apache.rocketmq.common.message.Message;
+import org.apache.rocketmq.common.message.MessageQueue;
import org.apache.rocketmq.remoting.exception.RemotingTimeoutException;
+import org.apache.rocketmq.remoting.protocol.admin.ConsumeStats;
+import org.apache.rocketmq.remoting.protocol.admin.OffsetWrapper;
import org.apache.rocketmq.remoting.protocol.ResponseCode;
import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
import org.apache.rocketmq.remoting.protocol.route.BrokerData;
@@ -35,6 +38,8 @@ import
org.apache.rocketmq.studio.cluster.broker.RuntimeAdminClientResolver;
import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO;
import org.apache.rocketmq.studio.instance.group.ConsumerGroupSettingsVO;
import org.apache.rocketmq.studio.instance.group.ConsumerInstanceVO;
+import org.apache.rocketmq.studio.instance.group.ResetConsumerOffsetPreviewVO;
+import
org.apache.rocketmq.studio.instance.group.ResetConsumerOffsetQueuePreviewVO;
import org.apache.rocketmq.studio.instance.topic.TopicVO;
import org.apache.rocketmq.studio.instance.topic.SendMessageDTO;
import org.apache.rocketmq.studio.instance.topic.SendMessageVO;
@@ -62,6 +67,8 @@ 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.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
@@ -244,6 +251,107 @@ class RocketMQAdminClientImplTest {
"instanceId=instance-a, topic=orders,
timestamp=1784246400000", "SUCCESS");
}
+ @Test
+ void
previewResetOffsetShouldComputeQueueImpactWithoutMutatingBrokerOffsets() throws
Exception {
+ long timestamp = 1784246400000L;
+ ConsumeStats stats = new ConsumeStats();
+ MessageQueue rewindQueue = new MessageQueue("orders", "broker-a", 0);
+ MessageQueue fastForwardQueue = new MessageQueue("orders", "broker-b",
1);
+ MessageQueue skippedQueue = new MessageQueue("payments", "broker-a",
0);
+ stats.getOffsetTable().put(rewindQueue, offsetWrapper(120L, 90L));
+ stats.getOffsetTable().put(fastForwardQueue, offsetWrapper(200L,
150L));
+ stats.getOffsetTable().put(skippedQueue, offsetWrapper(50L, 40L));
+ when(adminExt.examineConsumeStats("cg-orders")).thenReturn(stats);
+ when(adminExt.minOffset(rewindQueue)).thenReturn(0L);
+ when(adminExt.maxOffset(rewindQueue)).thenReturn(200L);
+ when(adminExt.searchOffset("broker-a", "orders", 0, timestamp,
3_000L)).thenReturn(80L);
+ when(adminExt.minOffset(fastForwardQueue)).thenReturn(0L);
+ when(adminExt.maxOffset(fastForwardQueue)).thenReturn(220L);
+ when(adminExt.searchOffset("broker-b", "orders", 1, timestamp,
3_000L)).thenReturn(170L);
+
+ ResetConsumerOffsetPreviewVO preview = adminClient.previewResetOffset(
+ null, "cg-orders", timestamp, "orders");
+
+ assertThat(preview.isComplete()).isTrue();
+ assertThat(preview.isAllowReset()).isTrue();
+ assertThat(preview.getQueueCount()).isEqualTo(2);
+ assertThat(preview.getCurrentTotalLag()).isEqualTo(80L);
+ assertThat(preview.getProjectedTotalLag()).isEqualTo(70L);
+ assertThat(preview.getRewindQueueCount()).isEqualTo(1);
+ assertThat(preview.getFastForwardQueueCount()).isEqualTo(1);
+ assertThat(preview.getTotalOffsetDelta()).isEqualTo(10L);
+ assertThat(preview.getWarnings())
+ .contains("1 queue(s) will move forward and may skip
unconsumed messages",
+ "1 queue(s) will move backward and may replay consumed
messages");
+ assertThat(preview.getQueues())
+ .extracting(ResetConsumerOffsetQueuePreviewVO::getBroker,
+ ResetConsumerOffsetQueuePreviewVO::getQueueId,
+ ResetConsumerOffsetQueuePreviewVO::getTargetOffset,
+ ResetConsumerOffsetQueuePreviewVO::getOffsetDelta,
+ ResetConsumerOffsetQueuePreviewVO::getProjectedLag)
+ .containsExactly(
+ org.assertj.core.groups.Tuple.tuple("broker-a", 0,
80L, -10L, 40L),
+ org.assertj.core.groups.Tuple.tuple("broker-b", 1,
170L, 20L, 30L));
+ verify(adminExt, never()).resetOffsetByTimestamp(anyString(),
anyString(), anyString(),
+ anyLong(), anyBoolean());
+ }
+
+ @Test
+ void previewResetOffsetShouldMarkFailedQueuesIncomplete() throws Exception
{
+ long timestamp = 1784246400000L;
+ ConsumeStats stats = new ConsumeStats();
+ MessageQueue queue = new MessageQueue("orders", "broker-a", 0);
+ stats.getOffsetTable().put(queue, offsetWrapper(120L, 90L));
+ when(adminExt.examineConsumeStats("cg-orders")).thenReturn(stats);
+ when(adminExt.minOffset(queue)).thenThrow(new
IllegalStateException("offset unavailable"));
+
+ ResetConsumerOffsetPreviewVO preview = adminClient.previewResetOffset(
+ null, "cg-orders", timestamp, "orders");
+
+ assertThat(preview.isComplete()).isFalse();
+ assertThat(preview.isAllowReset()).isFalse();
+ assertThat(preview.getWarningCount()).isEqualTo(1);
+ assertThat(preview.getWarnings())
+ .containsExactly("Failed to preview 1 queue(s); retry before
applying the reset");
+ assertThat(preview.getQueues()).hasSize(1);
+ ResetConsumerOffsetQueuePreviewVO row = preview.getQueues().get(0);
+ assertThat(row.getRiskLevel()).isEqualTo("ERROR");
+ assertThat(row.getTargetOffset()).isEqualTo(90L);
+ assertThat(row.getProjectedLag()).isEqualTo(30L);
+ assertThat(row.getMessage()).contains("offset unavailable");
+ verify(adminExt, never()).resetOffsetByTimestamp(anyString(),
anyString(), anyString(),
+ anyLong(), anyBoolean());
+ }
+
+ @Test
+ void previewResetOffsetShouldReturnEmptyPreviewWhenTopicHasNoOffsets()
throws Exception {
+ ConsumeStats stats = new ConsumeStats();
+ stats.getOffsetTable().put(new MessageQueue("payments", "broker-a",
0), offsetWrapper(120L, 90L));
+ when(adminExt.examineConsumeStats("cg-orders")).thenReturn(stats);
+
+ ResetConsumerOffsetPreviewVO preview = adminClient.previewResetOffset(
+ null, "cg-orders", 1784246400000L, "orders");
+
+ assertThat(preview.isComplete()).isFalse();
+ assertThat(preview.isAllowReset()).isFalse();
+ assertThat(preview.getQueueCount()).isZero();
+ assertThat(preview.getWarnings()).containsExactly("No consume offset
data found for topic orders");
+ verify(adminExt, never()).resetOffsetByTimestamp(anyString(),
anyString(), anyString(),
+ anyLong(), anyBoolean());
+ }
+
+ @Test
+ void previewResetOffsetShouldRejectBlankTopicBeforeResolvingAdmin() {
+ assertThatThrownBy(() -> adminClient.previewResetOffset("instance-a",
"cg-orders", 1784246400000L, " "))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("topic is required for offset reset preview")
+ .satisfies(exception -> assertThat(((BusinessException)
exception).getCode()).isEqualTo(400));
+
+ verifyNoInteractions(runtimeAdminClientResolver);
+ verify(adminFactory, never()).execute(anyString(), any(), any());
+ verifyNoInteractions(auditService);
+ }
+
@Test
void resetOffsetShouldRejectBlankTopicBeforeResolvingAdmin() {
assertThatThrownBy(() -> adminClient.resetOffset("instance-a",
"cg-orders", 1784246400000L, " "))
@@ -733,6 +841,13 @@ class RocketMQAdminClientImplTest {
return clusterInfo;
}
+ private OffsetWrapper offsetWrapper(long brokerOffset, long
consumerOffset) {
+ OffsetWrapper wrapper = new OffsetWrapper();
+ wrapper.setBrokerOffset(brokerOffset);
+ wrapper.setConsumerOffset(consumerOffset);
+ return wrapper;
+ }
+
@Test
void sendMessageShouldNotFailWhenAuditRecordingFails() throws Exception {
doThrow(new RuntimeException("audit db down")).when(auditService)
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 b93489163..234584860 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
@@ -51,6 +51,7 @@ import org.apache.rocketmq.studio.instance.InstanceRepository;
import org.apache.rocketmq.studio.instance.InstanceVO;
import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO;
import org.apache.rocketmq.studio.instance.group.QueueProgressVO;
+import org.apache.rocketmq.studio.instance.group.ResetConsumerOffsetPreviewVO;
import org.apache.rocketmq.studio.instance.group.SubscriptionEntryVO;
import org.apache.rocketmq.studio.instance.message.MessageRecordVO;
import org.apache.rocketmq.studio.instance.message.TraceNodeVO;
@@ -590,6 +591,29 @@ class TencentInstanceProviderTest {
assertThat(subscriptions.get(0).getType()).isEqualTo("TAG");
}
+ @Test
+ void previewResetOffsetShouldAllowLimitedCloudPreviewTest() throws
Exception {
+ SubscriptionData subscription = new SubscriptionData();
+ subscription.setTopic("orders");
+ subscription.setConsumerLag(42L);
+ DescribeTopicListByGroupResponse response = new
DescribeTopicListByGroupResponse();
+ response.setData(new SubscriptionData[]{subscription});
+ when(client.DescribeTopicListByGroup(any())).thenReturn(response);
+
+ ResetConsumerOffsetPreviewVO preview = provider.previewResetOffset(
+ STUDIO_INSTANCE_ID, "GID_test", 1600000000000L, "orders");
+
+ assertThat(preview.isComplete()).isFalse();
+ assertThat(preview.isAllowReset()).isTrue();
+ assertThat(preview.getQueueCount()).isEqualTo(1);
+ assertThat(preview.getCurrentTotalLag()).isEqualTo(42L);
+ assertThat(preview.getProjectedTotalLag()).isEqualTo(-1L);
+ assertThat(preview.getWarnings())
+ .containsExactly("Provider does not expose per-queue target
offset preview; confirm with current lag only");
+
assertThat(preview.getQueues().get(0).getTargetOffset()).isEqualTo(-1L);
+
assertThat(preview.getQueues().get(0).getRiskLevel()).isEqualTo("WARNING");
+ }
+
@Test
void resetOffsetShouldCallTencentOpenApiTest() throws Exception {
when(client.ResetConsumerGroupOffset(any())).thenReturn(null);
diff --git a/web/src/api/consumerGroups.test.ts
b/web/src/api/consumerGroups.test.ts
index a25044ea1..66e28622a 100644
--- a/web/src/api/consumerGroups.test.ts
+++ b/web/src/api/consumerGroups.test.ts
@@ -26,6 +26,7 @@ import {
listConsumerGroupPage,
listConsumerGroups,
deleteConsumerGroup,
+ previewConsumerOffsetReset,
resetConsumerOffset,
updateConsumerGroupSettings,
} from './metadata';
@@ -118,6 +119,54 @@ describe('consumer groups API contract', () => {
await expect(resetConsumerOffset(reset)).resolves.toBeUndefined();
});
+ it('posts reset preview requests and unwraps queue impact data', async () =>
{
+ const request = {
+ name: group.name,
+ instanceId: 'instance-1',
+ topic: 'orders',
+ timestamp: 1784246400000,
+ };
+ const preview = {
+ instanceId: 'instance-1',
+ groupName: group.name,
+ topic: 'orders',
+ timestamp: 1784246400000,
+ complete: true,
+ allowReset: true,
+ queueCount: 1,
+ warningCount: 1,
+ rewindQueueCount: 1,
+ fastForwardQueueCount: 0,
+ currentTotalLag: 30,
+ projectedTotalLag: 40,
+ totalOffsetDelta: -10,
+ warnings: ['1 queue(s) will move backward and may replay consumed
messages'],
+ queues: [
+ {
+ topic: 'orders',
+ broker: 'broker-a',
+ queueId: 0,
+ minOffset: 0,
+ maxOffset: 200,
+ brokerOffset: 120,
+ consumerOffset: 90,
+ targetOffset: 80,
+ currentLag: 30,
+ projectedLag: 40,
+ offsetDelta: -10,
+ riskLevel: 'WARNING',
+ message: 'Replays 10 message(s)',
+ },
+ ],
+ };
+ mock.onPost('/groups/reset-offset/preview').reply((config) => {
+ expect(JSON.parse(config.data)).toEqual(request);
+ return [200, { code: 200, data: preview }];
+ });
+
+ await
expect(previewConsumerOffsetReset(request)).resolves.toEqual(preview);
+ });
+
it('includes selected instance context when deleting a consumer group',
async () => {
mock.onPost('/groups/delete').reply((config) => {
expect(JSON.parse(config.data)).toEqual({ name: group.name, instanceId:
'instance-1' });
diff --git a/web/src/api/metadata.ts b/web/src/api/metadata.ts
index 148a8c842..185fe3271 100644
--- a/web/src/api/metadata.ts
+++ b/web/src/api/metadata.ts
@@ -373,6 +373,48 @@ export interface ResetConsumerOffsetRequest {
topic: string;
}
+export interface ResetConsumerOffsetQueuePreview {
+ topic: string;
+ broker: string;
+ queueId: number;
+ minOffset: number;
+ maxOffset: number;
+ brokerOffset: number;
+ consumerOffset: number;
+ targetOffset: number;
+ currentLag: number;
+ projectedLag: number;
+ offsetDelta: number;
+ riskLevel: 'INFO' | 'WARNING' | 'ERROR' | string;
+ message: string;
+}
+
+export interface ResetConsumerOffsetPreview {
+ instanceId?: string;
+ groupName: string;
+ topic: string;
+ timestamp: number;
+ complete: boolean;
+ allowReset: boolean;
+ queueCount: number;
+ warningCount: number;
+ rewindQueueCount: number;
+ fastForwardQueueCount: number;
+ currentTotalLag: number;
+ projectedTotalLag: number;
+ totalOffsetDelta: number;
+ warnings: string[];
+ queues: ResetConsumerOffsetQueuePreview[];
+}
+
+export async function previewConsumerOffsetReset(data:
ResetConsumerOffsetRequest) {
+ const res = await client.post<{ data: ResetConsumerOffsetPreview }>(
+ '/groups/reset-offset/preview',
+ data,
+ );
+ return res.data.data;
+}
+
export async function resetConsumerOffset(data: ResetConsumerOffsetRequest) {
await client.post('/groups/reset-offset', data);
}
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index eda784c6c..55ba19b2c 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -15,12 +15,12 @@
* limitations under the License.
*/
-import { App, Modal } from 'antd';
-import { act, fireEvent, render, screen, waitFor, within } from
'@testing-library/react';
+import { App, Modal, message } from 'antd';
+import { act, cleanup, fireEvent, render, screen, waitFor, within } from
'@testing-library/react';
import userEvent from '@testing-library/user-event';
import type React from 'react';
import { MemoryRouter } from 'react-router-dom';
-import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from
'vitest';
import type { ConsumerGroup } from '../../../api/metadata';
import * as instanceService from '../../../services/instanceService';
import { LangProvider } from '../../../i18n/LangContext';
@@ -39,6 +39,7 @@ vi.mock('../../../services/consumerService', () => ({
getConsumerGroupSettings: vi.fn(),
importConsumerGroups: vi.fn(),
listAllConsumerGroups: vi.fn(),
+ previewConsumerOffsetReset: vi.fn(),
updateConsumerGroupSettings: vi.fn(),
listConsumerGroupPage: vi.fn(),
refreshConsumerGroup: vi.fn(),
@@ -48,7 +49,7 @@ const instanceServiceMocks = vi.hoisted(() => ({
listInstances: vi.fn() }));
vi.mock('../../../services/instanceService', () => instanceServiceMocks);
-beforeAll(() => {
+const installBrowserMocks = () => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
@@ -70,7 +71,7 @@ beforeAll(() => {
writable: true,
value: vi.fn(),
});
-});
+};
const group: ConsumerGroup = {
name: 'remote-cg',
@@ -118,9 +119,13 @@ const renderWithProviders = (ui: React.ReactElement,
initialEntry = '/instance/c
</App>,
);
+beforeAll(installBrowserMocks);
+
describe('Consumer page', () => {
beforeEach(() => {
- vi.clearAllMocks();
+ vi.restoreAllMocks();
+ vi.resetAllMocks();
+ installBrowserMocks();
vi.mocked(instanceService.listInstances).mockResolvedValue([
{
id: 1,
@@ -205,6 +210,39 @@ describe('Consumer page', () => {
consistency: '一致',
},
]);
+ vi.mocked(consumerService.previewConsumerOffsetReset).mockResolvedValue({
+ instanceId: 'instance-1',
+ groupName: 'remote-cg',
+ topic: 'remote-topic',
+ timestamp: 1784246400000,
+ complete: true,
+ allowReset: true,
+ queueCount: 1,
+ warningCount: 1,
+ rewindQueueCount: 1,
+ fastForwardQueueCount: 0,
+ currentTotalLag: 30,
+ projectedTotalLag: 40,
+ totalOffsetDelta: -10,
+ warnings: ['1 queue(s) will move backward and may replay consumed
messages'],
+ queues: [
+ {
+ topic: 'remote-topic',
+ broker: 'broker-a',
+ queueId: 0,
+ minOffset: 0,
+ maxOffset: 200,
+ brokerOffset: 120,
+ consumerOffset: 90,
+ targetOffset: 80,
+ currentLag: 30,
+ projectedLag: 40,
+ offsetDelta: -10,
+ riskLevel: 'WARNING',
+ message: 'Replays 10 message(s)',
+ },
+ ],
+ });
vi.mocked(consumerService.getConsumerStack).mockResolvedValue({
groupName: 'remote-cg',
clientId: 'client-1',
@@ -250,6 +288,12 @@ describe('Consumer page', () => {
});
});
+ afterEach(() => {
+ cleanup();
+ Modal.destroyAll();
+ message.destroy();
+ });
+
it('submits the canonical global delivery order type', async () => {
const user = userEvent.setup();
const confirmSpy = vi.spyOn(Modal, 'confirm').mockImplementation((config)
=> {
@@ -469,7 +513,7 @@ describe('Consumer page', () => {
);
});
- it('requires and submits a target topic when resetting consumer offsets',
async () => {
+ it('previews queue impact before resetting consumer offsets', async () => {
const user = userEvent.setup();
renderWithProviders(<ConsumerPage />);
@@ -494,6 +538,22 @@ describe('Consumer page', () => {
return element;
});
await user.click(option);
+ expect(confirm).toBeDisabled();
+
+ await user.click(screen.getByRole('button', { name: /预览影响/ }));
+
+ await waitFor(() =>
+ expect(consumerService.previewConsumerOffsetReset).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'remote-cg',
+ instanceId: 'instance-1',
+ topic: 'remote-topic',
+ timestamp: expect.any(Number),
+ }),
+ ),
+ );
+ expect(await screen.findByText('重置后总堆积')).toBeInTheDocument();
+ expect(await screen.findByText('将回放 10 条消息')).toBeInTheDocument();
await waitFor(() => expect(confirm).toBeEnabled());
await user.click(confirm);
@@ -509,6 +569,87 @@ describe('Consumer page', () => {
);
});
+ it('invalidates the reset preview when reset parameters change', async () =>
{
+ const user = userEvent.setup();
+ renderWithProviders(<ConsumerPage />);
+
+ await user.click(await screen.findByRole('button', { name: /重置位点/ }));
+ const topicSelect = screen.getByRole('combobox', { name: '目标 Topic' });
+ await user.click(topicSelect);
+ 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: /预览影响/ }));
+
+ expect(await screen.findByText('将回放 10 条消息')).toBeInTheDocument();
+ await waitFor(() => expect(screen.getByRole('button', { name: '确认重置'
})).toBeEnabled());
+
+ await user.click(screen.getByRole('button', { name: '1 小时前' }));
+
+ expect(screen.queryByText('将回放 10 条消息')).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: '确认重置' })).toBeDisabled();
+ });
+
+ it('blocks reset confirmation when the preview has failed queues', async ()
=> {
+ vi.mocked(consumerService.previewConsumerOffsetReset).mockResolvedValue({
+ instanceId: 'instance-1',
+ groupName: 'remote-cg',
+ topic: 'remote-topic',
+ timestamp: 1784246400000,
+ complete: false,
+ allowReset: false,
+ queueCount: 1,
+ warningCount: 1,
+ rewindQueueCount: 0,
+ fastForwardQueueCount: 0,
+ currentTotalLag: 30,
+ projectedTotalLag: 30,
+ totalOffsetDelta: 0,
+ warnings: ['Failed to preview 1 queue(s); retry before applying the
reset'],
+ queues: [
+ {
+ topic: 'remote-topic',
+ broker: 'broker-a',
+ queueId: 0,
+ minOffset: -1,
+ maxOffset: -1,
+ brokerOffset: 120,
+ consumerOffset: 90,
+ targetOffset: 90,
+ currentLag: 30,
+ projectedLag: 30,
+ offsetDelta: 0,
+ riskLevel: 'ERROR',
+ message: 'Failed to preview queue offset: timeout',
+ },
+ ],
+ });
+ const user = userEvent.setup();
+ 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: /预览影响/ }));
+
+ expect(await screen.findByText('不完整')).toBeInTheDocument();
+ expect(await screen.findByText('Failed to preview queue offset:
timeout')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: '确认重置' })).toBeDisabled();
+ 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 a5c1cf993..bed410356 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -72,6 +72,8 @@ import type {
ConsumerInstance,
ConsumerStackTrace,
QueueProgress,
+ ResetConsumerOffsetPreview,
+ ResetConsumerOffsetQueuePreview,
SubscriptionEntry,
} from '../../api/metadata';
import {
@@ -84,6 +86,7 @@ import {
getConsumerSubscriptions,
importConsumerGroups,
listConsumerGroupPage,
+ previewConsumerOffsetReset,
refreshConsumerGroup,
resetConsumerOffset,
getConsumerGroupSettings,
@@ -165,6 +168,50 @@ const isConsistentSubscription = (subscription:
SubscriptionEntry): boolean =>
const isInconsistentSubscription = (subscription: SubscriptionEntry): boolean
=>
isInconsistentValue(subscription.consistency);
+const formatOffsetValue = (value: number) =>
+ Number.isFinite(value) && value >= 0 ? value.toLocaleString() : '-';
+
+const formatOffsetDelta = (value: number) => {
+ if (value > 0) return `+${value.toLocaleString()}`;
+ return value.toLocaleString();
+};
+
+const resetPreviewRiskColor = (riskLevel: string) => {
+ if (riskLevel === 'ERROR') return 'red';
+ if (riskLevel === 'WARNING') return 'orange';
+ return 'green';
+};
+
+const resetPreviewRiskLabel = (riskLevel: string) => {
+ if (riskLevel === 'ERROR') return '失败';
+ if (riskLevel === 'WARNING') return '需确认';
+ return '正常';
+};
+
+const resetPreviewQueueMessage = (queue: ResetConsumerOffsetQueuePreview) => {
+ const messages: string[] = [];
+ if (queue.riskLevel === 'ERROR') {
+ return queue.message || '预览失败';
+ }
+ if (queue.targetOffset < 0 || queue.consumerOffset < 0) {
+ return queue.message || '目标位点不可用';
+ }
+ if (queue.offsetDelta < 0) {
+ messages.push(`将回放 ${Math.abs(queue.offsetDelta).toLocaleString()} 条消息`);
+ } else if (queue.offsetDelta > 0) {
+ messages.push(`将跳过 ${queue.offsetDelta.toLocaleString()} 条未消费消息`);
+ } else {
+ messages.push('位点不变');
+ }
+ if (queue.minOffset >= 0 && queue.targetOffset === queue.minOffset) {
+ messages.push('目标为最小保留位点');
+ }
+ if (queue.maxOffset >= 0 && queue.targetOffset === queue.maxOffset) {
+ messages.push('目标为最新位点');
+ }
+ return messages.join(';');
+};
+
// 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) =>
@@ -210,6 +257,10 @@ const ConsumerPageContent = ({
const [resetGroup, setResetGroup] = useState<ConsumerGroup | null>(null);
const [resetTopic, setResetTopic] = useState<string>();
const [resetTime, setResetTime] = useState<Dayjs>(dayjs().subtract(3,
'hour'));
+ const [resetPreview, setResetPreview] = useState<ResetConsumerOffsetPreview
| null>(null);
+ const [resetPreviewKey, setResetPreviewKey] = useState('');
+ const [resetPreviewLoading, setResetPreviewLoading] = useState(false);
+ const [resetPreviewError, setResetPreviewError] = useState<string |
null>(null);
const [subscriptionsByGroup, setSubscriptionsByGroup] = useState<
Record<string, SubscriptionEntry[]>
>({});
@@ -245,6 +296,11 @@ const ConsumerPageContent = ({
silentRefreshRef.current = silent;
setRefreshKey((key) => key + 1);
}, []);
+ const clearResetPreview = useCallback(() => {
+ setResetPreview(null);
+ setResetPreviewKey('');
+ setResetPreviewError(null);
+ }, []);
const selectedGroupName = selectedGroup?.name;
useEffect(() => {
@@ -334,8 +390,8 @@ const ConsumerPageContent = ({
);
useEffect(() => {
- if (!modalOpen || !selectedGroup || !selectedInstanceId) return undefined;
- const groupName = selectedGroup.name;
+ if (!modalOpen || !selectedGroupName || !selectedInstanceId) return
undefined;
+ const groupName = selectedGroupName;
let inFlight = false;
const tick = async () => {
if (inFlight) return;
@@ -355,7 +411,7 @@ const ConsumerPageContent = ({
};
const interval = window.setInterval(() => void tick(), 2000);
return () => window.clearInterval(interval);
- }, [modalOpen, selectedGroup?.name, selectedInstanceId, loadProgress]);
+ }, [modalOpen, selectedGroupName, selectedInstanceId, loadProgress]);
/* ─── Filtered & sorted data ─── */
const filtered = useMemo(() => {
@@ -456,6 +512,17 @@ const ConsumerPageContent = ({
const resetDiagnosticKey = resetGroup
? diagnosticCacheKey(selectedInstanceId, resetGroup.name)
: '';
+ const resetTimestamp = resetTime.valueOf();
+ const currentResetPreviewKey =
+ resetGroup && resetTopic
+ ? [selectedInstanceId ?? '', resetGroup.name, resetTopic,
resetTimestamp].join('\u0000')
+ : '';
+ const hasCurrentResetPreview = Boolean(
+ resetPreview && resetPreviewKey === currentResetPreviewKey,
+ );
+ const resetPreviewQueues = hasCurrentResetPreview ? (resetPreview?.queues ??
[]) : [];
+ const resetPreviewWarnings = hasCurrentResetPreview ?
(resetPreview?.warnings ?? []) : [];
+ const resetPreviewCanApply = Boolean(hasCurrentResetPreview &&
resetPreview?.allowReset);
const resetTopicOptions = useMemo(() => {
const topics = new Set(resetGroup?.subscribedTopics ?? []);
for (const subscription of subscriptionsByGroup[resetDiagnosticKey] ?? [])
{
@@ -501,6 +568,73 @@ const ConsumerPageContent = ({
0,
);
+ const handlePreviewResetOffset = async () => {
+ if (!resetGroup || !resetTopic) {
+ message.warning('请先选择要重置的 Topic');
+ return;
+ }
+ const previewKey = currentResetPreviewKey;
+ setResetPreviewLoading(true);
+ setResetPreviewError(null);
+ try {
+ const preview = await previewConsumerOffsetReset({
+ name: resetGroup.name,
+ instanceId: selectedInstanceId || undefined,
+ topic: resetTopic,
+ timestamp: resetTimestamp,
+ });
+ setResetPreview(preview);
+ setResetPreviewKey(previewKey);
+ if (preview.complete && preview.queueCount > 0) {
+ message.success(`已预览 ${preview.queueCount} 个 Queue`);
+ } else {
+ message.warning('预览未覆盖可重置队列,请检查 Group/Topic 状态');
+ }
+ } catch (error) {
+ const reason = error instanceof Error ? error.message : '预览重置影响失败';
+ setResetPreview(null);
+ setResetPreviewKey('');
+ setResetPreviewError(reason);
+ message.error(reason);
+ } finally {
+ setResetPreviewLoading(false);
+ }
+ };
+
+ const handleResetOffset = async () => {
+ if (!resetGroup || !resetTopic) return;
+ if (!resetPreviewCanApply) {
+ message.warning('请先预览并确认位点影响');
+ return;
+ }
+ setResetSubmitting(true);
+ try {
+ await resetConsumerOffset({
+ name: resetGroup.name,
+ instanceId: selectedInstanceId || undefined,
+ topic: resetTopic,
+ timestamp: resetTimestamp,
+ });
+ message.success(
+ `${resetGroup.name} 在 ${resetTopic} 的消费位点已重置到
${resetTime.format('YYYY-MM-DD HH:mm:ss')}`,
+ );
+ setProgressByGroup((prev) => {
+ const next = { ...prev };
+ delete next[resetDiagnosticKey];
+ return next;
+ });
+ triggerRefresh(true);
+ setResetModalOpen(false);
+ setResetGroup(null);
+ setResetTopic(undefined);
+ clearResetPreview();
+ } catch {
+ message.error(t('consumer.resetFailed'));
+ } finally {
+ setResetSubmitting(false);
+ }
+ };
+
const openStackModal = async (consumerInstance: ConsumerInstance) => {
if (!selectedGroup) return;
const requestId = ++stackRequestIdRef.current;
@@ -784,6 +918,7 @@ const ConsumerPageContent = ({
setResetGroup(record);
setResetTopic(undefined);
setResetTime(dayjs().subtract(3, 'hour'));
+ clearResetPreview();
setResetModalOpen(true);
void loadSubscriptions(record.name);
}}
@@ -1036,6 +1171,111 @@ const ConsumerPageContent = ({
},
];
+ const resetPreviewColumns: ColumnsType<ResetConsumerOffsetQueuePreview> = [
+ {
+ title: 'Broker',
+ dataIndex: 'broker',
+ key: 'broker',
+ width: 140,
+ ellipsis: true,
+ render: (broker: string) => (
+ <Text strong style={{ fontSize: 14 }} title={broker}>
+ {broker || '-'}
+ </Text>
+ ),
+ },
+ {
+ title: 'Queue ID',
+ dataIndex: 'queueId',
+ key: 'queueId',
+ width: 86,
+ align: 'center',
+ render: (id: number) => <Tag color="blue">Queue {id}</Tag>,
+ },
+ {
+ title: '当前位点',
+ dataIndex: 'consumerOffset',
+ key: 'consumerOffset',
+ width: 120,
+ align: 'right',
+ render: (offset: number) => (
+ <Text style={{ fontFamily: 'monospace'
}}>{formatOffsetValue(offset)}</Text>
+ ),
+ },
+ {
+ title: '目标位点',
+ dataIndex: 'targetOffset',
+ key: 'targetOffset',
+ width: 120,
+ align: 'right',
+ render: (offset: number) => (
+ <Text strong style={{ fontFamily: 'monospace' }}>
+ {formatOffsetValue(offset)}
+ </Text>
+ ),
+ },
+ {
+ title: '变化',
+ dataIndex: 'offsetDelta',
+ key: 'offsetDelta',
+ width: 100,
+ align: 'right',
+ render: (delta: number, row: ResetConsumerOffsetQueuePreview) => (
+ <Text
+ style={{
+ color: delta > 0 ? '#fa8c16' : delta < 0 ? '#1677ff' : undefined,
+ fontFamily: 'monospace',
+ fontWeight: 600,
+ }}
+ >
+ {row.targetOffset < 0 || row.consumerOffset < 0 ? '-' :
formatOffsetDelta(delta)}
+ </Text>
+ ),
+ },
+ {
+ title: '当前堆积',
+ dataIndex: 'currentLag',
+ key: 'currentLag',
+ width: 110,
+ align: 'right',
+ render: (lag: number) => (
+ <Text style={{ fontFamily: 'monospace'
}}>{formatOffsetValue(lag)}</Text>
+ ),
+ },
+ {
+ title: '重置后堆积',
+ dataIndex: 'projectedLag',
+ key: 'projectedLag',
+ width: 124,
+ align: 'right',
+ render: (lag: number) => (
+ <Text style={{ fontFamily: 'monospace', color: lagColor(lag),
fontWeight: 600 }}>
+ {formatOffsetValue(lag)}
+ </Text>
+ ),
+ },
+ {
+ title: '风险',
+ dataIndex: 'riskLevel',
+ key: 'riskLevel',
+ width: 86,
+ render: (riskLevel: string) => (
+ <Tag
color={resetPreviewRiskColor(riskLevel)}>{resetPreviewRiskLabel(riskLevel)}</Tag>
+ ),
+ },
+ {
+ title: '说明',
+ key: 'message',
+ width: 240,
+ ellipsis: true,
+ render: (_: unknown, record: ResetConsumerOffsetQueuePreview) => (
+ <Text style={{ fontSize: 14 }}
title={resetPreviewQueueMessage(record)}>
+ {resetPreviewQueueMessage(record)}
+ </Text>
+ ),
+ },
+ ];
+
/* ═══════════════════════════════════════════
Render
═══════════════════════════════════════════ */
@@ -1919,54 +2159,29 @@ const ConsumerPageContent = ({
setResetModalOpen(false);
setResetGroup(null);
setResetTopic(undefined);
+ clearResetPreview();
}}
- onOk={async () => {
- if (!resetGroup || !resetTopic) return;
- setResetSubmitting(true);
- try {
- await resetConsumerOffset({
- name: resetGroup.name,
- instanceId: selectedInstanceId || undefined,
- topic: resetTopic,
- timestamp: resetTime.valueOf(),
- });
- message.success(
- `${resetGroup.name} 在 ${resetTopic} 的消费位点已重置到
${resetTime.format('YYYY-MM-DD HH:mm:ss')}`,
- );
- } catch {
- message.error(t('consumer.resetFailed'));
- return;
- } finally {
- setResetSubmitting(false);
- }
- setResetModalOpen(false);
- setResetGroup(null);
- setResetTopic(undefined);
- }}
+ onOk={() => void handleResetOffset()}
confirmLoading={resetSubmitting}
okButtonProps={{
- disabled: !resetTopic ||
Boolean(subscriptionLoadingByGroup[resetDiagnosticKey]),
+ disabled:
+ !resetPreviewCanApply ||
+ resetPreviewLoading ||
+ Boolean(subscriptionLoadingByGroup[resetDiagnosticKey]),
}}
okText="确认重置"
cancelText="取消"
- width={480}
+ width={1200}
destroyOnHidden
>
{resetGroup && (
- <div style={{ marginTop: 16 }}>
- <div
- style={{
- marginBottom: 16,
- padding: '12px 16px',
- background: '#fff7e6',
- borderRadius: 8,
- border: '1px solid #ffd591',
- }}
- >
- <Text type="warning" style={{ fontSize: 14 }}>
- ⚠️ 此操作将影响消息消费进度,请谨慎操作。重置后消费者将从指定时间点开始重新消费。
- </Text>
- </div>
+ <Space direction="vertical" size={16} style={{ width: '100%',
marginTop: 16 }}>
+ <Alert
+ showIcon
+ type="warning"
+ message="此操作将影响消息消费进度"
+ description="请先预览每个 Queue
的目标位点和堆积变化,确认预览结果后再执行重置。预览为时点快照、属页面操作引导(非服务端控制),预览期间消息持续写入,实际效果以执行时 Broker
状态为准。"
+ />
<div style={{ marginBottom: 16 }}>
<Text type="secondary" style={{ fontSize: 14, display: 'block',
marginBottom: 4 }}>
目标 Group
@@ -1988,7 +2203,10 @@ const ConsumerPageContent = ({
options={resetTopicOptions}
loading={subscriptionLoadingByGroup[resetDiagnosticKey]}
placeholder="选择要重置消费位点的 Topic"
- onChange={setResetTopic}
+ onChange={(value) => {
+ setResetTopic(value);
+ clearResetPreview();
+ }}
notFoundContent={
subscriptionErrorByGroup[resetDiagnosticKey]
? '订阅 Topic 加载失败'
@@ -2005,7 +2223,10 @@ const ConsumerPageContent = ({
style={{ width: '100%' }}
value={resetTime}
onChange={(val) => {
- if (val) setResetTime(val);
+ if (val) {
+ setResetTime(val);
+ clearResetPreview();
+ }
}}
format="YYYY-MM-DD HH:mm:ss"
placeholder="选择重置时间点"
@@ -2016,27 +2237,131 @@ const ConsumerPageContent = ({
快捷选择
</Text>
<Space wrap>
- <Button size="small" onClick={() =>
setResetTime(dayjs().subtract(1, 'hour'))}>
+ <Button
+ size="small"
+ onClick={() => {
+ setResetTime(dayjs().subtract(1, 'hour'));
+ clearResetPreview();
+ }}
+ >
1 小时前
</Button>
- <Button size="small" onClick={() =>
setResetTime(dayjs().subtract(3, 'hour'))}>
+ <Button
+ size="small"
+ onClick={() => {
+ setResetTime(dayjs().subtract(3, 'hour'));
+ clearResetPreview();
+ }}
+ >
3 小时前
</Button>
- <Button size="small" onClick={() =>
setResetTime(dayjs().subtract(6, 'hour'))}>
+ <Button
+ size="small"
+ onClick={() => {
+ setResetTime(dayjs().subtract(6, 'hour'));
+ clearResetPreview();
+ }}
+ >
6 小时前
</Button>
- <Button size="small" onClick={() =>
setResetTime(dayjs().subtract(12, 'hour'))}>
+ <Button
+ size="small"
+ onClick={() => {
+ setResetTime(dayjs().subtract(12, 'hour'));
+ clearResetPreview();
+ }}
+ >
12 小时前
</Button>
- <Button size="small" onClick={() =>
setResetTime(dayjs().subtract(1, 'day'))}>
+ <Button
+ size="small"
+ onClick={() => {
+ setResetTime(dayjs().subtract(1, 'day'));
+ clearResetPreview();
+ }}
+ >
1 天前
</Button>
- <Button size="small" onClick={() =>
setResetTime(dayjs().subtract(3, 'day'))}>
+ <Button
+ size="small"
+ onClick={() => {
+ setResetTime(dayjs().subtract(3, 'day'));
+ clearResetPreview();
+ }}
+ >
3 天前
</Button>
</Space>
</div>
- </div>
+ <Flex justify="space-between" align="center" gap={12}>
+ <Text type="secondary" style={{ fontSize: 14 }}>
+ 预览不会修改 broker 位点,仅计算目标时间对应的 Queue offset。
+ </Text>
+ <Button
+ icon={<Eye size={14} />}
+ loading={resetPreviewLoading}
+ disabled={
+ !resetTopic ||
+ Boolean(subscriptionLoadingByGroup[resetDiagnosticKey]) ||
+ resetSubmitting
+ }
+ onClick={() => void handlePreviewResetOffset()}
+ >
+ 预览影响
+ </Button>
+ </Flex>
+ {resetPreviewError && (
+ <Alert showIcon type="error" message="位点预览失败"
description={resetPreviewError} />
+ )}
+ {hasCurrentResetPreview && resetPreview && (
+ <Space direction="vertical" size={12} style={{ width: '100%' }}>
+ <Descriptions bordered size="small" column={4}>
+ <Descriptions.Item label="Queue
数">{resetPreview.queueCount}</Descriptions.Item>
+ <Descriptions.Item label="当前总堆积">
+ {formatOffsetValue(resetPreview.currentTotalLag)}
+ </Descriptions.Item>
+ <Descriptions.Item label="重置后总堆积">
+ {formatOffsetValue(resetPreview.projectedTotalLag)}
+ </Descriptions.Item>
+ <Descriptions.Item label="位点净变化">
+ {formatOffsetDelta(resetPreview.totalOffsetDelta)}
+ </Descriptions.Item>
+ <Descriptions.Item label="回放 Queue">
+ {resetPreview.rewindQueueCount}
+ </Descriptions.Item>
+ <Descriptions.Item label="跳过 Queue">
+ {resetPreview.fastForwardQueueCount}
+ </Descriptions.Item>
+ <Descriptions.Item label="预览状态" span={2}>
+ <Tag
+ color={
+ resetPreview.complete ? 'green' :
resetPreview.allowReset ? 'orange' : 'red'
+ }
+ >
+ {resetPreview.complete ? '完整' : resetPreview.allowReset
? '有限' : '不完整'}
+ </Tag>
+ </Descriptions.Item>
+ </Descriptions>
+ {resetPreviewWarnings.length > 0 && (
+ <Alert
+ showIcon
+ type={resetPreview.complete ? 'warning' : 'error'}
+ message="请确认以下影响"
+ description={resetPreviewWarnings.join(';')}
+ />
+ )}
+ <Table
+ columns={resetPreviewColumns}
+ dataSource={resetPreviewQueues}
+ rowKey={(row) => `${row.topic}-${row.broker}-${row.queueId}`}
+ pagination={false}
+ size="small"
+ scroll={{ x: tableScrollX(resetPreviewColumns), y: 260 }}
+ locale={{ emptyText: '未找到可预览的 Queue 位点' }}
+ />
+ </Space>
+ )}
+ </Space>
)}
</Modal>
</div>
diff --git a/web/src/services/consumerService.test.ts
b/web/src/services/consumerService.test.ts
index 897e19e3f..44463d470 100644
--- a/web/src/services/consumerService.test.ts
+++ b/web/src/services/consumerService.test.ts
@@ -25,6 +25,7 @@ import {
listAllConsumerGroups,
listConsumerGroupPage,
listConsumerGroups,
+ previewConsumerOffsetReset,
} from './consumerService';
const { mode, metadataApi } = vi.hoisted(() => ({
@@ -33,6 +34,7 @@ const { mode, metadataApi } = vi.hoisted(() => ({
getConsumerGroup: vi.fn(),
listConsumerGroupPage: vi.fn(),
listConsumerGroups: vi.fn(),
+ previewConsumerOffsetReset: vi.fn(),
},
}));
@@ -187,6 +189,67 @@ describe('consumer service mock data', () => {
expect(secondSubscriptions[0]).not.toBe(firstSubscriptions[0]);
});
+ it('builds copied reset offset previews in mock mode', async () => {
+ const firstPreview = await previewConsumerOffsetReset({
+ name: 'cg-order-notify',
+ instanceId: 'instance-proxy-1',
+ topic: 'order-create',
+ timestamp: 1784246400000,
+ });
+
+ expect(firstPreview.groupName).toBe('cg-order-notify');
+ expect(firstPreview.topic).toBe('order-create');
+ expect(firstPreview.complete).toBe(true);
+ expect(firstPreview.queueCount).toBeGreaterThan(0);
+ expect(firstPreview.rewindQueueCount).toBeGreaterThan(0);
+ expect(firstPreview.queues[0].topic).toBe('order-create');
+ firstPreview.queues[0].broker = 'mutated-broker';
+ firstPreview.warnings.push('mutated-warning');
+
+ const secondPreview = await previewConsumerOffsetReset({
+ name: 'cg-order-notify',
+ instanceId: 'instance-proxy-1',
+ topic: 'order-create',
+ timestamp: 1784246400000,
+ });
+ expect(secondPreview.queues[0].broker).not.toBe('mutated-broker');
+ expect(secondPreview.warnings).not.toContain('mutated-warning');
+ });
+
+ it('forwards reset offset previews in API mode', async () => {
+ mode.mock = false;
+ const request = {
+ name: 'cg-orders',
+ instanceId: 'instance-1',
+ topic: 'orders',
+ timestamp: 1784246400000,
+ };
+ const preview = {
+ instanceId: 'instance-1',
+ groupName: 'cg-orders',
+ topic: 'orders',
+ timestamp: 1784246400000,
+ complete: true,
+ allowReset: true,
+ queueCount: 0,
+ warningCount: 0,
+ rewindQueueCount: 0,
+ fastForwardQueueCount: 0,
+ currentTotalLag: 0,
+ projectedTotalLag: 0,
+ totalOffsetDelta: 0,
+ warnings: [],
+ queues: [],
+ };
+ metadataApi.previewConsumerOffsetReset.mockResolvedValue(preview);
+ try {
+ await
expect(previewConsumerOffsetReset(request)).resolves.toEqual(preview);
+
expect(metadataApi.previewConsumerOffsetReset).toHaveBeenCalledWith(request);
+ } finally {
+ mode.mock = true;
+ }
+ });
+
it('returns an empty mock consumer stack trace', async () => {
const stack = await getConsumerStack('cg-order-notify', 'client-1');
diff --git a/web/src/services/consumerService.ts
b/web/src/services/consumerService.ts
index 24d9767a2..098b0de67 100644
--- a/web/src/services/consumerService.ts
+++ b/web/src/services/consumerService.ts
@@ -11,6 +11,8 @@ import type {
ImportConsumerGroupsResult,
PageResult,
QueueProgress,
+ ResetConsumerOffsetPreview,
+ ResetConsumerOffsetQueuePreview,
ResetConsumerOffsetRequest,
SubscriptionEntry,
} from '../api/metadata';
@@ -63,6 +65,14 @@ function copySubscription(subscription: SubscriptionEntry):
SubscriptionEntry {
return { ...subscription };
}
+function copyResetOffsetPreview(preview: ResetConsumerOffsetPreview):
ResetConsumerOffsetPreview {
+ return {
+ ...preview,
+ warnings: [...preview.warnings],
+ queues: preview.queues.map((queue) => ({ ...queue })),
+ };
+}
+
const normalizeConsumerGroup = <T extends ConsumerGroup>(group: T): T => ({
...group,
subscribedTopics: group.subscribedTopics ?? [],
@@ -296,6 +306,96 @@ export async function resetConsumerOffset(data:
ResetConsumerOffsetRequest): Pro
return metadataApi.resetConsumerOffset(data);
}
+export async function previewConsumerOffsetReset(
+ data: ResetConsumerOffsetRequest,
+): Promise<ResetConsumerOffsetPreview> {
+ if (isMockMode()) return buildMockResetOffsetPreview(data);
+ return metadataApi.previewConsumerOffsetReset(data);
+}
+
+function buildMockResetOffsetPreview(data: ResetConsumerOffsetRequest):
ResetConsumerOffsetPreview {
+ const progressRows = ((mockQueueProgress[data.name] as unknown as
QueueProgress[]) ?? []).filter(
+ (progress) => !progress.topic || progress.topic === data.topic,
+ );
+ const queues = progressRows.map((progress) =>
+ buildMockResetOffsetQueuePreview(data.topic, progress),
+ );
+ const currentTotalLag = queues.reduce((sum, queue) => sum +
queue.currentLag, 0);
+ const projectedTotalLag = queues.reduce((sum, queue) => sum +
queue.projectedLag, 0);
+ const rewindQueueCount = queues.filter((queue) => queue.offsetDelta <
0).length;
+ const fastForwardQueueCount = queues.filter((queue) => queue.offsetDelta >
0).length;
+ const warnings = buildMockResetOffsetWarnings(queues, rewindQueueCount,
fastForwardQueueCount);
+
+ return copyResetOffsetPreview({
+ instanceId: data.instanceId,
+ groupName: data.name,
+ topic: data.topic,
+ timestamp: data.timestamp,
+ complete: queues.length > 0,
+ allowReset: queues.length > 0,
+ queueCount: queues.length,
+ warningCount: warnings.length,
+ rewindQueueCount,
+ fastForwardQueueCount,
+ currentTotalLag,
+ projectedTotalLag,
+ totalOffsetDelta: queues.reduce((sum, queue) => sum + queue.offsetDelta,
0),
+ warnings,
+ queues,
+ });
+}
+
+function buildMockResetOffsetQueuePreview(
+ topic: string,
+ progress: QueueProgress,
+): ResetConsumerOffsetQueuePreview {
+ const maxOffset = progress.brokerOffset;
+ const minOffset = 0;
+ const rewindWindow = Math.min(500, Math.max(1,
Math.floor(Math.max(progress.diffTotal, 1) / 2)));
+ const targetOffset = Math.max(
+ minOffset,
+ Math.min(maxOffset, progress.consumerOffset - rewindWindow),
+ );
+ const offsetDelta = targetOffset - progress.consumerOffset;
+ const currentLag = Math.max(0, progress.brokerOffset -
progress.consumerOffset);
+ const projectedLag = Math.max(0, progress.brokerOffset - targetOffset);
+
+ return {
+ topic: progress.topic || topic,
+ broker: progress.broker,
+ queueId: progress.queueId,
+ minOffset,
+ maxOffset,
+ brokerOffset: progress.brokerOffset,
+ consumerOffset: progress.consumerOffset,
+ targetOffset,
+ currentLag,
+ projectedLag,
+ offsetDelta,
+ riskLevel: offsetDelta === 0 ? 'INFO' : 'WARNING',
+ message:
+ offsetDelta === 0
+ ? 'Offset unchanged'
+ : `Replays ${Math.abs(offsetDelta).toLocaleString()} message(s)`,
+ };
+}
+
+function buildMockResetOffsetWarnings(
+ queues: ResetConsumerOffsetQueuePreview[],
+ rewindQueueCount: number,
+ fastForwardQueueCount: number,
+): string[] {
+ if (queues.length === 0) return ['No consume offset data found for the
selected topic'];
+ const warnings: string[] = [];
+ if (fastForwardQueueCount > 0) {
+ warnings.push(`${fastForwardQueueCount} queue(s) will move forward and may
skip messages`);
+ }
+ if (rewindQueueCount > 0) {
+ warnings.push(`${rewindQueueCount} queue(s) will replay consumed
messages`);
+ }
+ return warnings;
+}
+
export interface BatchDeleteConsumerGroupsResult {
deleted: string[];
failed: string[];