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 () => {

Reply via email to