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 9e2bb75b4 fix: stop fabricating or losing state in four backend paths 
(#5449)
9e2bb75b4 is described below

commit 9e2bb75b41c9aeb16cf56058b1eec6e070c3cd28
Author: 烤化の初雪 <[email protected]>
AuthorDate: Fri Oct 9 17:48:28 2026 +0800

    fix: stop fabricating or losing state in four backend paths (#5449)
    
    Four unrelated small backend defects, batched because each is a few lines 
with its own test.
    
    - `ApacheRocketMqClusterMetricsCollector` emitted no samples at all when a 
broker's runtime stats
      could not be read (no master address, null stats table, failed fetch), 
and alert reconciliation
      reads a missing sample as "the value cleared" - which resolved active 
disk / JVM / send-queue
      incidents on the collection failure itself. Those branches now emit 
explicit unavailable
      samples. (#5449)
    - `LlmConfigService.saveConfig` rebuilt the persisted settings without 
`clearApiKey`, so clearing the
      API key in the UI did not survive the save. (#5436)
    - `AliyunConverters.toTopicConsumerVO` left `metricsAvailable` at its 
default, reporting lag and TPS
      as real zeros when the Aliyun subscription API does not return them; it 
is now explicitly
      false. (#5423)
    - `rmqctl/scripts/build-windows.ps1` defaulted `$Version` to `1.0.0` while 
the CLI declares `3.0.0`,
      so Windows builds stamped the wrong version. (#5432)
    
    `AliyunConvertersTest` + `LlmConfigServiceTest` + 
`ApacheRocketMqClusterMetricsCollectorTest`: 48
    tests green, 0 checkstyle violations; `go test ./scripts/...` ok and `make 
fmt` clean. The two new
    collector cases were mutation-checked - removing either unavailable-sample 
call reddens its own case.
    
    Folded in #5423, #5432 and #5436.
---
 rmqctl/cmd/version_metadata_test.go                | 64 ++++++++++++++++++
 rmqctl/scripts/build-windows.ps1                   |  5 +-
 .../ApacheRocketMqClusterMetricsCollector.java     | 16 +++++
 .../rocketmq/studio/ops/ai/LlmConfigService.java   |  4 ++
 .../studio/provider/alibaba/AliyunConverters.java  |  5 ++
 .../ApacheRocketMqClusterMetricsCollectorTest.java | 78 ++++++++++++++++++++++
 .../studio/ops/ai/LlmConfigServiceTest.java        | 24 +++++++
 .../provider/alibaba/AliyunConvertersTest.java     | 20 ++++++
 8 files changed, 214 insertions(+), 2 deletions(-)

diff --git a/rmqctl/cmd/version_metadata_test.go 
b/rmqctl/cmd/version_metadata_test.go
new file mode 100644
index 000000000..7abf63291
--- /dev/null
+++ b/rmqctl/cmd/version_metadata_test.go
@@ -0,0 +1,64 @@
+/*
+ * 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 cmd
+
+import (
+       "os"
+       "path/filepath"
+       "regexp"
+       "testing"
+)
+
+// The binary's reported version comes from three build entry points: `make 
build` injects the
+// Makefile's VERSION, a plain `go build` falls back to CLIVersion, and the 
Windows script injects
+// its own default. They had drifted (the script still defaulted to 1.0.0 
after the CLI moved to
+// 3.0.0), so a Windows build reported a version no other build did. Pin them 
together: a release
+// bump has to update all three or this fails.
+func TestVersionMetadataAgreesAcrossBuildEntryPoints(t *testing.T) {
+       makefile := readRepoFile(t, "Makefile")
+       script := readRepoFile(t, filepath.Join("scripts", "build-windows.ps1"))
+
+       makefileVersion := firstCapture(t, `(?m)^VERSION \?= (\S+)\s*$`, 
makefile, "Makefile VERSION")
+       scriptVersion := firstCapture(t, `(?m)^\s*\[string\]\$Version = 
"([^"]+)"`, script,
+               "build-windows.ps1 $Version default")
+
+       if CLIVersion != makefileVersion {
+               t.Errorf("cmd.CLIVersion = %q but Makefile VERSION ?= %q", 
CLIVersion, makefileVersion)
+       }
+       if scriptVersion != CLIVersion {
+               t.Errorf("build-windows.ps1 default = %q but cmd.CLIVersion = 
%q", scriptVersion, CLIVersion)
+       }
+}
+
+func readRepoFile(t *testing.T, relative string) string {
+       t.Helper()
+       path := filepath.Join("..", relative)
+       content, err := os.ReadFile(path)
+       if err != nil {
+               t.Fatalf("reading %s: %v", path, err)
+       }
+       return string(content)
+}
+
+func firstCapture(t *testing.T, pattern, content, what string) string {
+       t.Helper()
+       matches := regexp.MustCompile(pattern).FindStringSubmatch(content)
+       if matches == nil {
+               t.Fatalf("could not read %s", what)
+       }
+       return matches[1]
+}
diff --git a/rmqctl/scripts/build-windows.ps1 b/rmqctl/scripts/build-windows.ps1
index 275cd0f95..af67aa4ed 100644
--- a/rmqctl/scripts/build-windows.ps1
+++ b/rmqctl/scripts/build-windows.ps1
@@ -29,7 +29,8 @@
     right toolchain automatically.
 
 .PARAMETER Version
-    Value injected into cmd.CLIVersion (default: 1.0.0).
+    Value injected into cmd.CLIVersion (default: 3.0.0, the version the 
Makefile and
+    cmd.CLIVersion already declare).
 
 .PARAMETER Output
     Output binary path (default: bin\rmqctl.exe).
@@ -42,7 +43,7 @@
     .\scripts\build-windows.ps1 -Version 1.2.3 -RunTests
 #>
 param(
-    [string]$Version = "1.0.0",
+    [string]$Version = "3.0.0",
     [string]$Output = "bin\rmqctl.exe",
     [switch]$RunTests
 )
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqClusterMetricsCollector.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqClusterMetricsCollector.java
index 9196ca196..3bedeb43e 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqClusterMetricsCollector.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqClusterMetricsCollector.java
@@ -104,12 +104,14 @@ public class ApacheRocketMqClusterMetricsCollector 
implements ClusterMetricsColl
                 : Map.of("brokerName", brokerName);
         if (!StringUtils.hasText(address)) {
             samples.add(unavailable(BROKER_AVAILABILITY, instance, clusterId, 
labels, collectedAt));
+            addUnavailableRuntimeMetrics(instance, clusterId, labels, 
collectedAt, samples);
             return;
         }
         try {
             KVTable runtime = admin.fetchBrokerRuntimeStats(address);
             if (runtime == null || runtime.getTable() == null) {
                 samples.add(unavailable(BROKER_AVAILABILITY, instance, 
clusterId, labels, collectedAt));
+                addUnavailableRuntimeMetrics(instance, clusterId, labels, 
collectedAt, samples);
                 return;
             }
             samples.add(available(BROKER_AVAILABILITY, instance, clusterId, 
labels, 1D, collectedAt));
@@ -125,9 +127,23 @@ public class ApacheRocketMqClusterMetricsCollector 
implements ClusterMetricsColl
             log.warn("Failed to collect runtime metrics for broker {} on 
instance {}: {}", brokerName,
                     instance.getName(), error.getMessage());
             samples.add(unavailable(BROKER_AVAILABILITY, instance, clusterId, 
labels, collectedAt));
+            addUnavailableRuntimeMetrics(instance, clusterId, labels, 
collectedAt, samples);
         }
     }
 
+    /**
+     * A broker whose runtime stats cannot be read at all must still report an 
unavailable sample for
+     * each metric those stats carry: alert reconciliation treats a missing 
sample as "the value
+     * cleared" and would resolve an active disk/JVM/send-queue incident on 
the collection failure
+     * itself. The parse-miss branches already do this through {@code 
metricOrUnavailable}.
+     */
+    private void addUnavailableRuntimeMetrics(InstanceVO instance, String 
clusterId,
+            Map<String, String> labels, Instant collectedAt, 
List<MetricSample> samples) {
+        samples.add(unavailable(BROKER_DISK_USAGE_RATIO, instance, clusterId, 
labels, collectedAt));
+        samples.add(unavailable(BROKER_JVM_HEAP_USAGE_RATIO, instance, 
clusterId, labels, collectedAt));
+        samples.add(unavailable(BROKER_SEND_QUEUE_USAGE_RATIO, instance, 
clusterId, labels, collectedAt));
+    }
+
     private static java.util.Optional<Double> parseDiskUsage(String raw) {
         if (!StringUtils.hasText(raw)) {
             return java.util.Optional.empty();
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigService.java 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigService.java
index 07e8ce423..138087787 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigService.java
@@ -132,6 +132,10 @@ public class LlmConfigService {
                 .llmProvider(normalized.getProvider())
                 .llmEngine(normalized.getEngine())
                 .apiKey(persistedApiKey)
+                // Carry the clear intent into the persisted settings: without 
it a blank key plus
+                // clearApiKey=false means "keep the stored key" to 
SettingsService's merge, so the
+                // secret the operator just removed would be written straight 
back.
+                .clearApiKey(normalized.isClearApiKey())
                 .model(normalized.getModel())
                 .baseUrl(normalized.getApiBase())
                 .deploymentName(normalized.getDeploymentName())
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConverters.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConverters.java
index 4c8595eda..708bea0b9 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConverters.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConverters.java
@@ -164,6 +164,11 @@ final class AliyunConverters {
                 .group(data.getConsumerGroupId())
                 .consumeType(toConsumeType(data.getMessageModel()))
                 .messageModel(data.getMessageModel())
+                // ListTopicSubscriptions carries no lag or TPS field, so the 
VO defaults (0/0)
+                // would render as measured "0 backlog / 0 TPS" in the console 
and in the AI tool
+                // output. Flag them unavailable, the same way the Apache 
provider does when
+                // consume stats cannot be read.
+                .metricsAvailable(false)
                 .build();
     }
 
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqClusterMetricsCollectorTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqClusterMetricsCollectorTest.java
index 2ffaf2a4c..79f3cfedb 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqClusterMetricsCollectorTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqClusterMetricsCollectorTest.java
@@ -123,6 +123,74 @@ class ApacheRocketMqClusterMetricsCollectorTest {
                 .isEmpty();
     }
 
+    @Test
+    void 
emitsUnavailableSamplesForEveryRuntimeMetricWhenTheStatsCallFailsTest() throws 
Exception {
+        RuntimeAdminClientResolver resolver = 
mock(RuntimeAdminClientResolver.class);
+        MQAdminExt admin = mock(MQAdminExt.class);
+        InstanceVO instance = apacheInstance();
+        ClusterInfo topology = new ClusterInfo();
+        topology.setBrokerAddrTable(Map.of("broker-a", new 
BrokerData("cluster-a", "broker-a",
+                new HashMap<>(Map.of(0L, "broker-a:10911")))));
+        when(admin.examineBrokerClusterInfo()).thenReturn(topology);
+        when(admin.fetchBrokerRuntimeStats("broker-a:10911"))
+                .thenThrow(new IllegalStateException("broker restarting"));
+        when(resolver.execute(eq(instance), 
any(MqAdminExtFactory.AdminAction.class)))
+                .thenAnswer(invocation -> 
invocation.<MqAdminExtFactory.AdminAction<Object>>getArgument(1)
+                        .apply(admin));
+
+        List<MetricSample> samples = new 
ApacheRocketMqClusterMetricsCollector(resolver).collect(instance);
+
+        // Without a sample of its own, an active disk/JVM/send-queue alert is 
reconciled as cleared
+        // by the very collection that failed to read the value.
+        assertUnavailableBrokerMetric(samples, "broker.disk.usage_ratio");
+        assertUnavailableBrokerMetric(samples, "broker.jvm.heap.usage_ratio");
+        assertUnavailableBrokerMetric(samples, 
"broker.send_queue.usage_ratio");
+        assertThat(samples).filteredOn(sample -> 
sample.metricKey().equals("broker.availability"))
+                .singleElement().satisfies(sample ->
+                        
assertThat(sample.availability()).isEqualTo(MetricAvailability.UNAVAILABLE));
+    }
+
+    @Test
+    void emitsUnavailableRuntimeMetricsWhenTheBrokerHasNoMasterAddressTest() 
throws Exception {
+        RuntimeAdminClientResolver resolver = 
mock(RuntimeAdminClientResolver.class);
+        MQAdminExt admin = mock(MQAdminExt.class);
+        InstanceVO instance = apacheInstance();
+        ClusterInfo topology = new ClusterInfo();
+        // A BrokerData with no 0L entry has no master address to read runtime 
stats from.
+        topology.setBrokerAddrTable(Map.of("broker-a", new 
BrokerData("cluster-a", "broker-a", new HashMap<>())));
+        when(admin.examineBrokerClusterInfo()).thenReturn(topology);
+        when(resolver.execute(eq(instance), 
any(MqAdminExtFactory.AdminAction.class)))
+                .thenAnswer(invocation -> 
invocation.<MqAdminExtFactory.AdminAction<Object>>getArgument(1)
+                        .apply(admin));
+
+        List<MetricSample> samples = new 
ApacheRocketMqClusterMetricsCollector(resolver).collect(instance);
+
+        assertUnavailableBrokerMetricWithoutAddress(samples, 
"broker.disk.usage_ratio");
+        assertUnavailableBrokerMetricWithoutAddress(samples, 
"broker.jvm.heap.usage_ratio");
+        assertUnavailableBrokerMetricWithoutAddress(samples, 
"broker.send_queue.usage_ratio");
+    }
+
+    @Test
+    void emitsUnavailableRuntimeMetricsWhenTheStatsTableIsNullTest() throws 
Exception {
+        RuntimeAdminClientResolver resolver = 
mock(RuntimeAdminClientResolver.class);
+        MQAdminExt admin = mock(MQAdminExt.class);
+        InstanceVO instance = apacheInstance();
+        ClusterInfo topology = new ClusterInfo();
+        topology.setBrokerAddrTable(Map.of("broker-a", new 
BrokerData("cluster-a", "broker-a",
+                new HashMap<>(Map.of(0L, "broker-a:10911")))));
+        when(admin.examineBrokerClusterInfo()).thenReturn(topology);
+        when(admin.fetchBrokerRuntimeStats("broker-a:10911")).thenReturn(null);
+        when(resolver.execute(eq(instance), 
any(MqAdminExtFactory.AdminAction.class)))
+                .thenAnswer(invocation -> 
invocation.<MqAdminExtFactory.AdminAction<Object>>getArgument(1)
+                        .apply(admin));
+
+        List<MetricSample> samples = new 
ApacheRocketMqClusterMetricsCollector(resolver).collect(instance);
+
+        assertUnavailableBrokerMetric(samples, "broker.disk.usage_ratio");
+        assertUnavailableBrokerMetric(samples, "broker.jvm.heap.usage_ratio");
+        assertUnavailableBrokerMetric(samples, 
"broker.send_queue.usage_ratio");
+    }
+
     private static void assertUnavailableBrokerMetric(List<MetricSample> 
samples, String metricKey) {
         assertThat(samples).filteredOn(sample -> 
sample.metricKey().equals(metricKey))
                 .singleElement().satisfies(sample -> {
@@ -133,6 +201,16 @@ class ApacheRocketMqClusterMetricsCollectorTest {
                 });
     }
 
+    private static void 
assertUnavailableBrokerMetricWithoutAddress(List<MetricSample> samples, String 
metricKey) {
+        assertThat(samples).filteredOn(sample -> 
sample.metricKey().equals(metricKey))
+                .singleElement().satisfies(sample -> {
+                    
assertThat(sample.availability()).isEqualTo(MetricAvailability.UNAVAILABLE);
+                    assertThat(sample.value()).isNull();
+                    assertThat(sample.labels()).containsEntry("brokerName", 
"broker-a")
+                            .doesNotContainKey("brokerAddr");
+                });
+    }
+
     private static InstanceVO apacheInstance() {
         return 
InstanceVO.builder().name("local").endpoint("localhost:9876").vendor(InstanceVendor.APACHE).build();
     }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/LlmConfigServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/LlmConfigServiceTest.java
index 04064a476..4c5f7e5ef 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/LlmConfigServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/LlmConfigServiceTest.java
@@ -308,11 +308,35 @@ class LlmConfigServiceTest {
         ArgumentCaptor<GeneralSettingsVO> captor = 
ArgumentCaptor.forClass(GeneralSettingsVO.class);
         verify(settingsService).saveGeneralSettings(captor.capture());
         assertThat(captor.getValue().getApiKey()).isBlank();
+        // A blank key alone is not the clear signal: SettingsService's merge 
reads a blank key
+        // with clearApiKey=false as "keep the stored key" and persists it 
again, so the flag has
+        // to survive the rebuild of the settings VO.
+        assertThat(captor.getValue().isClearApiKey()).isTrue();
         
when(settingsService.getGeneralSettings()).thenReturn(captor.getValue());
         assertThat(llmConfigService.getConfig().getApiKey()).isBlank();
         
assertThat(llmConfigService.getConfig().isApiKeyConfigured()).isFalse();
     }
 
+    @Test
+    void saveConfigShouldNotAskToClearTheStoredApiKeyOnAnOrdinarySaveTest() {
+        llmConfigService.saveConfig(LlmConfigVO.builder()
+                .provider("deepseek")
+                .apiKey("sk-new")
+                .apiBase("https://api.deepseek.com/v1";)
+                .model("deepseek-chat")
+                .maxTokens(8192)
+                .temperature(0.2)
+                .enabled(true)
+                .build());
+
+        ArgumentCaptor<GeneralSettingsVO> captor = 
ArgumentCaptor.forClass(GeneralSettingsVO.class);
+        verify(settingsService).saveGeneralSettings(captor.capture());
+        // The merge must keep filling in a key the form left blank, so an 
ordinary save must not
+        // carry a clear flag.
+        assertThat(captor.getValue().isClearApiKey()).isFalse();
+        assertThat(captor.getValue().getApiKey()).isEqualTo("sk-new");
+    }
+
     @Test
     void saveConfigShouldNormalizeChatCompletionsEndpointToApiBase() {
         llmConfigService.saveConfig(LlmConfigVO.builder()
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConvertersTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConvertersTest.java
index 148624940..4629bf38e 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConvertersTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConvertersTest.java
@@ -18,8 +18,10 @@ package org.apache.rocketmq.studio.provider.alibaba;
 
 import 
com.aliyun.sdk.service.rocketmq20220801.models.ListConsumerGroupSubscriptionsResponseBody;
 import 
com.aliyun.sdk.service.rocketmq20220801.models.ListInstancesResponseBody;
+import 
com.aliyun.sdk.service.rocketmq20220801.models.ListTopicSubscriptionsResponseBody;
 import com.aliyun.sdk.service.rocketmq20220801.models.ListTopicsResponseBody;
 import org.apache.rocketmq.studio.common.domain.enums.TopicType;
+import org.apache.rocketmq.studio.instance.topic.TopicConsumerVO;
 import org.apache.rocketmq.studio.instance.topic.TopicVO;
 import org.apache.rocketmq.studio.instance.group.SubscriptionEntryVO;
 import org.junit.jupiter.api.Test;
@@ -61,6 +63,24 @@ class AliyunConvertersTest {
         assertThat(vo.getType()).isEqualTo(TopicType.LITE);
     }
 
+    @Test
+    void toTopicConsumerVoShouldMarkTheUngettableLagAndTpsUnavailableTest() {
+        // ListTopicSubscriptionsResponseBody.Data carries only consistency, 
consumerGroupId,
+        // filterExpression, filterExpressionType, messageModel, 
subscriptionStatus and topicName -
+        // no lag or TPS. Leaving the VO defaults would present 0 backlog / 0 
TPS as measured
+        // values; the Apache provider flags the same gap with 
metricsAvailable(false), which is
+        // the only value the console renders as unavailable.
+        ListTopicSubscriptionsResponseBody.Data data = 
ListTopicSubscriptionsResponseBody.Data.builder()
+                .consumerGroupId("cg-orders")
+                .messageModel("CLUSTERING")
+                .build();
+
+        TopicConsumerVO consumer = AliyunConverters.toTopicConsumerVO(data);
+
+        assertThat(consumer.getGroup()).isEqualTo("cg-orders");
+        assertThat(consumer.isMetricsAvailable()).isFalse();
+    }
+
     @Test
     void toInstanceOptionShouldClampCountsOutsideTheIntegerRange() {
         ListInstancesResponseBody.List data = 
ListInstancesResponseBody.List.builder()

Reply via email to