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 9a055ed06 feat(metrics): default to 5.x native profile with
multi-panel explorer and multi-cluster health overview (#2838)
9a055ed06 is described below
commit 9a055ed061a6e91ca92a82873c8d9b468bd118d3
Author: lizhimins <[email protected]>
AuthorDate: Mon Aug 31 16:35:52 2026 +0800
feat(metrics): default to 5.x native profile with multi-panel explorer and
multi-cluster health overview (#2838)
---
.../cluster/metrics/MetricProfileService.java | 34 +-
.../cluster/metrics/PrometheusProperties.java | 2 +
.../studio/cluster/metrics/SemanticMetric.java | 2 +
.../provider/apache/RocketMQDashboardProvider.java | 116 ++++-
.../cluster/metrics/MetricProfileServiceTest.java | 75 +++-
.../apache/RocketMQDashboardProviderTest.java | 98 ++++-
web/src/components/MetricsExplorer.tsx | 474 ++++++++++++++-------
.../components/__tests__/MetricsExplorer.test.tsx | 79 +++-
8 files changed, 710 insertions(+), 170 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricProfileService.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricProfileService.java
index d41e574e8..24990ae07 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricProfileService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricProfileService.java
@@ -16,19 +16,32 @@
*/
package org.apache.rocketmq.studio.cluster.metrics;
+import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
+import java.util.ArrayList;
import java.util.List;
@Service
+@RequiredArgsConstructor
public class MetricProfileService {
+ private final PrometheusProperties prometheusProperties;
+
public List<MetricProfileVO> listProfiles() {
- return List.of(
+ List<MetricProfileVO> profiles = new ArrayList<>(List.of(
profile(MetricProfile.ROCKETMQ_4_EXPORTER,
rocketmq4ExporterMetrics()),
profile(MetricProfile.ROCKETMQ_5_NATIVE,
rocketmq5NativeMetrics())
- );
+ ));
+ String preferredId = prometheusProperties.getProfile();
+ for (int i = 1; i < profiles.size(); i++) {
+ if (profiles.get(i).getId().equals(preferredId)) {
+ profiles.add(0, profiles.remove(i));
+ break;
+ }
+ }
+ return List.copyOf(profiles);
}
public String resolvePromql(String profileId, String semanticMetric) {
@@ -97,12 +110,19 @@ public class MetricProfileService {
mapping(SemanticMetric.CONSUMER_LAG_MESSAGES,
"rocketmq_consumer_lag_messages",
"sum(rocketmq_consumer_lag_messages) by (cluster,
topic, consumer_group)",
"cluster", "topic", "consumer_group"),
- mapping(SemanticMetric.CONSUMER_LAG_LATENCY,
"rocketmq_consumer_lag_latency",
- "max(rocketmq_consumer_lag_latency) by (cluster,
topic, consumer_group)",
+ mapping(SemanticMetric.CONSUMER_LAG_LATENCY,
"rocketmq_consumer_lag_latency_milliseconds",
+ "max(rocketmq_consumer_lag_latency_milliseconds) by
(cluster, topic, consumer_group)",
"cluster", "topic", "consumer_group"),
- mapping(SemanticMetric.BROKER_HEALTH,
"rocketmq_processor_watermark",
- "max(rocketmq_processor_watermark) by (cluster,
node_id, processor)",
- "cluster", "node_id", "processor")
+ // Every broker reports the same cluster-level count; max
avoids double counting.
+ mapping(SemanticMetric.TOPIC_NUMBER, "rocketmq_topic_number",
+ "max(rocketmq_topic_number) by (cluster)",
+ "cluster"),
+ mapping(SemanticMetric.CONSUMER_GROUP_NUMBER,
"rocketmq_consumer_group_number",
+ "max(rocketmq_consumer_group_number) by (cluster)",
+ "cluster"),
+ mapping(SemanticMetric.BROKER_HEALTH, "up",
+ "min(up{job=~\".*rocketmq.*\"}) by (job, instance)",
+ "job", "instance")
);
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/PrometheusProperties.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/PrometheusProperties.java
index c34f43ec5..62f5dabf9 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/PrometheusProperties.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/PrometheusProperties.java
@@ -34,4 +34,6 @@ public class PrometheusProperties {
private String username;
private String password;
private String bearerToken;
+ /** Metric profile id listed first by the profile catalog; defaults to the
5.x native profile. */
+ private String profile = "rocketmq5-native";
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/SemanticMetric.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/SemanticMetric.java
index c3c66f247..0cdb164a3 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/SemanticMetric.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/SemanticMetric.java
@@ -23,6 +23,8 @@ public enum SemanticMetric {
THROUGHPUT_OUT("throughput_out", "Throughput Out", "bytes/s"),
CONSUMER_LAG_MESSAGES("consumer_lag_messages", "Consumer Lag", "messages"),
CONSUMER_LAG_LATENCY("consumer_lag_latency", "Consumer Lag Latency", "ms"),
+ TOPIC_NUMBER("topic_number", "Topic Count", ""),
+ CONSUMER_GROUP_NUMBER("consumer_group_number", "Consumer Group Count", ""),
BROKER_HEALTH("broker_health", "Broker Health", "up");
private final String key;
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDashboardProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDashboardProvider.java
index 205983b18..6fbe18459 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDashboardProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDashboardProvider.java
@@ -18,6 +18,7 @@ package org.apache.rocketmq.studio.provider.apache;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
@@ -36,6 +37,8 @@ import
org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.common.domain.enums.ClusterStatus;
import org.apache.rocketmq.studio.common.domain.enums.ClusterType;
import org.apache.rocketmq.studio.common.domain.enums.InstanceType;
+import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
+import org.apache.rocketmq.studio.instance.InstanceRepository;
import org.apache.rocketmq.studio.instance.InstanceVO;
import org.apache.rocketmq.studio.ops.dashboard.ClusterOverviewVO;
import org.apache.rocketmq.studio.ops.dashboard.DashboardDataVO;
@@ -65,16 +68,117 @@ public class RocketMQDashboardProvider implements
DashboardProvider {
private final MqAdminExtFactory adminFactory;
private final RocketMQProperties properties;
private final RuntimeAdminClientResolver runtimeAdminClientResolver;
+ private final InstanceRepository instanceRepository;
@Override
public DashboardDataVO getDashboardData() {
- String namesrvAddr = properties.getNamesrvAddr();
- if (!StringUtils.hasText(namesrvAddr)) {
- log.warn("NameServer address not configured, returning empty
dashboard");
- return unavailableTopologyDashboard();
+ List<InstanceVO> apacheInstances =
instanceRepository.findAll().stream()
+ .filter(instance -> instance.getVendor() == null ||
instance.getVendor() == InstanceVendor.APACHE)
+ .sorted(Comparator.comparing(InstanceVO::getName,
String.CASE_INSENSITIVE_ORDER))
+ .toList();
+ if (apacheInstances.isEmpty()) {
+ String namesrvAddr = properties.getNamesrvAddr();
+ if (!StringUtils.hasText(namesrvAddr)) {
+ log.warn("NameServer address not configured, returning empty
dashboard");
+ return unavailableTopologyDashboard();
+ }
+ return adminFactory.execute(namesrvAddr, null,
+ admin -> collectDashboardData(admin,
ClusterType.V5_PROXY_CLUSTER, countEndpoints(namesrvAddr)));
}
- return adminFactory.execute(namesrvAddr, null,
- admin -> collectDashboardData(admin,
ClusterType.V5_PROXY_CLUSTER, countEndpoints(namesrvAddr)));
+ return aggregateInstances(apacheInstances);
+ }
+
+ /**
+ * Aggregates every registered Apache instance into one overview.
Instances whose endpoint
+ * cannot be reached contribute a warning row instead of failing the whole
dashboard.
+ */
+ private DashboardDataVO aggregateInstances(List<InstanceVO> instances) {
+ int totalClusters = 0;
+ int healthyClusters = 0;
+ int totalBrokers = 0;
+ int totalNameServers = 0;
+ int totalTopics = 0;
+ int totalGroups = 0;
+ long tpsIn = 0;
+ long tpsOut = 0;
+ long messagesToday = 0;
+ List<ClusterOverviewVO> clusters = new ArrayList<>();
+
+ for (InstanceVO instance : instances) {
+ DashboardDataVO part;
+ try {
+ part = getDashboardData(instance.getName());
+ } catch (Exception e) {
+ log.warn("Failed to collect dashboard data for instance {}:
{}",
+ instance.getName(), e.getMessage());
+ clusters.add(unavailableInstanceCluster(instance));
+ totalClusters++;
+ continue;
+ }
+ DashboardStatsVO stats = part.getStats();
+ totalClusters += stats.getTotalClusters();
+ healthyClusters += stats.getHealthyClusters();
+ totalBrokers += stats.getTotalBrokers();
+ totalNameServers += stats.getTotalNameServers() == null ? 0 :
stats.getTotalNameServers();
+ totalTopics += stats.getTotalTopics();
+ totalGroups += stats.getTotalConsumerGroups();
+ tpsIn += stats.getTpsIn();
+ tpsOut += stats.getTpsOut();
+ messagesToday += stats.getTotalMessagesToday();
+ for (ClusterOverviewVO cluster : part.getClusters()) {
+ clusters.add(scopeClusterToInstance(instance.getName(),
cluster));
+ }
+ }
+
+ DashboardStatsVO stats = DashboardStatsVO.builder()
+ .totalClusters(totalClusters)
+ .healthyClusters(healthyClusters)
+ .totalBrokers(totalBrokers)
+ .totalProxies(null)
+ .totalNameServers(totalNameServers)
+ .totalTopics(totalTopics)
+ .totalConsumerGroups(totalGroups)
+ .totalMessagesToday(messagesToday)
+ .messagesPerSecond(tpsIn + tpsOut)
+ .tpsIn(tpsIn)
+ .tpsOut(tpsOut)
+ .build();
+ return
DashboardDataVO.builder().stats(stats).clusters(clusters).build();
+ }
+
+ private ClusterOverviewVO scopeClusterToInstance(String instanceName,
ClusterOverviewVO cluster) {
+ return ClusterOverviewVO.builder()
+ .id(instanceName + "/" + cluster.getId())
+ .name(instanceName + " / " + cluster.getName())
+ .type(cluster.getType())
+ .status(cluster.getStatus())
+ .brokers(cluster.getBrokers())
+ .proxies(cluster.getProxies())
+ .topics(cluster.getTopics())
+ .groups(cluster.getGroups())
+ .tpsIn(cluster.getTpsIn())
+ .tpsOut(cluster.getTpsOut())
+ .version(cluster.getVersion())
+ .throughput(cluster.getThroughput())
+ .build();
+ }
+
+ private ClusterOverviewVO unavailableInstanceCluster(InstanceVO instance) {
+ ClusterType clusterType = clusterTypeFor(instance);
+ return ClusterOverviewVO.builder()
+ .id(instance.getName())
+ .name(instance.getName())
+ .type(clusterType)
+ .status(ClusterStatus.warning)
+ .brokers(0)
+ .proxies(clusterType == ClusterType.V4_DIRECT ? 0 : null)
+ .topics(0)
+ .groups(0)
+ .tpsIn(0)
+ .tpsOut(0)
+ .version("unknown")
+ .throughput(List.of())
+ .build();
}
@Override
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricProfileServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricProfileServiceTest.java
index e310cdf61..73024103d 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricProfileServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricProfileServiceTest.java
@@ -28,7 +28,25 @@ import static
org.assertj.core.api.Assertions.assertThatExceptionOfType;
class MetricProfileServiceTest {
- private final MetricProfileService service = new MetricProfileService();
+ private final MetricProfileService service = new MetricProfileService(new
PrometheusProperties());
+
+ @Test
+ void listProfilesShouldDefaultToRocketmq5NativeFirstTest() {
+
assertThat(service.listProfiles().stream().map(MetricProfileVO::getId).toList())
+ .containsExactly("rocketmq5-native", "rocketmq4-exporter");
+ }
+
+ @Test
+ void listProfilesShouldHonorConfiguredDefaultProfileTest() {
+ PrometheusProperties properties = new PrometheusProperties();
+ properties.setProfile("rocketmq4-exporter");
+
+ List<String> ids = new
MetricProfileService(properties).listProfiles().stream()
+ .map(MetricProfileVO::getId)
+ .toList();
+
+ assertThat(ids).containsExactly("rocketmq4-exporter",
"rocketmq5-native");
+ }
@Test
void listProfilesShouldExposeRocketmq4And5Mappings() {
@@ -36,12 +54,39 @@ class MetricProfileServiceTest {
.collect(Collectors.toMap(MetricProfileVO::getId,
Function.identity()));
assertThat(profiles.keySet()).containsExactlyInAnyOrder("rocketmq4-exporter",
"rocketmq5-native");
+ // The standalone 4.x exporter exposes no topic/group counts, so its
mapping
+ // set stays at the legacy seven metrics while the 5.x profile covers
all nine.
assertThat(semanticMetrics(profiles.get("rocketmq4-exporter")))
- .containsExactlyInAnyOrderElementsOf(allSemanticMetricKeys());
+
.containsExactlyInAnyOrderElementsOf(legacySemanticMetricKeys());
assertThat(semanticMetrics(profiles.get("rocketmq5-native")))
.containsExactlyInAnyOrderElementsOf(allSemanticMetricKeys());
}
+ @Test
+ void rocketmq5ProfileShouldOrderPanelsTrafficLagCountsHealthTest() {
+ MetricProfileVO profile = findProfile("rocketmq5-native");
+
+ assertThat(profile.getMetrics().stream()
+ .map(MetricProfileVO.MetricMappingVO::getSemanticMetric)
+ .toList())
+ .containsExactly(
+ "message_in_tps", "message_out_tps",
+ "throughput_in", "throughput_out",
+ "consumer_lag_messages", "consumer_lag_latency",
+ "topic_number", "consumer_group_number",
+ "broker_health");
+ }
+
+ @Test
+ void rocketmq5CountsShouldUseMaxToAvoidDoubleCountingTest() {
+ MetricProfileVO profile = findProfile("rocketmq5-native");
+
+ assertThat(mapping(profile, SemanticMetric.TOPIC_NUMBER).getPromql())
+ .isEqualTo("max(rocketmq_topic_number) by (cluster)");
+ assertThat(mapping(profile,
SemanticMetric.CONSUMER_GROUP_NUMBER).getPromql())
+ .isEqualTo("max(rocketmq_consumer_group_number) by (cluster)");
+ }
+
@Test
void rocketmq5ProfileShouldUseNativeMetricNames() {
MetricProfileVO profile = findProfile("rocketmq5-native");
@@ -54,7 +99,18 @@ class MetricProfileServiceTest {
assertThat(mapping(profile,
SemanticMetric.CONSUMER_LAG_MESSAGES).getPrometheusMetric())
.isEqualTo("rocketmq_consumer_lag_messages");
assertThat(mapping(profile,
SemanticMetric.BROKER_HEALTH).getPrometheusMetric())
- .isEqualTo("rocketmq_processor_watermark");
+ .isEqualTo("up");
+ }
+
+ @Test
+ void rocketmq5LagLatencyShouldUseUnitSuffixedMetricNameTest() {
+ MetricProfileVO profile = findProfile("rocketmq5-native");
+
+ assertThat(mapping(profile, SemanticMetric.CONSUMER_LAG_LATENCY))
+
.extracting(MetricProfileVO.MetricMappingVO::getPrometheusMetric,
+ MetricProfileVO.MetricMappingVO::getPromql)
+ .containsExactly("rocketmq_consumer_lag_latency_milliseconds",
+ "max(rocketmq_consumer_lag_latency_milliseconds) by
(cluster, topic, consumer_group)");
}
@Test
@@ -129,6 +185,19 @@ class MetricProfileServiceTest {
.toList();
}
+ private List<String> legacySemanticMetricKeys() {
+ return List.of(
+ SemanticMetric.MESSAGE_IN_TPS,
+ SemanticMetric.MESSAGE_OUT_TPS,
+ SemanticMetric.THROUGHPUT_IN,
+ SemanticMetric.THROUGHPUT_OUT,
+ SemanticMetric.CONSUMER_LAG_MESSAGES,
+ SemanticMetric.CONSUMER_LAG_LATENCY,
+ SemanticMetric.BROKER_HEALTH).stream()
+ .map(SemanticMetric::getKey)
+ .toList();
+ }
+
private void assertBadRequest(Runnable action, String message) {
assertThatExceptionOfType(PrometheusException.class)
.isThrownBy(action::run)
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDashboardProviderTest.java
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDashboardProviderTest.java
index d00dc6fa1..46172fa7b 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDashboardProviderTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDashboardProviderTest.java
@@ -36,7 +36,10 @@ import
org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.common.domain.enums.ClusterStatus;
import org.apache.rocketmq.studio.common.domain.enums.ClusterType;
import org.apache.rocketmq.studio.common.domain.enums.InstanceType;
+import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
+import org.apache.rocketmq.studio.instance.InstanceRepository;
import org.apache.rocketmq.studio.instance.InstanceVO;
+import org.apache.rocketmq.studio.ops.dashboard.ClusterOverviewVO;
import org.apache.rocketmq.studio.ops.dashboard.DashboardDataVO;
import org.apache.rocketmq.tools.admin.DefaultMQAdminExt;
import org.junit.jupiter.api.Test;
@@ -434,8 +437,11 @@ class RocketMQDashboardProviderTest {
void dashboardShouldReportUnconfiguredLegacyTopologyAsUnavailable() {
RocketMQProperties properties = new RocketMQProperties();
properties.setNamesrvAddr(" ");
+ InstanceRepository instanceRepository = mock(InstanceRepository.class);
+ when(instanceRepository.findAll()).thenReturn(List.of());
RocketMQDashboardProvider provider = new RocketMQDashboardProvider(
- mock(MqAdminExtFactory.class), properties,
mock(RuntimeAdminClientResolver.class));
+ mock(MqAdminExtFactory.class), properties,
mock(RuntimeAdminClientResolver.class),
+ instanceRepository);
DashboardDataVO dashboard = provider.getDashboardData();
@@ -539,17 +545,105 @@ class RocketMQDashboardProviderTest {
});
}
+ @Test
+ void dashboardShouldAggregateAllApacheInstancesTest() throws Exception {
+ DefaultMQAdminExt adminExt = mock(DefaultMQAdminExt.class);
+ RuntimeAdminClientResolver resolver =
mock(RuntimeAdminClientResolver.class);
+ InstanceVO first = apacheInstance("cluster-a", InstanceType.DIRECT);
+ InstanceVO second = apacheInstance("cluster-b",
InstanceType.PROXY_LOCAL);
+ InstanceVO cloud = InstanceVO.builder()
+ .name("cloud-instance").type(InstanceType.CLOUD)
+
.endpoint("rmq.aliyuncs.com:8080").vendor(InstanceVendor.ALIYUN).build();
+ when(resolver.resolveInstance("cluster-a")).thenReturn(first);
+ when(resolver.resolveInstance("cluster-b")).thenReturn(second);
+ when(resolver.execute(any(InstanceVO.class),
any())).thenAnswer(invocation ->
+
invocation.<MqAdminExtFactory.AdminAction<DashboardDataVO>>getArgument(1).apply(adminExt));
+ when(adminExt.examineBrokerClusterInfo()).thenReturn(clusterInfo());
+ when(adminExt.getAllTopicConfig("10.0.0.11:10911",
5000)).thenReturn(topicConfig("orders"));
+ when(adminExt.getAllSubscriptionGroup("10.0.0.11:10911",
5000)).thenReturn(subscriptionGroups());
+
when(adminExt.fetchBrokerRuntimeStats("10.0.0.11:10911")).thenReturn(runtimeStats());
+
+ DashboardDataVO dashboard =
+ newProvider(adminExt, resolver, List.of(first, second,
cloud)).getDashboardData();
+
+ // Cloud instances stay out of the Apache-runtime overview.
+
assertThat(dashboard.getClusters()).extracting(ClusterOverviewVO::getName)
+ .containsExactly("cluster-a / DefaultCluster", "cluster-b /
DefaultCluster");
+
assertThat(dashboard.getClusters()).extracting(ClusterOverviewVO::getId)
+ .containsExactly("cluster-a/DefaultCluster",
"cluster-b/DefaultCluster");
+ assertThat(dashboard.getClusters()).allSatisfy(cluster ->
+
assertThat(cluster.getStatus()).isEqualTo(ClusterStatus.healthy));
+ assertThat(dashboard.getStats().getTotalClusters()).isEqualTo(2);
+ assertThat(dashboard.getStats().getHealthyClusters()).isEqualTo(2);
+ assertThat(dashboard.getStats().getTotalBrokers()).isEqualTo(2);
+ assertThat(dashboard.getStats().getTotalTopics()).isEqualTo(2);
+ assertThat(dashboard.getStats().getTpsIn()).isEqualTo(4);
+ assertThat(dashboard.getStats().getTotalMessagesToday()).isEqualTo(84);
+ verify(resolver, never()).execute(eq(cloud), any());
+ }
+
+ @Test
+ void dashboardShouldFlagUnreachableInstancesAsWarningTest() throws
Exception {
+ DefaultMQAdminExt adminExt = mock(DefaultMQAdminExt.class);
+ RuntimeAdminClientResolver resolver =
mock(RuntimeAdminClientResolver.class);
+ InstanceVO reachable = apacheInstance("cluster-a",
InstanceType.DIRECT);
+ InstanceVO unreachable = apacheInstance("cluster-b",
InstanceType.DIRECT);
+ when(resolver.resolveInstance("cluster-a")).thenReturn(reachable);
+ when(resolver.resolveInstance("cluster-b")).thenReturn(unreachable);
+ when(resolver.execute(eq(reachable), any())).thenAnswer(invocation ->
+
invocation.<MqAdminExtFactory.AdminAction<DashboardDataVO>>getArgument(1).apply(adminExt));
+ when(resolver.execute(eq(unreachable), any()))
+ .thenThrow(new BusinessException(502, "connect to ns-b:9876
failed"));
+ when(adminExt.examineBrokerClusterInfo()).thenReturn(clusterInfo());
+ when(adminExt.getAllTopicConfig("10.0.0.11:10911",
5000)).thenReturn(topicConfig("orders"));
+ when(adminExt.getAllSubscriptionGroup("10.0.0.11:10911",
5000)).thenReturn(subscriptionGroups());
+
when(adminExt.fetchBrokerRuntimeStats("10.0.0.11:10911")).thenReturn(runtimeStats());
+
+ DashboardDataVO dashboard =
+ newProvider(adminExt, resolver, List.of(reachable,
unreachable)).getDashboardData();
+
+ assertThat(dashboard.getClusters()).hasSize(2);
+
assertThat(dashboard.getClusters().get(0).getName()).isEqualTo("cluster-a /
DefaultCluster");
+
assertThat(dashboard.getClusters().get(0).getStatus()).isEqualTo(ClusterStatus.healthy);
+ assertThat(dashboard.getClusters().get(1)).satisfies(cluster -> {
+ assertThat(cluster.getName()).isEqualTo("cluster-b");
+ assertThat(cluster.getStatus()).isEqualTo(ClusterStatus.warning);
+ assertThat(cluster.getBrokers()).isZero();
+ });
+ assertThat(dashboard.getStats().getTotalClusters()).isEqualTo(2);
+ assertThat(dashboard.getStats().getHealthyClusters()).isEqualTo(1);
+ assertThat(dashboard.getStats().getTotalBrokers()).isEqualTo(1);
+ }
+
+ private InstanceVO apacheInstance(String name, InstanceType type) {
+ InstanceVO instance = InstanceVO.builder()
+ .name(name)
+ .type(type)
+ .endpoint(type == InstanceType.DIRECT ? name + ":9876" : name
+ ":8080")
+ .vendor(InstanceVendor.APACHE)
+ .build();
+ instance.setId((long) name.length());
+ return instance;
+ }
+
private RocketMQDashboardProvider newProvider(DefaultMQAdminExt adminExt) {
return newProvider(adminExt, mock(RuntimeAdminClientResolver.class));
}
private RocketMQDashboardProvider newProvider(DefaultMQAdminExt adminExt,
RuntimeAdminClientResolver resolver) {
+ return newProvider(adminExt, resolver, List.of());
+ }
+
+ private RocketMQDashboardProvider newProvider(DefaultMQAdminExt adminExt,
RuntimeAdminClientResolver resolver,
+ List<InstanceVO> instances) {
MqAdminExtFactory adminFactory = mock(MqAdminExtFactory.class);
when(adminFactory.execute(anyString(), any(),
any())).thenAnswer(invocation ->
invocation.<MqAdminExtFactory.AdminAction<Object>>getArgument(2).apply(adminExt));
RocketMQProperties properties = new RocketMQProperties();
properties.setNamesrvAddr("10.0.0.1:9876");
- return new RocketMQDashboardProvider(adminFactory, properties,
resolver);
+ InstanceRepository instanceRepository = mock(InstanceRepository.class);
+ when(instanceRepository.findAll()).thenReturn(instances);
+ return new RocketMQDashboardProvider(adminFactory, properties,
resolver, instanceRepository);
}
private ClusterInfo clusterInfo() {
ClusterInfo info = new ClusterInfo();
diff --git a/web/src/components/MetricsExplorer.tsx
b/web/src/components/MetricsExplorer.tsx
index d85c1b979..dd6f35d54 100644
--- a/web/src/components/MetricsExplorer.tsx
+++ b/web/src/components/MetricsExplorer.tsx
@@ -19,6 +19,7 @@ import { useCallback, useEffect, useMemo, useRef, useState }
from 'react';
import {
Alert,
Button,
+ Card,
Empty,
Flex,
Form,
@@ -27,6 +28,7 @@ import {
Segmented,
Select,
Skeleton,
+ Spin,
Tag,
Tooltip,
Typography,
@@ -43,7 +45,7 @@ const { Text, Title } = Typography;
const CHART_WIDTH = 840;
const CHART_HEIGHT = 240;
-const CHART_PADDING = { top: 18, right: 18, bottom: 32, left: 64 };
+const CHART_PADDING = { top: 18, right: 18, bottom: 36, left: 76 };
const SERIES_COLORS = ['#1677ff', '#52c41a', '#fa8c16', '#722ed1', '#13c2c2',
'#eb2f96'];
const RANGE_OPTIONS = [
@@ -52,6 +54,14 @@ const RANGE_OPTIONS = [
{ label: '24h', value: '24h', seconds: 24 * 60 * 60, step: '5m' },
] as const;
+// High-cardinality queries (per topic/group) can return dozens of series;
keep the
+// busiest ones so the panel layout stays readable.
+const MAX_SERIES = 10;
+
+type RangeOption = (typeof RANGE_OPTIONS)[number];
+
+const PROFILE_STORAGE_KEY = 'rocketmq-studio.metric-profile';
+
interface NumericSample {
timestamp: number;
value: number;
@@ -118,6 +128,7 @@ interface MetricChartProps {
noSamples: string;
histogramLabel: string;
histogramTooltip: string;
+ hiddenSeriesText: (count: number) => string;
}
const MetricChart = ({
@@ -127,8 +138,9 @@ const MetricChart = ({
noSamples,
histogramLabel,
histogramTooltip,
+ hiddenSeriesText,
}: MetricChartProps) => {
- const chartSeries = data.series
+ const allSeries = data.series
.map((series, index) => {
const { samples, fromHistogram } = toNumericSamples(series);
return {
@@ -140,10 +152,20 @@ const MetricChart = ({
})
.filter((series) => series.samples.length > 0);
- if (chartSeries.length === 0) {
+ if (allSeries.length === 0) {
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={noSamples}
/>;
}
+ const latestValue = (series: { samples: NumericSample[] }) =>
+ series.samples[series.samples.length - 1].value;
+ const hiddenCount = Math.max(0, allSeries.length - MAX_SERIES);
+ const chartSeries =
+ hiddenCount === 0
+ ? allSeries
+ : [...allSeries]
+ .sort((left, right) => latestValue(right) - latestValue(left))
+ .slice(0, MAX_SERIES);
+
const samples = chartSeries.flatMap((series) => series.samples);
const timestamps = samples.map((sample) => sample.timestamp);
const values = samples.map((sample) => sample.value);
@@ -195,9 +217,9 @@ const MetricChart = ({
/>
<text
x={CHART_PADDING.left - 8}
- y={gridY + 4}
+ y={gridY + 6}
textAnchor="end"
- fontSize="11"
+ fontSize="20"
fill="#8c8c8c"
>
{formatMetricValue(gridValue)}
@@ -210,7 +232,7 @@ const MetricChart = ({
key={series.label}
fill="none"
stroke={series.color}
- strokeWidth="2"
+ strokeWidth="2.5"
strokeLinejoin="round"
strokeLinecap="round"
points={series.samples
@@ -222,7 +244,7 @@ const MetricChart = ({
x={CHART_PADDING.left}
y={CHART_HEIGHT - 8}
textAnchor="start"
- fontSize="11"
+ fontSize="20"
fill="#8c8c8c"
>
{formatTime(minTime)}
@@ -231,14 +253,14 @@ const MetricChart = ({
x={CHART_WIDTH - CHART_PADDING.right}
y={CHART_HEIGHT - 8}
textAnchor="end"
- fontSize="11"
+ fontSize="20"
fill="#8c8c8c"
>
{formatTime(maxTime)}
</text>
</svg>
- <Flex gap={16} wrap="wrap" style={{ marginTop: 8 }}>
+ <Flex gap="8px 16px" wrap="wrap" style={{ marginTop: 8 }}>
{chartSeries.map((series) => {
const latest = series.samples[series.samples.length - 1];
return (
@@ -246,12 +268,12 @@ const MetricChart = ({
key={series.label}
align="center"
gap={6}
- style={{ flex: '1 1 220px', minWidth: 0, maxWidth: '100%' }}
+ style={{ flex: '0 1 auto', minWidth: 0, maxWidth: '100%' }}
>
<span
- style={{ width: 18, height: 3, background: series.color,
display: 'inline-block' }}
+ style={{ width: 14, height: 3, background: series.color,
display: 'inline-block' }}
/>
- <Text ellipsis={{ tooltip: series.label }} style={{ maxWidth:
220 }}>
+ <Text type="secondary" ellipsis={{ tooltip: series.label }}
style={{ maxWidth: 160 }}>
{series.label}
</Text>
{series.fromHistogram ? (
@@ -262,11 +284,17 @@ const MetricChart = ({
</Tooltip>
) : null}
<Text strong>
- {formatMetricValue(latest.value)} {metric.unit}
+ {formatMetricValue(latest.value)}
+ {metric.unit ? ` ${metric.unit}` : ''}
</Text>
</Flex>
);
})}
+ {hiddenCount > 0 ? (
+ <Text type="secondary" style={{ flex: '1 1 100%' }}>
+ {hiddenSeriesText(hiddenCount)}
+ </Text>
+ ) : null}
</Flex>
</div>
);
@@ -288,6 +316,12 @@ interface DataSourceCredentials extends AuthFormValues {
key: string;
}
+interface PanelState {
+ loading: boolean;
+ data?: MetricData;
+ error?: string;
+}
+
const getQueryErrorMessage = (error: unknown, fallback: string): string => {
if (typeof error !== 'object' || error === null || !('response' in error)) {
return fallback;
@@ -322,16 +356,21 @@ const MetricsExplorer = ({ instanceId }:
MetricsExplorerProps) => {
? {
title: 'Prometheus 指标',
profile: '指标模板',
- metric: '指标',
range: '时间范围',
- refresh: '刷新指标',
+ refresh: '刷新全部面板',
profileError: '指标模板加载失败',
- queryError: queryErrorFallback,
noProfiles: '暂无指标模板',
noSamples: '暂无数据',
histogram: '直方图',
histogramTooltip: '无标量样本,趋势由直方图观测值推导',
+ hiddenSeries: (count: number) =>
+ `另有 ${count} 条序列未显示(按最新值保留前 ${MAX_SERIES} 条)`,
defaultDataSource: '默认数据源',
+ customTitle: '自定义查询',
+ customPlaceholder:
+ '输入 PromQL,如 sum(rate(rocketmq_messages_in_total[1m])) by
(cluster)',
+ customRun: '查询',
+ customEmpty: '输入 PromQL 后点击查询',
authTitle: '数据源认证',
authDescription: '凭据仅用于当前数据源,离开该数据源后会被清除。',
username: '用户名',
@@ -344,16 +383,21 @@ const MetricsExplorer = ({ instanceId }:
MetricsExplorerProps) => {
: {
title: 'Prometheus Metrics',
profile: 'Metric profile',
- metric: 'Metric',
range: 'Time range',
- refresh: 'Refresh metrics',
+ refresh: 'Refresh all panels',
profileError: 'Failed to load metric profiles',
- queryError: queryErrorFallback,
noProfiles: 'No metric profiles',
noSamples: 'No samples',
histogram: 'Histogram',
histogramTooltip: 'No scalar samples; trend derived from histogram
observations',
+ hiddenSeries: (count: number) =>
+ `${count} more series hidden (showing top ${MAX_SERIES} by latest
value)`,
defaultDataSource: 'Default source',
+ customTitle: 'Custom query',
+ customPlaceholder:
+ 'Enter PromQL, e.g. sum(rate(rocketmq_messages_in_total[1m])) by
(cluster)',
+ customRun: 'Run',
+ customEmpty: 'Enter a PromQL expression and run the query',
authTitle: 'Data source authentication',
authDescription:
'Credentials are used only for this source and cleared when you
leave it.',
@@ -364,22 +408,23 @@ const MetricsExplorer = ({ instanceId }:
MetricsExplorerProps) => {
cancel: 'Cancel',
required: 'This field is required',
};
+ const locale = lang === 'zh' ? 'zh-CN' : 'en-US';
const [authForm] = Form.useForm<AuthFormValues>();
const [profiles, setProfiles] = useState<MetricProfile[]>([]);
const [profileId, setProfileId] = useState('');
- const [metricId, setMetricId] = useState('');
- const [rangeId, setRangeId] = useState<(typeof
RANGE_OPTIONS)[number]['value']>('1h');
- const [data, setData] = useState<MetricData | null>(null);
+ const [rangeId, setRangeId] = useState<RangeOption['value']>('1h');
+ const [panels, setPanels] = useState<Record<string, PanelState>>({});
const [profilesLoading, setProfilesLoading] = useState(true);
- const [queryLoading, setQueryLoading] = useState(false);
const [profileError, setProfileError] = useState(false);
- const [queryError, setQueryError] = useState<string | null>(null);
+ const [customPromql, setCustomPromql] = useState('');
+ const [customPanel, setCustomPanel] = useState<PanelState | null>(null);
+ const [appliedCustomPromql, setAppliedCustomPromql] = useState('');
const [dataSources, setDataSources] = useState<DataSource[]>([]);
const [dataSourceKey, setDataSourceKey] = useState('');
const [dataSourcesLoading, setDataSourcesLoading] = useState(true);
const [pendingDataSource, setPendingDataSource] = useState<DataSource |
null>(null);
const requestId = useRef(0);
- // Keeps the latest data source readable from the stable loadMetrics
callback so switching
+ // Keeps the latest data source readable from the stable callbacks so
switching
// the source uses the new key instead of a stale closure value.
const dataSourceKeyRef = useRef(dataSourceKey);
const dataSourceCredentialsRef = useRef<DataSourceCredentials | null>(null);
@@ -388,11 +433,9 @@ const MetricsExplorer = ({ instanceId }:
MetricsExplorerProps) => {
() => profiles.find((profile) => profile.id === profileId),
[profileId, profiles],
);
- const selectedMetric = useMemo(
- () => selectedProfile?.metrics.find((metric) => metric.semanticMetric ===
metricId),
- [metricId, selectedProfile],
- );
const selectedRange = RANGE_OPTIONS.find((range) => range.value === rangeId)
?? RANGE_OPTIONS[0];
+ const anyLoading =
+ Object.values(panels).some((panel) => panel.loading) ||
Boolean(customPanel?.loading);
const availableDataSources = useMemo(
() =>
dataSources.filter(
@@ -408,52 +451,68 @@ const MetricsExplorer = ({ instanceId }:
MetricsExplorerProps) => {
availableDataSourceKeysRef.current = new
Set(availableDataSources.map((source) => source.key));
}, [availableDataSources]);
- const loadMetrics = useCallback(
- async (metric: MetricMapping | undefined, range: (typeof
RANGE_OPTIONS)[number]) => {
- if (!metric) return;
- const currentRequest = ++requestId.current;
+ const runQuery = useCallback(
+ (promql: string, range: RangeOption): Promise<MetricData> => {
const end = Math.floor(Date.now() / 1000);
- const query = {
- metric: metric.promql,
- start: end - range.seconds,
- end,
- step: range.step,
- };
- setQueryLoading(true);
- setQueryError(null);
- try {
- const selectedDataSourceKey = dataSourceKeyRef.current;
- const currentDataSourceKey =
- selectedDataSourceKey &&
availableDataSourceKeysRef.current.has(selectedDataSourceKey)
- ? selectedDataSourceKey
- : '';
- const credentials =
- dataSourceCredentialsRef.current?.key === currentDataSourceKey
- ? dataSourceCredentialsRef.current
- : null;
- const result = currentDataSourceKey
- ? await queryByDataSource({
- key: currentDataSourceKey,
- query,
- instanceId,
- ...(credentials?.username !== undefined ? { username:
credentials.username } : {}),
- ...(credentials?.password !== undefined ? { password:
credentials.password } : {}),
- ...(credentials?.bearerToken !== undefined
- ? { bearerToken: credentials.bearerToken }
- : {}),
- })
- : await queryMetrics(query);
- if (currentRequest === requestId.current) setData(result);
- } catch (error) {
- if (currentRequest === requestId.current) {
- setData(null);
- setQueryError(getQueryErrorMessage(error, queryErrorFallback));
- }
- } finally {
- if (currentRequest === requestId.current) setQueryLoading(false);
- }
+ const query = { metric: promql, start: end - range.seconds, end, step:
range.step };
+ const selectedDataSourceKey = dataSourceKeyRef.current;
+ const currentDataSourceKey =
+ selectedDataSourceKey &&
availableDataSourceKeysRef.current.has(selectedDataSourceKey)
+ ? selectedDataSourceKey
+ : '';
+ const credentials =
+ dataSourceCredentialsRef.current?.key === currentDataSourceKey
+ ? dataSourceCredentialsRef.current
+ : null;
+ return currentDataSourceKey
+ ? queryByDataSource({
+ key: currentDataSourceKey,
+ query,
+ instanceId,
+ ...(credentials?.username !== undefined ? { username:
credentials.username } : {}),
+ ...(credentials?.password !== undefined ? { password:
credentials.password } : {}),
+ ...(credentials?.bearerToken !== undefined
+ ? { bearerToken: credentials.bearerToken }
+ : {}),
+ })
+ : queryMetrics(query);
},
- [instanceId, queryErrorFallback],
+ [instanceId],
+ );
+
+ const loadAll = useCallback(
+ async (profile: MetricProfile | undefined, range: RangeOption) => {
+ if (!profile) return;
+ const currentRequest = ++requestId.current;
+ const loadingPatch = Object.fromEntries(
+ profile.metrics.map((metric) => [metric.semanticMetric, { loading:
true } as PanelState]),
+ );
+ setPanels(loadingPatch);
+ await Promise.all(
+ profile.metrics.map(async (metric) => {
+ try {
+ const result = await runQuery(metric.promql, range);
+ if (currentRequest === requestId.current) {
+ setPanels((previous) => ({
+ ...previous,
+ [metric.semanticMetric]: { loading: false, data: result },
+ }));
+ }
+ } catch (error) {
+ if (currentRequest === requestId.current) {
+ setPanels((previous) => ({
+ ...previous,
+ [metric.semanticMetric]: {
+ loading: false,
+ error: getQueryErrorMessage(error, queryErrorFallback),
+ },
+ }));
+ }
+ }
+ }),
+ );
+ },
+ [queryErrorFallback, runQuery],
);
useEffect(() => {
@@ -462,11 +521,11 @@ const MetricsExplorer = ({ instanceId }:
MetricsExplorerProps) => {
.then((nextProfiles) => {
if (cancelled) return;
setProfiles(nextProfiles);
- const initialProfile = nextProfiles[0];
- const initialMetric = initialProfile?.metrics[0];
+ const storedProfileId = localStorage.getItem(PROFILE_STORAGE_KEY);
+ const initialProfile =
+ nextProfiles.find((profile) => profile.id === storedProfileId) ??
nextProfiles[0];
setProfileId(initialProfile?.id ?? '');
- setMetricId(initialMetric?.semanticMetric ?? '');
- void loadMetrics(initialMetric, RANGE_OPTIONS[0]);
+ void loadAll(initialProfile, RANGE_OPTIONS[0]);
})
.catch(() => {
if (!cancelled) setProfileError(true);
@@ -478,39 +537,54 @@ const MetricsExplorer = ({ instanceId }:
MetricsExplorerProps) => {
cancelled = true;
requestId.current += 1;
};
- }, [loadMetrics]);
+ }, [loadAll]);
const handleProfileChange = (nextProfileId: string) => {
const nextProfile = profiles.find((profile) => profile.id ===
nextProfileId);
- const nextMetric = nextProfile?.metrics[0];
+ localStorage.setItem(PROFILE_STORAGE_KEY, nextProfileId);
setProfileId(nextProfileId);
- setMetricId(nextMetric?.semanticMetric ?? '');
- setData(null);
- void loadMetrics(nextMetric, selectedRange);
- };
-
- const handleMetricChange = (nextMetricId: string) => {
- const nextMetric = selectedProfile?.metrics.find(
- (metric) => metric.semanticMetric === nextMetricId,
- );
- setMetricId(nextMetricId);
- setData(null);
- void loadMetrics(nextMetric, selectedRange);
+ void loadAll(nextProfile, selectedRange);
};
- const handleRangeChange = (nextRangeId: (typeof
RANGE_OPTIONS)[number]['value']) => {
+ const handleRangeChange = (nextRangeId: RangeOption['value']) => {
const nextRange =
RANGE_OPTIONS.find((range) => range.value === nextRangeId) ??
RANGE_OPTIONS[0];
setRangeId(nextRangeId);
- void loadMetrics(selectedMetric, nextRange);
+ void loadAll(selectedProfile, nextRange);
};
+ const runCustomQuery = useCallback(
+ async (promql: string, range: RangeOption) => {
+ const trimmed = promql.trim();
+ if (!trimmed) return;
+ const currentRequest = ++requestId.current;
+ setCustomPanel({ loading: true });
+ setAppliedCustomPromql(trimmed);
+ try {
+ const result = await runQuery(trimmed, range);
+ if (currentRequest === requestId.current) {
+ setCustomPanel({ loading: false, data: result });
+ }
+ } catch (error) {
+ if (currentRequest === requestId.current) {
+ setCustomPanel({
+ loading: false,
+ error: getQueryErrorMessage(error, queryErrorFallback),
+ });
+ }
+ }
+ },
+ [queryErrorFallback, runQuery],
+ );
+
const activateDataSource = (nextKey: string, credentials?: AuthFormValues)
=> {
dataSourceCredentialsRef.current = credentials ? { key: nextKey,
...credentials } : null;
dataSourceKeyRef.current = nextKey;
setDataSourceKey(nextKey);
- setData(null);
- void loadMetrics(selectedMetric, selectedRange);
+ void loadAll(selectedProfile, selectedRange);
+ if (appliedCustomPromql) {
+ void runCustomQuery(appliedCustomPromql, selectedRange);
+ }
};
const handleDataSourceChange = (nextKey: string) => {
@@ -566,17 +640,83 @@ const MetricsExplorer = ({ instanceId }:
MetricsExplorerProps) => {
dataSourceCredentialsRef.current = null;
dataSourceKeyRef.current = '';
setDataSourceKey('');
- setData(null);
setPendingDataSource(null);
- void loadMetrics(selectedMetric, selectedRange);
+ void loadAll(selectedProfile, selectedRange);
+ if (appliedCustomPromql) {
+ void runCustomQuery(appliedCustomPromql, selectedRange);
+ }
}, 0);
}
- }, [availableDataSources, dataSourceKey, loadMetrics, selectedMetric,
selectedRange]);
+ }, [
+ availableDataSources,
+ dataSourceKey,
+ loadAll,
+ runCustomQuery,
+ selectedProfile,
+ selectedRange,
+ appliedCustomPromql,
+ ]);
const pendingAuthMode = pendingDataSource
? getDataSourceAuthMode(pendingDataSource.auth)
: 'none';
+ const renderPanel = (metric: MetricMapping) => {
+ const state = panels[metric.semanticMetric];
+ return (
+ <Card
+ key={metric.semanticMetric}
+ size="small"
+ title={
+ <Flex gap={8} align="center">
+ <span>{metric.name}</span>
+ {metric.unit ? <Tag style={{ marginInlineEnd: 0
}}>{metric.unit}</Tag> : null}
+ </Flex>
+ }
+ >
+ {state?.loading ? (
+ <Flex justify="center" style={{ minHeight: 200 }} align="center">
+ <Spin />
+ </Flex>
+ ) : state?.error ? (
+ <Alert type="error" showIcon message={state.error} />
+ ) : state?.data ? (
+ <>
+ {state.data.warnings.map((warning) => (
+ <Alert
+ key={warning}
+ type="warning"
+ showIcon
+ message={warning}
+ style={{ marginBottom: 8 }}
+ />
+ ))}
+ <MetricChart
+ data={state.data}
+ metric={metric}
+ locale={locale}
+ noSamples={copy.noSamples}
+ histogramLabel={copy.histogram}
+ histogramTooltip={copy.histogramTooltip}
+ hiddenSeriesText={copy.hiddenSeries}
+ />
+ </>
+ ) : (
+ <Empty image={Empty.PRESENTED_IMAGE_SIMPLE}
description={copy.noSamples} />
+ )}
+ </Card>
+ );
+ };
+
+ const customMetric: MetricMapping = {
+ semanticMetric: 'custom',
+ name: appliedCustomPromql || copy.customTitle,
+ unit: '',
+ prometheusMetric: '',
+ promql: appliedCustomPromql,
+ labels: [],
+ };
+
return (
<section aria-labelledby="metrics-explorer-title" style={{ marginTop: 24
}}>
<Flex
@@ -586,12 +726,9 @@ const MetricsExplorer = ({ instanceId }:
MetricsExplorerProps) => {
wrap="wrap"
style={{ marginBottom: 12 }}
>
- <Flex gap={8} wrap="wrap" align="center">
- <Title id="metrics-explorer-title" level={4} style={{ margin: 0,
fontSize: 16 }}>
- {copy.title}
- </Title>
- {selectedMetric && <Tag>{selectedMetric.unit}</Tag>}
- </Flex>
+ <Title id="metrics-explorer-title" level={4} style={{ margin: 0,
fontSize: 16 }}>
+ {copy.title}
+ </Title>
<Flex gap={8} wrap="wrap" align="center" style={{ maxWidth: '100%' }}>
<Select
@@ -613,77 +750,112 @@ const MetricsExplorer = ({ instanceId }:
MetricsExplorerProps) => {
options={profiles.map((profile) => ({ label: profile.name, value:
profile.id }))}
style={{ width: 210, maxWidth: '100%' }}
/>
- <Select
- aria-label={copy.metric}
- value={metricId || undefined}
- onChange={handleMetricChange}
- options={(selectedProfile?.metrics ?? []).map((metric) => ({
- label: metric.name,
- value: metric.semanticMetric,
- }))}
- style={{ width: 190, maxWidth: '100%' }}
- />
<Segmented
aria-label={copy.range}
size="small"
value={rangeId}
- onChange={(value) =>
- handleRangeChange(value as (typeof
RANGE_OPTIONS)[number]['value'])
- }
+ onChange={(value) => handleRangeChange(value as
RangeOption['value'])}
options={RANGE_OPTIONS.map(({ label, value }) => ({ label, value
}))}
/>
<Tooltip title={copy.refresh}>
<Button
aria-label={copy.refresh}
icon={<ArrowsClockwise size={16} />}
- onClick={() => void loadMetrics(selectedMetric, selectedRange)}
- loading={queryLoading}
+ onClick={() => {
+ void loadAll(selectedProfile, selectedRange);
+ if (appliedCustomPromql) {
+ void runCustomQuery(appliedCustomPromql, selectedRange);
+ }
+ }}
+ loading={anyLoading}
/>
</Tooltip>
</Flex>
</Flex>
- {selectedMetric && (
- <Text
- code
- copyable
- style={{ display: 'block', marginBottom: 12, overflowWrap:
'anywhere' }}
- >
- {selectedMetric.promql}
- </Text>
- )}
-
{profilesLoading ? (
<Skeleton active paragraph={{ rows: 5 }} />
) : profileError ? (
<Alert type="error" showIcon message={copy.profileError} />
) : profiles.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE}
description={copy.noProfiles} />
- ) : queryError ? (
- <Alert type="error" showIcon message={queryError} />
- ) : queryLoading && !data ? (
- <Skeleton active paragraph={{ rows: 5 }} />
- ) : data && selectedMetric ? (
+ ) : (
<>
- {data.warnings.map((warning) => (
- <Alert
- key={warning}
- type="warning"
- showIcon
- message={warning}
- style={{ marginBottom: 8 }}
+ <Card
+ size="small"
+ title={copy.customTitle}
+ style={{ marginBottom: 16 }}
+ extra={
+ <Button
+ type="primary"
+ size="small"
+ loading={Boolean(customPanel?.loading)}
+ disabled={!customPromql.trim()}
+ onClick={() => void runCustomQuery(customPromql,
selectedRange)}
+ >
+ {copy.customRun}
+ </Button>
+ }
+ >
+ <Input.TextArea
+ aria-label={copy.customTitle}
+ value={customPromql}
+ onChange={(event) => setCustomPromql(event.target.value)}
+ onPressEnter={(event) => {
+ if (!event.shiftKey) {
+ event.preventDefault();
+ void runCustomQuery(customPromql, selectedRange);
+ }
+ }}
+ placeholder={copy.customPlaceholder}
+ autoSize={{ minRows: 2, maxRows: 6 }}
+ style={{ fontFamily: 'monospace' }}
/>
- ))}
- <MetricChart
- data={data}
- metric={selectedMetric}
- locale={lang === 'zh' ? 'zh-CN' : 'en-US'}
- noSamples={copy.noSamples}
- histogramLabel={copy.histogram}
- histogramTooltip={copy.histogramTooltip}
- />
+ <div style={{ marginTop: 12 }}>
+ {customPanel?.loading ? (
+ <Flex justify="center" style={{ minHeight: 200 }}
align="center">
+ <Spin />
+ </Flex>
+ ) : customPanel?.error ? (
+ <Alert type="error" showIcon message={customPanel.error} />
+ ) : customPanel?.data ? (
+ <>
+ {customPanel.data.warnings.map((warning) => (
+ <Alert
+ key={warning}
+ type="warning"
+ showIcon
+ message={warning}
+ style={{ marginBottom: 8 }}
+ />
+ ))}
+ <MetricChart
+ data={customPanel.data}
+ metric={customMetric}
+ locale={locale}
+ noSamples={copy.noSamples}
+ histogramLabel={copy.histogram}
+ histogramTooltip={copy.histogramTooltip}
+ hiddenSeriesText={copy.hiddenSeries}
+ />
+ </>
+ ) : (
+ <Text type="secondary">{copy.customEmpty}</Text>
+ )}
+ </div>
+ </Card>
+
+ <div
+ style={{
+ display: 'grid',
+ gridTemplateColumns: 'repeat(auto-fill, minmax(430px, 1fr))',
+ gap: 16,
+ }}
+ >
+ {(selectedProfile?.metrics ?? []).map(renderPanel)}
+ </div>
</>
- ) : null}
+ )}
<Modal
title={copy.authTitle}
open={pendingDataSource !== null}
diff --git a/web/src/components/__tests__/MetricsExplorer.test.tsx
b/web/src/components/__tests__/MetricsExplorer.test.tsx
index d13a85d98..8273ca7eb 100644
--- a/web/src/components/__tests__/MetricsExplorer.test.tsx
+++ b/web/src/components/__tests__/MetricsExplorer.test.tsx
@@ -208,6 +208,83 @@ describe('MetricsExplorer', () => {
);
});
+ it('renders one panel per metric in the selected profile', async () => {
+ vi.mocked(listMetricProfiles).mockResolvedValue([
+ {
+ id: 'rocketmq5-native',
+ name: 'RocketMQ 5.x Native',
+ description: 'RocketMQ 5.x native metrics',
+ metrics: [
+ {
+ semanticMetric: 'message_in_tps',
+ name: 'Message In TPS',
+ unit: 'messages/s',
+ prometheusMetric: 'rocketmq_messages_in_total',
+ promql: 'sum(rate(rocketmq_messages_in_total[1m])) by (cluster,
node_id)',
+ labels: ['cluster'],
+ },
+ {
+ semanticMetric: 'consumer_lag_messages',
+ name: 'Consumer Lag Messages',
+ unit: 'messages',
+ prometheusMetric: 'rocketmq_consumer_lag_messages',
+ promql: 'sum(rocketmq_consumer_lag_messages) by (cluster)',
+ labels: ['cluster'],
+ },
+ ],
+ },
+ ]);
+
+ renderWithProviders(<MetricsExplorer />);
+
+ await waitFor(() => expect(queryMetrics).toHaveBeenCalledTimes(2));
+ expect(screen.getByText('Message In TPS')).toBeInTheDocument();
+ expect(screen.getByText('Consumer Lag Messages')).toBeInTheDocument();
+ expect(queryMetrics).toHaveBeenCalledWith({
+ metric: 'sum(rocketmq_consumer_lag_messages) by (cluster)',
+ start: 1_799_996_400,
+ end: 1_800_000_000,
+ step: '30s',
+ });
+ });
+
+ it('caps the plotted series and reports the hidden count', async () => {
+ const manySeries = Array.from({ length: 14 }, (_, index) => ({
+ labels: { cluster: 'prod', node_id: `broker-${index}` },
+ values: [
+ { timestamp: 1_799_996_400, value: String(index) },
+ { timestamp: 1_800_000_000, value: String(index + 1) },
+ ],
+ histograms: [],
+ }));
+ vi.mocked(queryMetrics).mockResolvedValue({ ...metricData, series:
manySeries });
+
+ renderWithProviders(<MetricsExplorer />);
+
+ expect(await screen.findByText(/另有 4 条序列未显示/)).toBeInTheDocument();
+ const chart = screen.getByRole('img', { name: 'Message In TPS time series'
});
+ expect(chart.querySelectorAll('polyline')).toHaveLength(10);
+ });
+
+ it('runs a custom PromQL expression from the query box', async () => {
+ const user = userEvent.setup();
+ renderWithProviders(<MetricsExplorer />);
+ await screen.findByRole('img', { name: 'Message In TPS time series' });
+
+ await user.type(screen.getByLabelText('自定义查询'),
'sum(rocketmq_topic_number)');
+ await user.click(screen.getByRole('button', { name: /查\s*询/ }));
+
+ await waitFor(() =>
+ expect(queryMetrics).toHaveBeenCalledWith({
+ metric: 'sum(rocketmq_topic_number)',
+ start: 1_799_996_400,
+ end: 1_800_000_000,
+ step: '30s',
+ }),
+ );
+ expect(await screen.findAllByText('cluster=prod /
node_id=broker-a')).not.toHaveLength(0);
+ });
+
it('queries the first metric when the version profile changes', async () => {
const user = userEvent.setup();
renderWithProviders(<MetricsExplorer />);
@@ -265,7 +342,7 @@ describe('MetricsExplorer', () => {
expect(await screen.findByText('Prometheus 查询失败')).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: '指标模板' })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: '刷新指标' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: '刷新全部面板' })).toBeInTheDocument();
});
it('shows the actionable message returned by the metrics API', async () => {