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 16a73bc8 feat(metrics): support multiple Prometheus-compatible metrics 
backends (#1011)
16a73bc8 is described below

commit 16a73bc80aea6a4db3806993508d0851ca5a260f
Author: zhaohai <[email protected]>
AuthorDate: Wed Aug 5 17:45:24 2026 +0800

    feat(metrics): support multiple Prometheus-compatible metrics backends 
(#1011)
    
    Co-authored-by: zhaohaihzb <[email protected]>
---
 ...AbstractPrometheusCompatibleMetricsSource.java} |  95 +++---
 .../studio/cluster/metrics/ArmsMetricsSource.java  |  33 +++
 .../cluster/metrics/CortexMetricsSource.java       |  32 +++
 .../studio/cluster/metrics/MetricsBackendType.java |  67 +++++
 .../studio/cluster/metrics/MetricsController.java  |  21 ++
 .../studio/cluster/metrics/MetricsService.java     |  53 ++++
 .../cluster/metrics/MetricsSourceFactory.java      |  64 +++++
 .../cluster/metrics/MetricsSourceSettings.java     | 133 +++++++++
 .../studio/cluster/metrics/MimirMetricsSource.java |  32 +++
 .../cluster/metrics/PrometheusMetricsSource.java   | 319 ++-------------------
 .../cluster/metrics/ThanosMetricsSource.java       |  32 +++
 .../metrics/VictoriaMetricsMetricsSource.java      |  32 +++
 .../request/MetricsDataSourceQueryRequest.java     |  70 +++++
 .../rocketmq/studio/settings/SettingsService.java  |  10 +-
 .../cluster/metrics/MetricsBackendTypeTest.java    |  56 ++++
 .../studio/cluster/metrics/MetricsServiceTest.java |  53 ++++
 .../metrics/MultiBackendMetricsSourceTest.java     | 128 +++++++++
 web/src/api/metrics.test.ts                        |  32 +++
 web/src/api/metrics.ts                             |  20 ++
 .../api/{dataSources.test.ts => settings.test.ts}  |   0
 web/src/components/MetricsExplorer.tsx             |  60 +++-
 .../components/__tests__/MetricsExplorer.test.tsx  |  50 +++-
 .../settings/__tests__/DataSourceTab.test.tsx      |  30 ++
 web/src/pages/settings/index.tsx                   |  24 +-
 24 files changed, 1092 insertions(+), 354 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/PrometheusMetricsSource.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/AbstractPrometheusCompatibleMetricsSource.java
similarity index 78%
copy from 
server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/PrometheusMetricsSource.java
copy to 
server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/AbstractPrometheusCompatibleMetricsSource.java
index 921b7402..01884c82 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/PrometheusMetricsSource.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/AbstractPrometheusCompatibleMetricsSource.java
@@ -24,7 +24,6 @@ import org.springframework.http.HttpStatus;
 import org.springframework.http.HttpStatusCode;
 import org.springframework.http.MediaType;
 import org.springframework.http.client.SimpleClientHttpRequestFactory;
-import org.springframework.stereotype.Component;
 import org.springframework.util.LinkedMultiValueMap;
 import org.springframework.util.MultiValueMap;
 import org.springframework.util.StringUtils;
@@ -43,27 +42,39 @@ import java.util.List;
 import java.util.Map;
 import java.util.stream.StreamSupport;
 
+/**
+ * Shared implementation for every Prometheus-compatible metrics backend
+ * (Prometheus, VictoriaMetrics, Thanos, Cortex, Mimir, ARMS, ...).
+ * <p>
+ * The only behavioural difference between backends is the URL path under which
+ * the Prometheus HTTP query API is exposed; query/parse/authentication logic 
is
+ * identical. Concrete subclasses only pick a {@link MetricsBackendType}.
+ * </p>
+ */
 @Slf4j
-@Component
-public class PrometheusMetricsSource implements MetricsSource {
+public abstract class AbstractPrometheusCompatibleMetricsSource implements 
MetricsSource {
 
-    private static final String QUERY_RANGE_PATH = "/api/v1/query_range";
     private static final int MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
     private static final int MAX_SERIES = 1_000;
     private static final int MAX_TOTAL_SAMPLES = 100_000;
 
     private final RestClient restClient;
     private final ObjectMapper objectMapper;
-    private final PrometheusProperties properties;
+    private final MetricsSourceSettings settings;
 
-    public PrometheusMetricsSource(RestClient.Builder restClientBuilder, 
ObjectMapper objectMapper,
-                                   PrometheusProperties properties) {
+    protected AbstractPrometheusCompatibleMetricsSource(RestClient.Builder 
restClientBuilder,
+                                                         ObjectMapper 
objectMapper,
+                                                         MetricsSourceSettings 
settings) {
         SimpleClientHttpRequestFactory requestFactory = new 
SimpleClientHttpRequestFactory();
-        requestFactory.setConnectTimeout(properties.getConnectTimeout());
-        requestFactory.setReadTimeout(properties.getReadTimeout());
+        requestFactory.setConnectTimeout(settings.getConnectTimeout());
+        requestFactory.setReadTimeout(settings.getReadTimeout());
         this.restClient = 
restClientBuilder.requestFactory(requestFactory).build();
         this.objectMapper = objectMapper;
-        this.properties = properties;
+        this.settings = settings;
+    }
+
+    protected MetricsBackendType backendType() {
+        return settings.getBackendType();
     }
 
     @Override
@@ -76,8 +87,8 @@ public class PrometheusMetricsSource implements MetricsSource 
{
         form.add("end", Long.toString(query.getEnd()));
         form.add("step", query.getStep());
 
-        log.debug("Querying Prometheus range: start={}, end={}, step={}",
-                query.getStart(), query.getEnd(), query.getStep());
+        log.debug("Querying {} range: start={}, end={}, step={}",
+                settings.getBackendType(), query.getStart(), query.getEnd(), 
query.getStep());
 
         try {
             JsonNode response = restClient.post()
@@ -98,17 +109,17 @@ public class PrometheusMetricsSource implements 
MetricsSource {
         } catch (ResourceAccessException exception) {
             if (hasCause(exception, SocketTimeoutException.class)) {
                 throw new 
PrometheusException(HttpStatus.GATEWAY_TIMEOUT.value(),
-                        "Prometheus query timed out", exception);
+                        backendLabel() + " query timed out", exception);
             }
             throw new PrometheusException(HttpStatus.BAD_GATEWAY.value(),
-                    "Failed to connect to Prometheus", exception);
+                    "Failed to connect to " + backendLabel(), exception);
         } catch (RestClientException exception) {
             if (hasCause(exception, SocketTimeoutException.class)) {
                 throw new 
PrometheusException(HttpStatus.GATEWAY_TIMEOUT.value(),
-                        "Prometheus query timed out", exception);
+                        backendLabel() + " query timed out", exception);
             }
             throw new PrometheusException(HttpStatus.BAD_GATEWAY.value(),
-                    "Prometheus query failed", exception);
+                    backendLabel() + " query failed", exception);
         }
     }
 
@@ -135,39 +146,39 @@ public class PrometheusMetricsSource implements 
MetricsSource {
     }
 
     private URI queryRangeUri() {
-        if (!StringUtils.hasText(properties.getBaseUrl())) {
+        if (!StringUtils.hasText(settings.getBaseUrl())) {
             throw new 
PrometheusException(HttpStatus.SERVICE_UNAVAILABLE.value(),
-                    "Prometheus base URL is not configured");
+                    backendLabel() + " base URL is not configured");
         }
         try {
-            String baseUrl = properties.getBaseUrl().strip();
+            String baseUrl = settings.getBaseUrl().strip();
             while (baseUrl.endsWith("/")) {
                 baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
             }
-            URI uri = URI.create(baseUrl + QUERY_RANGE_PATH);
+            URI uri = URI.create(baseUrl + settings.getQueryPath());
             if (!"http".equalsIgnoreCase(uri.getScheme()) && 
!"https".equalsIgnoreCase(uri.getScheme())) {
-                throw new IllegalArgumentException("Unsupported Prometheus URL 
scheme");
+                throw new IllegalArgumentException("Unsupported " + 
backendLabel() + " URL scheme");
             }
             return uri;
         } catch (IllegalArgumentException exception) {
             throw new 
PrometheusException(HttpStatus.SERVICE_UNAVAILABLE.value(),
-                    "Prometheus base URL is invalid", exception);
+                    backendLabel() + " base URL is invalid", exception);
         }
     }
 
     private void applyAuthentication(HttpHeaders headers) {
-        if (StringUtils.hasText(properties.getBearerToken())) {
-            headers.setBearerAuth(properties.getBearerToken());
+        if (StringUtils.hasText(settings.getBearerToken())) {
+            headers.setBearerAuth(settings.getBearerToken());
             return;
         }
-        boolean hasUsername = StringUtils.hasText(properties.getUsername());
-        boolean hasPassword = StringUtils.hasText(properties.getPassword());
+        boolean hasUsername = StringUtils.hasText(settings.getUsername());
+        boolean hasPassword = StringUtils.hasText(settings.getPassword());
         if (hasUsername != hasPassword) {
             throw new 
PrometheusException(HttpStatus.SERVICE_UNAVAILABLE.value(),
-                    "Prometheus basic authentication is incomplete");
+                    backendLabel() + " basic authentication is incomplete");
         }
         if (hasUsername) {
-            headers.setBasicAuth(properties.getUsername(), 
properties.getPassword());
+            headers.setBasicAuth(settings.getUsername(), 
settings.getPassword());
         }
     }
 
@@ -180,7 +191,7 @@ public class PrometheusMetricsSource implements 
MetricsSource {
         JsonNode result = data.path("result");
         if (!data.isObject() || !result.isArray() || 
!StringUtils.hasText(data.path("resultType").asText())) {
             throw new PrometheusException(HttpStatus.BAD_GATEWAY.value(),
-                    "Prometheus returned a malformed response");
+                    backendLabel() + " returned a malformed response");
         }
         validateResponseLimits(result);
 
@@ -207,7 +218,7 @@ public class PrometheusMetricsSource implements 
MetricsSource {
         boolean invalidHistograms = !histograms.isMissingNode() && 
!hasHistograms;
         if (!metric.isObject() || invalidValues || invalidHistograms || 
!hasSamples) {
             throw new PrometheusException(HttpStatus.BAD_GATEWAY.value(),
-                    "Prometheus returned a malformed time series");
+                    backendLabel() + " returned a malformed time series");
         }
 
         Map<String, String> labels = new LinkedHashMap<>();
@@ -230,7 +241,7 @@ public class PrometheusMetricsSource implements 
MetricsSource {
     private MetricDataVO.MetricSampleVO parseSample(JsonNode sampleNode) {
         if (!sampleNode.isArray() || sampleNode.size() != 2 || 
!sampleNode.get(0).isNumber()) {
             throw new PrometheusException(HttpStatus.BAD_GATEWAY.value(),
-                    "Prometheus returned a malformed sample");
+                    backendLabel() + " returned a malformed sample");
         }
         return MetricDataVO.MetricSampleVO.builder()
                 .timestamp(sampleNode.get(0).asDouble())
@@ -242,7 +253,7 @@ public class PrometheusMetricsSource implements 
MetricsSource {
         if (!sampleNode.isArray() || sampleNode.size() != 2
                 || !sampleNode.get(0).isNumber() || 
!sampleNode.get(1).isObject()) {
             throw new PrometheusException(HttpStatus.BAD_GATEWAY.value(),
-                    "Prometheus returned a malformed histogram sample");
+                    backendLabel() + " returned a malformed histogram sample");
         }
         return MetricDataVO.MetricHistogramSampleVO.builder()
                 .timestamp(sampleNode.get(0).asDouble())
@@ -266,7 +277,7 @@ public class PrometheusMetricsSource implements 
MetricsSource {
             while ((read = response.read(buffer)) != -1) {
                 if (output.size() > MAX_RESPONSE_BYTES - read) {
                     throw new 
PrometheusException(HttpStatus.PAYLOAD_TOO_LARGE.value(),
-                            "Prometheus response exceeds 5 MiB; narrow the 
query");
+                            backendLabel() + " response exceeds 5 MiB; narrow 
the query");
                 }
                 output.write(buffer, 0, read);
             }
@@ -277,7 +288,7 @@ public class PrometheusMetricsSource implements 
MetricsSource {
     private void validateResponseLimits(JsonNode result) {
         if (result.size() > MAX_SERIES) {
             throw new PrometheusException(HttpStatus.PAYLOAD_TOO_LARGE.value(),
-                    "Prometheus query returned too many series; narrow the 
query");
+                    backendLabel() + " query returned too many series; narrow 
the query");
         }
         long totalSamples = 0;
         for (JsonNode series : result) {
@@ -285,18 +296,17 @@ public class PrometheusMetricsSource implements 
MetricsSource {
             totalSamples += series.path("histograms").size();
             if (totalSamples > MAX_TOTAL_SAMPLES) {
                 throw new 
PrometheusException(HttpStatus.PAYLOAD_TOO_LARGE.value(),
-                        "Prometheus query returned too many samples; increase 
step or narrow the query");
+                        backendLabel() + " query returned too many samples; 
increase step or narrow the query");
             }
         }
     }
 
     private int responseStatus(HttpStatusCode statusCode) {
         int upstreamStatus = statusCode.value();
-        int mappedStatus = switch (upstreamStatus) {
+        return switch (upstreamStatus) {
             case 400, 422, 503 -> upstreamStatus;
             default -> HttpStatus.BAD_GATEWAY.value();
         };
-        return mappedStatus;
     }
 
     private PrometheusException responseBodyException(JsonNode response, int 
statusCode) {
@@ -304,11 +314,11 @@ public class PrometheusMetricsSource implements 
MetricsSource {
         String error = response == null ? "" : response.path("error").asText();
         if (StringUtils.hasText(error)) {
             String message = StringUtils.hasText(errorType)
-                    ? "Prometheus query failed (" + errorType + "): " + error
-                    : "Prometheus query failed: " + error;
+                    ? backendLabel() + " query failed (" + errorType + "): " + 
error
+                    : backendLabel() + " query failed: " + error;
             return new PrometheusException(statusCode, message);
         }
-        return new PrometheusException(statusCode, "Prometheus query failed");
+        return new PrometheusException(statusCode, backendLabel() + " query 
failed");
     }
 
     private boolean hasCause(Throwable throwable, Class<? extends Throwable> 
causeType) {
@@ -321,4 +331,9 @@ public class PrometheusMetricsSource implements 
MetricsSource {
         }
         return false;
     }
+
+    private String backendLabel() {
+        return settings.getBackendType() == MetricsBackendType.PROMETHEUS
+                ? "Prometheus" : settings.getBackendType().name();
+    }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/ArmsMetricsSource.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/ArmsMetricsSource.java
new file mode 100644
index 00000000..afcfc71b
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/ArmsMetricsSource.java
@@ -0,0 +1,33 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.cluster.metrics;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.web.client.RestClient;
+
+/**
+ * Alibaba Cloud ARMS Prometheus metrics backend. Exposes the Prometheus query
+ * API through the ARMS Prometheus-compatible endpoint
+ * (see {@link MetricsBackendType#ARMS}).
+ */
+public class ArmsMetricsSource extends 
AbstractPrometheusCompatibleMetricsSource {
+
+    public ArmsMetricsSource(RestClient.Builder restClientBuilder, 
ObjectMapper objectMapper,
+                             MetricsSourceSettings settings) {
+        super(restClientBuilder, objectMapper, settings);
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/CortexMetricsSource.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/CortexMetricsSource.java
new file mode 100644
index 00000000..baa2aa7b
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/CortexMetricsSource.java
@@ -0,0 +1,32 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.cluster.metrics;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.web.client.RestClient;
+
+/**
+ * Cortex metrics backend. Exposes the Prometheus query API through the Cortex
+ * Query Frontend / Query component (see {@link MetricsBackendType#CORTEX}).
+ */
+public class CortexMetricsSource extends 
AbstractPrometheusCompatibleMetricsSource {
+
+    public CortexMetricsSource(RestClient.Builder restClientBuilder, 
ObjectMapper objectMapper,
+                               MetricsSourceSettings settings) {
+        super(restClientBuilder, objectMapper, settings);
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsBackendType.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsBackendType.java
new file mode 100644
index 00000000..147a78c8
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsBackendType.java
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.cluster.metrics;
+
+/**
+ * Prometheus-compatible metrics backend types supported by RocketMQ Studio.
+ * <p>
+ * All backends speak the Prometheus HTTP query API, but differ in the URL path
+ * they expose it under (e.g. Mimir and VictoriaMetrics mount the API behind a
+ * tenant/prefix path). The query/parse semantics are otherwise identical, 
which
+ * is why every type shares {@link AbstractPrometheusCompatibleMetricsSource}.
+ * </p>
+ */
+public enum MetricsBackendType {
+
+    PROMETHEUS("/api/v1/query_range"),
+    VICTORIA_METRICS("/select/0/prometheus/api/v1/query_range"),
+    THANOS("/api/v1/query_range"),
+    CORTEX("/api/v1/query_range"),
+    MIMIR("/prometheus/api/v1/query_range"),
+    ARMS("/api/v1/query_range"),
+    CUSTOM("/api/v1/query_range");
+
+    private final String queryPath;
+
+    MetricsBackendType(String queryPath) {
+        this.queryPath = queryPath;
+    }
+
+    public String getQueryPath() {
+        return queryPath;
+    }
+
+    /**
+     * Resolves a provider type name (as stored in {@code 
MetricsDataSourceConfig.providerType})
+     * to a backend type, defaulting to {@link #PROMETHEUS} for unknown values.
+     */
+    public static MetricsBackendType fromProviderType(String providerType) {
+        if (providerType == null) {
+            return PROMETHEUS;
+        }
+        return switch (providerType.trim().toUpperCase()) {
+            case "PROMETHEUS" -> PROMETHEUS;
+            case "VICTORIAMETRICS", "VICTORIA_METRICS", "VICTORIA" -> 
VICTORIA_METRICS;
+            case "THANOS" -> THANOS;
+            case "CORTEX" -> CORTEX;
+            case "MIMIR" -> MIMIR;
+            case "ARMS" -> ARMS;
+            case "CUSTOM" -> CUSTOM;
+            default -> PROMETHEUS;
+        };
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsController.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsController.java
index 2c79b35b..5d997737 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsController.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsController.java
@@ -17,6 +17,7 @@
 package org.apache.rocketmq.studio.cluster.metrics;
 
 import org.apache.rocketmq.studio.common.domain.Result;
+import org.apache.rocketmq.studio.model.request.MetricsDataSourceQueryRequest;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.responses.ApiResponse;
 import io.swagger.v3.oas.annotations.responses.ApiResponses;
@@ -26,6 +27,7 @@ import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
 import java.util.List;
@@ -63,4 +65,23 @@ public class MetricsController {
     public Result<MetricDataVO> query(@Valid @RequestBody MetricQueryDTO 
query) {
         return Result.ok(metricsService.query(query));
     }
+
+    @Operation(summary = "Query a configured Prometheus-compatible data 
source",
+            description = "Executes a PromQL range query against a data source 
configured via the "
+                    + "settings data-source flow. The backend type/URL come 
from the persisted "
+                    + "configuration; credentials are supplied per request and 
never persisted.")
+    @ApiResponses({
+        @ApiResponse(responseCode = "200", description = "Range query 
completed successfully",
+                useReturnTypeSchema = true),
+        @ApiResponse(responseCode = "400", description = "Invalid request or 
unknown data source"),
+        @ApiResponse(responseCode = "422", description = "Backend could not 
execute the expression"),
+        @ApiResponse(responseCode = "502", description = "Backend connection 
or response failure"),
+        @ApiResponse(responseCode = "503", description = "Backend is 
unavailable or not configured"),
+        @ApiResponse(responseCode = "504", description = "Backend query timed 
out")
+    })
+    @PostMapping("/query/datasource")
+    public Result<MetricDataVO> queryByDataSource(@RequestParam String key,
+                                                  @Valid @RequestBody 
MetricsDataSourceQueryRequest request) {
+        return Result.ok(metricsService.queryByDataSource(key, request));
+    }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsService.java
index f4fcb219..9750c920 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsService.java
@@ -18,6 +18,10 @@ package org.apache.rocketmq.studio.cluster.metrics;
 
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.studio.model.MetricsDataSourceConfig;
+import org.apache.rocketmq.studio.model.request.MetricsDataSourceQueryRequest;
+import org.apache.rocketmq.studio.settings.DataSourceVO;
+import org.apache.rocketmq.studio.settings.SettingsService;
 import org.springframework.http.HttpStatus;
 import org.springframework.stereotype.Service;
 import org.springframework.util.StringUtils;
@@ -47,6 +51,8 @@ public class MetricsService {
 
     private final MetricsSource metricsSource;
     private final MetricProfileService metricProfileService;
+    private final MetricsSourceFactory metricsSourceFactory;
+    private final SettingsService settingsService;
 
     public MetricDataVO query(MetricQueryDTO query) {
         if (query == null) {
@@ -59,6 +65,53 @@ public class MetricsService {
         return metricsSource.query(resolvedQuery);
     }
 
+    /**
+     * Runs a PromQL range query against a configured data source, selected by 
its
+     * persisted key. The backend type and URL come from the existing
+     * {@link DataSourceVO} configuration (managed by {@link SettingsService});
+     * credentials are supplied per request and are never persisted, mirroring 
the
+     * existing data-source test flow.
+     */
+    public MetricDataVO queryByDataSource(String dataSourceKey, 
MetricsDataSourceQueryRequest request) {
+        if (request == null || request.getQuery() == null) {
+            throw badRequest("Metric query is required");
+        }
+        if (!StringUtils.hasText(dataSourceKey)) {
+            throw badRequest("Data source key is required");
+        }
+        MetricQueryDTO resolvedQuery = resolveMetricQuery(request.getQuery());
+        validateQueryWindow(resolvedQuery);
+        DataSourceVO dataSource = settingsService.getDataSource(dataSourceKey);
+        MetricsSource source = 
metricsSourceFactory.create(toConfig(dataSource, request));
+        log.debug("Querying data source {} (type={}): start={}, end={}, 
step={}",
+                dataSourceKey, dataSource.getType(),
+                resolvedQuery.getStart(), resolvedQuery.getEnd(), 
resolvedQuery.getStep());
+        return source.query(resolvedQuery);
+    }
+
+    private MetricsDataSourceConfig toConfig(DataSourceVO dataSource, 
MetricsDataSourceQueryRequest request) {
+        MetricsDataSourceConfig config = new MetricsDataSourceConfig();
+        config.setName(dataSource.getName());
+        config.setProviderType(dataSource.getType());
+        config.setUrl(dataSource.getUrl());
+        config.setAuthType(normalizeAuth(dataSource.getAuth()));
+        config.setUsername(request.getUsername());
+        config.setPassword(request.getPassword());
+        config.setBearerToken(request.getBearerToken());
+        return config;
+    }
+
+    private String normalizeAuth(String auth) {
+        if (!StringUtils.hasText(auth)) {
+            return "none";
+        }
+        return switch (auth.trim().toLowerCase()) {
+            case "basic auth", "basic" -> "basic";
+            case "bearer token", "bearer" -> "bearer";
+            default -> "none";
+        };
+    }
+
     private void validateQueryWindow(MetricQueryDTO query) {
         long rangeSeconds = query.getEnd() - query.getStart();
         if (rangeSeconds <= 0) {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsSourceFactory.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsSourceFactory.java
new file mode 100644
index 00000000..7e50172e
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsSourceFactory.java
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.cluster.metrics;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestClient;
+
+import java.time.Duration;
+
+/**
+ * Builds a {@link MetricsSource} for a given {@link 
org.apache.rocketmq.studio.model.MetricsDataSourceConfig}.
+ * <p>
+ * The concrete implementation is selected by {@code 
MetricsDataSourceConfig.providerType},
+ * which maps to a {@link MetricsBackendType}. Every backend shares the same
+ * query/parse logic via {@link AbstractPrometheusCompatibleMetricsSource}.
+ * </p>
+ */
+@Component
+public class MetricsSourceFactory {
+
+    private final RestClient.Builder restClientBuilder;
+    private final ObjectMapper objectMapper;
+
+    public MetricsSourceFactory(RestClient.Builder restClientBuilder, 
ObjectMapper objectMapper) {
+        this.restClientBuilder = restClientBuilder;
+        this.objectMapper = objectMapper;
+    }
+
+    public MetricsSource 
create(org.apache.rocketmq.studio.model.MetricsDataSourceConfig config) {
+        MetricsBackendType backendType = 
MetricsBackendType.fromProviderType(config.getProviderType());
+        MetricsSourceSettings settings = MetricsSourceSettings.builder()
+                .backendType(backendType)
+                .baseUrl(config.getUrl())
+                .connectTimeout(Duration.ofSeconds(3))
+                .readTimeout(Duration.ofSeconds(10))
+                .username(config.getUsername())
+                .password(config.getPassword())
+                .bearerToken(config.getBearerToken())
+                .build();
+        return switch (backendType) {
+            case VICTORIA_METRICS -> new 
VictoriaMetricsMetricsSource(restClientBuilder, objectMapper, settings);
+            case THANOS -> new ThanosMetricsSource(restClientBuilder, 
objectMapper, settings);
+            case CORTEX -> new CortexMetricsSource(restClientBuilder, 
objectMapper, settings);
+            case MIMIR -> new MimirMetricsSource(restClientBuilder, 
objectMapper, settings);
+            case ARMS -> new ArmsMetricsSource(restClientBuilder, 
objectMapper, settings);
+            case CUSTOM, PROMETHEUS -> new 
PrometheusMetricsSource(restClientBuilder, objectMapper, settings);
+        };
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsSourceSettings.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsSourceSettings.java
new file mode 100644
index 00000000..6e60146f
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsSourceSettings.java
@@ -0,0 +1,133 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.cluster.metrics;
+
+import java.time.Duration;
+
+/**
+ * Resolved connection settings for a Prometheus-compatible metrics backend.
+ * <p>
+ * Unlike {@code MetricsDataSourceConfig} (which is the serializable 
user-facing
+ * model), this object carries only the values required to issue a query and is
+ * produced by {@link MetricsSourceFactory} from a data source configuration.
+ * </p>
+ */
+public class MetricsSourceSettings {
+
+    private final MetricsBackendType backendType;
+    private final String baseUrl;
+    private final Duration connectTimeout;
+    private final Duration readTimeout;
+    private final String username;
+    private final String password;
+    private final String bearerToken;
+
+    private MetricsSourceSettings(Builder builder) {
+        this.backendType = builder.backendType;
+        this.baseUrl = builder.baseUrl;
+        this.connectTimeout = builder.connectTimeout;
+        this.readTimeout = builder.readTimeout;
+        this.username = builder.username;
+        this.password = builder.password;
+        this.bearerToken = builder.bearerToken;
+    }
+
+    public MetricsBackendType getBackendType() {
+        return backendType;
+    }
+
+    public String getQueryPath() {
+        return backendType.getQueryPath();
+    }
+
+    public String getBaseUrl() {
+        return baseUrl;
+    }
+
+    public Duration getConnectTimeout() {
+        return connectTimeout;
+    }
+
+    public Duration getReadTimeout() {
+        return readTimeout;
+    }
+
+    public String getUsername() {
+        return username;
+    }
+
+    public String getPassword() {
+        return password;
+    }
+
+    public String getBearerToken() {
+        return bearerToken;
+    }
+
+    public static Builder builder() {
+        return new Builder();
+    }
+
+    public static class Builder {
+        private MetricsBackendType backendType = MetricsBackendType.PROMETHEUS;
+        private String baseUrl;
+        private Duration connectTimeout = Duration.ofSeconds(3);
+        private Duration readTimeout = Duration.ofSeconds(10);
+        private String username;
+        private String password;
+        private String bearerToken;
+
+        public Builder backendType(MetricsBackendType backendType) {
+            this.backendType = backendType;
+            return this;
+        }
+
+        public Builder baseUrl(String baseUrl) {
+            this.baseUrl = baseUrl;
+            return this;
+        }
+
+        public Builder connectTimeout(Duration connectTimeout) {
+            this.connectTimeout = connectTimeout;
+            return this;
+        }
+
+        public Builder readTimeout(Duration readTimeout) {
+            this.readTimeout = readTimeout;
+            return this;
+        }
+
+        public Builder username(String username) {
+            this.username = username;
+            return this;
+        }
+
+        public Builder password(String password) {
+            this.password = password;
+            return this;
+        }
+
+        public Builder bearerToken(String bearerToken) {
+            this.bearerToken = bearerToken;
+            return this;
+        }
+
+        public MetricsSourceSettings build() {
+            return new MetricsSourceSettings(this);
+        }
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MimirMetricsSource.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MimirMetricsSource.java
new file mode 100644
index 00000000..11bb65b7
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MimirMetricsSource.java
@@ -0,0 +1,32 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.cluster.metrics;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.web.client.RestClient;
+
+/**
+ * Grafana Mimir metrics backend. Speaks the Prometheus query API mounted under
+ * the {@code /prometheus} tenant prefix (see {@link 
MetricsBackendType#MIMIR}).
+ */
+public class MimirMetricsSource extends 
AbstractPrometheusCompatibleMetricsSource {
+
+    public MimirMetricsSource(RestClient.Builder restClientBuilder, 
ObjectMapper objectMapper,
+                              MetricsSourceSettings settings) {
+        super(restClientBuilder, objectMapper, settings);
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/PrometheusMetricsSource.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/PrometheusMetricsSource.java
index 921b7402..42effc12 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/PrometheusMetricsSource.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/PrometheusMetricsSource.java
@@ -16,309 +16,42 @@
  */
 package org.apache.rocketmq.studio.cluster.metrics;
 
-import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.http.HttpHeaders;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.HttpStatusCode;
-import org.springframework.http.MediaType;
-import org.springframework.http.client.SimpleClientHttpRequestFactory;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Component;
-import org.springframework.util.LinkedMultiValueMap;
-import org.springframework.util.MultiValueMap;
-import org.springframework.util.StringUtils;
-import org.springframework.web.client.ResourceAccessException;
 import org.springframework.web.client.RestClient;
-import org.springframework.web.client.RestClientException;
 
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.SocketTimeoutException;
-import java.net.URI;
-import java.util.Iterator;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.stream.StreamSupport;
-
-@Slf4j
+/**
+ * Default Prometheus metrics backend. Built from {@link PrometheusProperties}.
+ * <p>
+ * Behaviour is identical to the other Prometheus-compatible backends; this 
class
+ * only adapts the Spring {@code @ConfigurationProperties} bean into the shared
+ * {@link AbstractPrometheusCompatibleMetricsSource}.
+ * </p>
+ */
 @Component
-public class PrometheusMetricsSource implements MetricsSource {
-
-    private static final String QUERY_RANGE_PATH = "/api/v1/query_range";
-    private static final int MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
-    private static final int MAX_SERIES = 1_000;
-    private static final int MAX_TOTAL_SAMPLES = 100_000;
-
-    private final RestClient restClient;
-    private final ObjectMapper objectMapper;
-    private final PrometheusProperties properties;
+public class PrometheusMetricsSource extends 
AbstractPrometheusCompatibleMetricsSource {
 
+    @Autowired
     public PrometheusMetricsSource(RestClient.Builder restClientBuilder, 
ObjectMapper objectMapper,
                                    PrometheusProperties properties) {
-        SimpleClientHttpRequestFactory requestFactory = new 
SimpleClientHttpRequestFactory();
-        requestFactory.setConnectTimeout(properties.getConnectTimeout());
-        requestFactory.setReadTimeout(properties.getReadTimeout());
-        this.restClient = 
restClientBuilder.requestFactory(requestFactory).build();
-        this.objectMapper = objectMapper;
-        this.properties = properties;
+        super(restClientBuilder, objectMapper, toSettings(properties));
     }
 
-    @Override
-    public MetricDataVO query(MetricQueryDTO query) {
-        validateQuery(query);
-        URI queryRangeUri = queryRangeUri();
-        MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
-        form.add("query", query.getMetric());
-        form.add("start", Long.toString(query.getStart()));
-        form.add("end", Long.toString(query.getEnd()));
-        form.add("step", query.getStep());
-
-        log.debug("Querying Prometheus range: start={}, end={}, step={}",
-                query.getStart(), query.getEnd(), query.getStep());
-
-        try {
-            JsonNode response = restClient.post()
-                    .uri(queryRangeUri)
-                    .contentType(MediaType.APPLICATION_FORM_URLENCODED)
-                    .headers(this::applyAuthentication)
-                    .body(form)
-                    .exchange((request, clientResponse) -> {
-                        JsonNode body = 
objectMapper.readTree(readResponseBody(clientResponse.getBody()));
-                        if (clientResponse.getStatusCode().isError()) {
-                            throw responseBodyException(body, 
responseStatus(clientResponse.getStatusCode()));
-                        }
-                        return body;
-                    });
-            return parseResponse(response);
-        } catch (PrometheusException exception) {
-            throw exception;
-        } catch (ResourceAccessException exception) {
-            if (hasCause(exception, SocketTimeoutException.class)) {
-                throw new 
PrometheusException(HttpStatus.GATEWAY_TIMEOUT.value(),
-                        "Prometheus query timed out", exception);
-            }
-            throw new PrometheusException(HttpStatus.BAD_GATEWAY.value(),
-                    "Failed to connect to Prometheus", exception);
-        } catch (RestClientException exception) {
-            if (hasCause(exception, SocketTimeoutException.class)) {
-                throw new 
PrometheusException(HttpStatus.GATEWAY_TIMEOUT.value(),
-                        "Prometheus query timed out", exception);
-            }
-            throw new PrometheusException(HttpStatus.BAD_GATEWAY.value(),
-                    "Prometheus query failed", exception);
-        }
-    }
-
-    private void validateQuery(MetricQueryDTO query) {
-        if (query == null) {
-            throw new PrometheusException(HttpStatus.BAD_REQUEST.value(), 
"Metric query is required");
-        }
-        if (!StringUtils.hasText(query.getMetric())) {
-            throw new PrometheusException(HttpStatus.BAD_REQUEST.value(), 
"Metric query is required");
-        }
-        if (query.getStart() <= 0) {
-            throw new PrometheusException(HttpStatus.BAD_REQUEST.value(), 
"Metric query start must be positive");
-        }
-        if (query.getEnd() <= 0) {
-            throw new PrometheusException(HttpStatus.BAD_REQUEST.value(), 
"Metric query end must be positive");
-        }
-        if (query.getEnd() < query.getStart()) {
-            throw new PrometheusException(HttpStatus.BAD_REQUEST.value(),
-                    "Metric query end must not be earlier than start");
-        }
-        if (!StringUtils.hasText(query.getStep())) {
-            throw new PrometheusException(HttpStatus.BAD_REQUEST.value(), 
"Metric query step is required");
-        }
-    }
-
-    private URI queryRangeUri() {
-        if (!StringUtils.hasText(properties.getBaseUrl())) {
-            throw new 
PrometheusException(HttpStatus.SERVICE_UNAVAILABLE.value(),
-                    "Prometheus base URL is not configured");
-        }
-        try {
-            String baseUrl = properties.getBaseUrl().strip();
-            while (baseUrl.endsWith("/")) {
-                baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
-            }
-            URI uri = URI.create(baseUrl + QUERY_RANGE_PATH);
-            if (!"http".equalsIgnoreCase(uri.getScheme()) && 
!"https".equalsIgnoreCase(uri.getScheme())) {
-                throw new IllegalArgumentException("Unsupported Prometheus URL 
scheme");
-            }
-            return uri;
-        } catch (IllegalArgumentException exception) {
-            throw new 
PrometheusException(HttpStatus.SERVICE_UNAVAILABLE.value(),
-                    "Prometheus base URL is invalid", exception);
-        }
-    }
-
-    private void applyAuthentication(HttpHeaders headers) {
-        if (StringUtils.hasText(properties.getBearerToken())) {
-            headers.setBearerAuth(properties.getBearerToken());
-            return;
-        }
-        boolean hasUsername = StringUtils.hasText(properties.getUsername());
-        boolean hasPassword = StringUtils.hasText(properties.getPassword());
-        if (hasUsername != hasPassword) {
-            throw new 
PrometheusException(HttpStatus.SERVICE_UNAVAILABLE.value(),
-                    "Prometheus basic authentication is incomplete");
-        }
-        if (hasUsername) {
-            headers.setBasicAuth(properties.getUsername(), 
properties.getPassword());
-        }
-    }
-
-    private MetricDataVO parseResponse(JsonNode response) {
-        if (response == null || 
!"success".equals(response.path("status").asText())) {
-            throw responseBodyException(response, 
HttpStatus.BAD_GATEWAY.value());
-        }
-
-        JsonNode data = response.path("data");
-        JsonNode result = data.path("result");
-        if (!data.isObject() || !result.isArray() || 
!StringUtils.hasText(data.path("resultType").asText())) {
-            throw new PrometheusException(HttpStatus.BAD_GATEWAY.value(),
-                    "Prometheus returned a malformed response");
-        }
-        validateResponseLimits(result);
-
-        List<MetricDataVO.MetricSeriesVO> series = 
StreamSupport.stream(result.spliterator(), false)
-                .map(this::parseSeries)
-                .toList();
-        List<String> warnings = parseWarnings(response.path("warnings"));
-
-        return MetricDataVO.builder()
-                .resultType(data.path("resultType").asText())
-                .series(series)
-                .warnings(warnings)
-                .build();
-    }
-
-    private MetricDataVO.MetricSeriesVO parseSeries(JsonNode seriesNode) {
-        JsonNode metric = seriesNode.path("metric");
-        JsonNode values = seriesNode.path("values");
-        JsonNode histograms = seriesNode.path("histograms");
-        boolean hasValues = values.isArray();
-        boolean hasHistograms = histograms.isArray();
-        boolean hasSamples = hasValues || hasHistograms;
-        boolean invalidValues = !values.isMissingNode() && !hasValues;
-        boolean invalidHistograms = !histograms.isMissingNode() && 
!hasHistograms;
-        if (!metric.isObject() || invalidValues || invalidHistograms || 
!hasSamples) {
-            throw new PrometheusException(HttpStatus.BAD_GATEWAY.value(),
-                    "Prometheus returned a malformed time series");
-        }
-
-        Map<String, String> labels = new LinkedHashMap<>();
-        Iterator<Map.Entry<String, JsonNode>> fields = metric.fields();
-        fields.forEachRemaining(entry -> labels.put(entry.getKey(), 
entry.getValue().asText()));
-
-        List<MetricDataVO.MetricSampleVO> samples = hasValues
-                ? StreamSupport.stream(values.spliterator(), 
false).map(this::parseSample).toList()
-                : List.of();
-        List<MetricDataVO.MetricHistogramSampleVO> histogramSamples = 
hasHistograms
-                ? StreamSupport.stream(histograms.spliterator(), 
false).map(this::parseHistogramSample).toList()
-                : List.of();
-        return MetricDataVO.MetricSeriesVO.builder()
-                .labels(labels)
-                .values(samples)
-                .histograms(histogramSamples)
-                .build();
-    }
-
-    private MetricDataVO.MetricSampleVO parseSample(JsonNode sampleNode) {
-        if (!sampleNode.isArray() || sampleNode.size() != 2 || 
!sampleNode.get(0).isNumber()) {
-            throw new PrometheusException(HttpStatus.BAD_GATEWAY.value(),
-                    "Prometheus returned a malformed sample");
-        }
-        return MetricDataVO.MetricSampleVO.builder()
-                .timestamp(sampleNode.get(0).asDouble())
-                .value(sampleNode.get(1).asText())
-                .build();
-    }
-
-    private MetricDataVO.MetricHistogramSampleVO parseHistogramSample(JsonNode 
sampleNode) {
-        if (!sampleNode.isArray() || sampleNode.size() != 2
-                || !sampleNode.get(0).isNumber() || 
!sampleNode.get(1).isObject()) {
-            throw new PrometheusException(HttpStatus.BAD_GATEWAY.value(),
-                    "Prometheus returned a malformed histogram sample");
-        }
-        return MetricDataVO.MetricHistogramSampleVO.builder()
-                .timestamp(sampleNode.get(0).asDouble())
-                .histogram(sampleNode.get(1))
+    public PrometheusMetricsSource(RestClient.Builder restClientBuilder, 
ObjectMapper objectMapper,
+                                   MetricsSourceSettings settings) {
+        super(restClientBuilder, objectMapper, settings);
+    }
+
+    private static MetricsSourceSettings toSettings(PrometheusProperties 
properties) {
+        return MetricsSourceSettings.builder()
+                .backendType(MetricsBackendType.PROMETHEUS)
+                .baseUrl(properties.getBaseUrl())
+                .connectTimeout(properties.getConnectTimeout())
+                .readTimeout(properties.getReadTimeout())
+                .username(properties.getUsername())
+                .password(properties.getPassword())
+                .bearerToken(properties.getBearerToken())
                 .build();
     }
-
-    private List<String> parseWarnings(JsonNode warningsNode) {
-        if (!warningsNode.isArray()) {
-            return List.of();
-        }
-        return StreamSupport.stream(warningsNode.spliterator(), false)
-                .map(JsonNode::asText)
-                .toList();
-    }
-
-    private byte[] readResponseBody(InputStream input) throws IOException {
-        try (InputStream response = input; ByteArrayOutputStream output = new 
ByteArrayOutputStream()) {
-            byte[] buffer = new byte[8 * 1024];
-            int read;
-            while ((read = response.read(buffer)) != -1) {
-                if (output.size() > MAX_RESPONSE_BYTES - read) {
-                    throw new 
PrometheusException(HttpStatus.PAYLOAD_TOO_LARGE.value(),
-                            "Prometheus response exceeds 5 MiB; narrow the 
query");
-                }
-                output.write(buffer, 0, read);
-            }
-            return output.toByteArray();
-        }
-    }
-
-    private void validateResponseLimits(JsonNode result) {
-        if (result.size() > MAX_SERIES) {
-            throw new PrometheusException(HttpStatus.PAYLOAD_TOO_LARGE.value(),
-                    "Prometheus query returned too many series; narrow the 
query");
-        }
-        long totalSamples = 0;
-        for (JsonNode series : result) {
-            totalSamples += series.path("values").size();
-            totalSamples += series.path("histograms").size();
-            if (totalSamples > MAX_TOTAL_SAMPLES) {
-                throw new 
PrometheusException(HttpStatus.PAYLOAD_TOO_LARGE.value(),
-                        "Prometheus query returned too many samples; increase 
step or narrow the query");
-            }
-        }
-    }
-
-    private int responseStatus(HttpStatusCode statusCode) {
-        int upstreamStatus = statusCode.value();
-        int mappedStatus = switch (upstreamStatus) {
-            case 400, 422, 503 -> upstreamStatus;
-            default -> HttpStatus.BAD_GATEWAY.value();
-        };
-        return mappedStatus;
-    }
-
-    private PrometheusException responseBodyException(JsonNode response, int 
statusCode) {
-        String errorType = response == null ? "" : 
response.path("errorType").asText();
-        String error = response == null ? "" : response.path("error").asText();
-        if (StringUtils.hasText(error)) {
-            String message = StringUtils.hasText(errorType)
-                    ? "Prometheus query failed (" + errorType + "): " + error
-                    : "Prometheus query failed: " + error;
-            return new PrometheusException(statusCode, message);
-        }
-        return new PrometheusException(statusCode, "Prometheus query failed");
-    }
-
-    private boolean hasCause(Throwable throwable, Class<? extends Throwable> 
causeType) {
-        Throwable current = throwable;
-        while (current != null) {
-            if (causeType.isInstance(current)) {
-                return true;
-            }
-            current = current.getCause();
-        }
-        return false;
-    }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/ThanosMetricsSource.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/ThanosMetricsSource.java
new file mode 100644
index 00000000..165bfac2
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/ThanosMetricsSource.java
@@ -0,0 +1,32 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.cluster.metrics;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.web.client.RestClient;
+
+/**
+ * Thanos metrics backend. Exposes the Prometheus query API through the Thanos
+ * Query component (see {@link MetricsBackendType#THANOS}).
+ */
+public class ThanosMetricsSource extends 
AbstractPrometheusCompatibleMetricsSource {
+
+    public ThanosMetricsSource(RestClient.Builder restClientBuilder, 
ObjectMapper objectMapper,
+                               MetricsSourceSettings settings) {
+        super(restClientBuilder, objectMapper, settings);
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/VictoriaMetricsMetricsSource.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/VictoriaMetricsMetricsSource.java
new file mode 100644
index 00000000..4bd8d3c6
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/VictoriaMetricsMetricsSource.java
@@ -0,0 +1,32 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.cluster.metrics;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.web.client.RestClient;
+
+/**
+ * VictoriaMetrics metrics backend. Speaks the Prometheus query API mounted 
under
+ * {@code /select/0/prometheus} (see {@link 
MetricsBackendType#VICTORIA_METRICS}).
+ */
+public class VictoriaMetricsMetricsSource extends 
AbstractPrometheusCompatibleMetricsSource {
+
+    public VictoriaMetricsMetricsSource(RestClient.Builder restClientBuilder, 
ObjectMapper objectMapper,
+                                         MetricsSourceSettings settings) {
+        super(restClientBuilder, objectMapper, settings);
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/model/request/MetricsDataSourceQueryRequest.java
 
b/server/src/main/java/org/apache/rocketmq/studio/model/request/MetricsDataSourceQueryRequest.java
new file mode 100644
index 00000000..7144f193
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/model/request/MetricsDataSourceQueryRequest.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.model.request;
+
+import org.apache.rocketmq.studio.cluster.metrics.MetricQueryDTO;
+
+/**
+ * Request to run a PromQL range query against a configured data source.
+ *
+ * <p>The data source is identified by its persisted key (carrying the backend
+ * {@code type} and {@code url}); credentials are supplied per request and are
+ * never persisted, mirroring the existing {@code 
/api/settings/datasources/test}
+ * flow.</p>
+ */
+public class MetricsDataSourceQueryRequest {
+
+    private MetricQueryDTO query;
+
+    private String username;
+
+    private String password;
+
+    private String bearerToken;
+
+    public MetricQueryDTO getQuery() {
+        return query;
+    }
+
+    public void setQuery(MetricQueryDTO query) {
+        this.query = query;
+    }
+
+    public String getUsername() {
+        return username;
+    }
+
+    public void setUsername(String username) {
+        this.username = username;
+    }
+
+    public String getPassword() {
+        return password;
+    }
+
+    public void setPassword(String password) {
+        this.password = password;
+    }
+
+    public String getBearerToken() {
+        return bearerToken;
+    }
+
+    public void setBearerToken(String bearerToken) {
+        this.bearerToken = bearerToken;
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsService.java 
b/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsService.java
index 0d8295dc..ceda5b7c 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsService.java
@@ -46,7 +46,7 @@ import java.util.UUID;
 public class SettingsService {
 
     private static final Set<String> PROMETHEUS_COMPATIBLE_TYPES = Set.of(
-            "prometheus", "victoriametrics", "thanos", "mimir");
+            "prometheus", "victoriametrics", "thanos", "mimir", "cortex", 
"arms");
     private static final String PROMETHEUS_TEST_QUERY = "up";
     private static final String AUTH_NONE = "none";
     private static final String AUTH_BASIC = "basic auth";
@@ -131,6 +131,14 @@ public class SettingsService {
     }
 
 
+    public DataSourceVO getDataSource(String key) {
+        String normalizedKey = normalizeDataSourceKey(key);
+        log.debug("Loading data source: {}", normalizedKey);
+        return settingsRepository.findDataSourceByKey(normalizedKey)
+                .orElseThrow(() -> new BusinessException(404, "Data source not 
found: " + normalizedKey));
+    }
+
+
     public DataSourceTestResultVO testDataSource(DataSourceTestDTO request) {
         log.info("Testing data source connection: type={}", request == null ? 
null : request.getType());
         if (request == null) {
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricsBackendTypeTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricsBackendTypeTest.java
new file mode 100644
index 00000000..02defc44
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricsBackendTypeTest.java
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.cluster.metrics;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class MetricsBackendTypeTest {
+
+    @Test
+    void shouldResolveEverySupportedProviderType() {
+        
assertThat(MetricsBackendType.fromProviderType("PROMETHEUS")).isEqualTo(MetricsBackendType.PROMETHEUS);
+        assertThat(MetricsBackendType.fromProviderType("VICTORIAMETRICS"))
+                .isEqualTo(MetricsBackendType.VICTORIA_METRICS);
+        assertThat(MetricsBackendType.fromProviderType("VICTORIA_METRICS"))
+                .isEqualTo(MetricsBackendType.VICTORIA_METRICS);
+        
assertThat(MetricsBackendType.fromProviderType("THANOS")).isEqualTo(MetricsBackendType.THANOS);
+        
assertThat(MetricsBackendType.fromProviderType("CORTEX")).isEqualTo(MetricsBackendType.CORTEX);
+        
assertThat(MetricsBackendType.fromProviderType("MIMIR")).isEqualTo(MetricsBackendType.MIMIR);
+        
assertThat(MetricsBackendType.fromProviderType("ARMS")).isEqualTo(MetricsBackendType.ARMS);
+        
assertThat(MetricsBackendType.fromProviderType("CUSTOM")).isEqualTo(MetricsBackendType.CUSTOM);
+    }
+
+    @Test
+    void shouldDefaultUnknownProviderTypeToPrometheus() {
+        
assertThat(MetricsBackendType.fromProviderType(null)).isEqualTo(MetricsBackendType.PROMETHEUS);
+        
assertThat(MetricsBackendType.fromProviderType("")).isEqualTo(MetricsBackendType.PROMETHEUS);
+        
assertThat(MetricsBackendType.fromProviderType("unknown-backend")).isEqualTo(MetricsBackendType.PROMETHEUS);
+    }
+
+    @Test
+    void shouldExposeDistinctQueryPathsForBackends() {
+        
assertThat(MetricsBackendType.PROMETHEUS.getQueryPath()).isEqualTo("/api/v1/query_range");
+        assertThat(MetricsBackendType.VICTORIA_METRICS.getQueryPath())
+                .isEqualTo("/select/0/prometheus/api/v1/query_range");
+        
assertThat(MetricsBackendType.MIMIR.getQueryPath()).isEqualTo("/prometheus/api/v1/query_range");
+        
assertThat(MetricsBackendType.THANOS.getQueryPath()).isEqualTo("/api/v1/query_range");
+        
assertThat(MetricsBackendType.CORTEX.getQueryPath()).isEqualTo("/api/v1/query_range");
+        
assertThat(MetricsBackendType.ARMS.getQueryPath()).isEqualTo("/api/v1/query_range");
+    }
+}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricsServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricsServiceTest.java
index a0d790cd..99f9f563 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricsServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricsServiceTest.java
@@ -16,6 +16,10 @@
  */
 package org.apache.rocketmq.studio.cluster.metrics;
 
+import org.apache.rocketmq.studio.model.MetricsDataSourceConfig;
+import org.apache.rocketmq.studio.model.request.MetricsDataSourceQueryRequest;
+import org.apache.rocketmq.studio.settings.DataSourceVO;
+import org.apache.rocketmq.studio.settings.SettingsService;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.ArgumentCaptor;
@@ -43,6 +47,12 @@ class MetricsServiceTest {
     @Mock
     private MetricProfileService metricProfileService;
 
+    @Mock
+    private MetricsSourceFactory metricsSourceFactory;
+
+    @Mock
+    private SettingsService settingsService;
+
     @InjectMocks
     private MetricsService metricsService;
 
@@ -307,4 +317,47 @@ class MetricsServiceTest {
                     assertThat(exception.getMessage()).isEqualTo(message);
                 });
     }
+
+    @Test
+    void queryByDataSourceShouldBuildSourceFromConfiguredDataSource() {
+        MetricQueryDTO query = MetricQueryDTO.builder()
+                .metric("cpu")
+                .start(1700000000L)
+                .end(1700003600L)
+                .step("1m")
+                .build();
+        MetricsDataSourceQueryRequest request = new 
MetricsDataSourceQueryRequest();
+        request.setQuery(query);
+
+        DataSourceVO dataSource = DataSourceVO.builder()
+                .key("ds-1")
+                .name("thanos-prod")
+                .type("thanos")
+                .url("http://thanos:9090";)
+                .auth("none")
+                .build();
+        when(settingsService.getDataSource("ds-1")).thenReturn(dataSource);
+        
when(metricsSourceFactory.create(any(MetricsDataSourceConfig.class))).thenReturn(metricsSource);
+        MetricDataVO data = metricData("cpu", List.of(sample(1700000000L, 
"1")));
+        when(metricsSource.query(any(MetricQueryDTO.class))).thenReturn(data);
+
+        MetricDataVO result = metricsService.queryByDataSource("ds-1", 
request);
+
+        assertThat(result.getSeries()).hasSize(1);
+        verify(settingsService).getDataSource("ds-1");
+        
verify(metricsSourceFactory).create(any(MetricsDataSourceConfig.class));
+        verify(metricsSource).query(any(MetricQueryDTO.class));
+    }
+
+    @Test
+    void queryByDataSourceShouldRejectMissingKey() {
+        MetricsDataSourceQueryRequest request = new 
MetricsDataSourceQueryRequest();
+        request.setQuery(MetricQueryDTO.builder()
+                
.metric("cpu").start(1700000000L).end(1700003600L).step("1m").build());
+
+        assertThatExceptionOfType(PrometheusException.class)
+                .isThrownBy(() -> metricsService.queryByDataSource("  ", 
request))
+                .satisfies(exception -> 
assertThat(exception.getStatusCode()).isEqualTo(400));
+        verifyNoInteractions(settingsService, metricsSourceFactory, 
metricsSource);
+    }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MultiBackendMetricsSourceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MultiBackendMetricsSourceTest.java
new file mode 100644
index 00000000..0e3875f4
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MultiBackendMetricsSourceTest.java
@@ -0,0 +1,128 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.cluster.metrics;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import org.apache.rocketmq.studio.model.MetricsDataSourceConfig;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.springframework.web.client.RestClient;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class MultiBackendMetricsSourceTest {
+
+    private HttpServer server;
+    private String baseUrl;
+    private final MetricsSourceFactory factory =
+            new MetricsSourceFactory(RestClient.builder(), new ObjectMapper());
+
+    @BeforeEach
+    void setUp() throws IOException {
+        server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+        baseUrl = "http://127.0.0.1:"; + server.getAddress().getPort();
+        server.start();
+    }
+
+    @AfterEach
+    void tearDown() {
+        server.stop(0);
+    }
+
+    @ParameterizedTest
+    @EnumSource(MetricsBackendType.class)
+    void 
everyBackendShouldHitItsOwnQueryPathAndParseTheResponse(MetricsBackendType 
backendType) {
+        AtomicReference<String> requestPath = new AtomicReference<>();
+        server.createContext(backendType.getQueryPath(), exchange -> {
+            requestPath.set(exchange.getRequestURI().getPath());
+            respond(exchange, 200, """
+                    
{"status":"success","data":{"resultType":"matrix","result":[
+                      {"metric":{"backend":"%s"},"values":[[1784107658,"1.0"]]}
+                    ]}}
+                    """.formatted(backendType.name()));
+        });
+
+        MetricsDataSourceConfig config = configFor(backendType);
+        MetricsSource source = factory.create(config);
+        MetricDataVO result = source.query(query());
+
+        assertThat(requestPath.get()).isEqualTo(backendType.getQueryPath());
+        assertThat(result.getResultType()).isEqualTo("matrix");
+        assertThat(result.getSeries()).hasSize(1);
+        
assertThat(result.getSeries().get(0).getLabels()).containsEntry("backend", 
backendType.name());
+        
assertThat(result.getSeries().get(0).getValues().get(0).getValue()).isEqualTo("1.0");
+    }
+
+    @Test
+    void factoryShouldReturnTheMatchingConcreteClassPerProviderType() {
+        assertThat(factory.create(configFor(MetricsBackendType.PROMETHEUS)))
+                .isInstanceOf(PrometheusMetricsSource.class);
+        
assertThat(factory.create(configFor(MetricsBackendType.VICTORIA_METRICS)))
+                .isInstanceOf(VictoriaMetricsMetricsSource.class);
+        assertThat(factory.create(configFor(MetricsBackendType.THANOS)))
+                .isInstanceOf(ThanosMetricsSource.class);
+        assertThat(factory.create(configFor(MetricsBackendType.CORTEX)))
+                .isInstanceOf(CortexMetricsSource.class);
+        assertThat(factory.create(configFor(MetricsBackendType.MIMIR)))
+                .isInstanceOf(MimirMetricsSource.class);
+        assertThat(factory.create(configFor(MetricsBackendType.ARMS)))
+                .isInstanceOf(ArmsMetricsSource.class);
+    }
+
+    @Test
+    void unknownProviderTypeShouldFallBackToPrometheus() {
+        MetricsDataSourceConfig config = new MetricsDataSourceConfig();
+        config.setProviderType("does-not-exist");
+        config.setUrl(baseUrl);
+        
assertThat(factory.create(config)).isInstanceOf(PrometheusMetricsSource.class);
+    }
+
+    private MetricsDataSourceConfig configFor(MetricsBackendType backendType) {
+        MetricsDataSourceConfig config = new MetricsDataSourceConfig();
+        config.setName(backendType.name().toLowerCase());
+        config.setProviderType(backendType.name());
+        config.setUrl(baseUrl);
+        return config;
+    }
+
+    private MetricQueryDTO query() {
+        return MetricQueryDTO.builder()
+                .metric("up")
+                .start(1784107658L)
+                .end(1784108558L)
+                .step("30s")
+                .build();
+    }
+
+    private void respond(HttpExchange exchange, int statusCode, String body) 
throws IOException {
+        byte[] response = body.getBytes(StandardCharsets.UTF_8);
+        exchange.getResponseHeaders().set("Content-Type", "application/json");
+        exchange.sendResponseHeaders(statusCode, response.length);
+        exchange.getResponseBody().write(response);
+        exchange.close();
+    }
+}
diff --git a/web/src/api/metrics.test.ts b/web/src/api/metrics.test.ts
index b517ae4e..6f85c96e 100644
--- a/web/src/api/metrics.test.ts
+++ b/web/src/api/metrics.test.ts
@@ -24,6 +24,7 @@ import {
   getGrafanaDashboard,
   exportGrafanaDashboard,
   listMetricProfiles,
+  queryByDataSource,
   queryMetrics,
 } from './metrics';
 
@@ -87,6 +88,37 @@ describe('metrics API', () => {
     await expect(queryMetrics(query)).resolves.toEqual(result);
   });
 
+  it('posts a data-source query by key and returns its result', async () => {
+    const dsQuery = {
+      key: 'ds-prom-1',
+      query: { metric: 'up', start: 1, end: 2, step: '1m' },
+    };
+    const result = {
+      resultType: 'matrix',
+      series: [
+        {
+          labels: { instance: 'prometheus:9090' },
+          values: [{ timestamp: 1, value: '1' }],
+          histograms: [],
+        },
+      ],
+      warnings: [],
+    };
+
+    mock.onPost('/metrics/query/datasource').reply((config) => {
+      expect(config.params).toEqual({ key: 'ds-prom-1' });
+      expect(JSON.parse(config.data)).toEqual({
+        query: dsQuery.query,
+        username: undefined,
+        password: undefined,
+        bearerToken: undefined,
+      });
+      return [200, { code: 200, data: result }];
+    });
+
+    await expect(queryByDataSource(dsQuery)).resolves.toEqual(result);
+  });
+
   it('loads version-aware metric profiles', async () => {
     const profiles = [
       {
diff --git a/web/src/api/metrics.ts b/web/src/api/metrics.ts
index 8471b0af..a3d8034f 100644
--- a/web/src/api/metrics.ts
+++ b/web/src/api/metrics.ts
@@ -103,6 +103,26 @@ export async function queryMetrics(query: MetricQuery) {
   return res.data.data;
 }
 
+export interface DataSourceQuery {
+  key: string;
+  query: MetricQuery;
+  username?: string;
+  password?: string;
+  bearerToken?: string;
+}
+
+// Runs a PromQL range query against a configured data source (key identifies 
the
+// persisted source; credentials are optional and fall back to the stored 
config).
+export async function queryByDataSource(params: DataSourceQuery) {
+  const { key, query, username, password, bearerToken } = params;
+  const res = await client.post<{ data: MetricData }>(
+    '/metrics/query/datasource',
+    { query, username, password, bearerToken },
+    { params: { key } },
+  );
+  return res.data.data;
+}
+
 export async function listMetricProfiles() {
   const res = await client.get<{ data: MetricProfile[] }>('/metrics/profiles');
   return res.data.data;
diff --git a/web/src/api/dataSources.test.ts b/web/src/api/settings.test.ts
similarity index 100%
rename from web/src/api/dataSources.test.ts
rename to web/src/api/settings.test.ts
diff --git a/web/src/components/MetricsExplorer.tsx 
b/web/src/components/MetricsExplorer.tsx
index 5ad43444..6acc99f0 100644
--- a/web/src/components/MetricsExplorer.tsx
+++ b/web/src/components/MetricsExplorer.tsx
@@ -30,7 +30,9 @@ import {
 } from 'antd';
 import { ArrowsClockwise } from '@phosphor-icons/react';
 
-import { listMetricProfiles, queryMetrics } from '../api/metrics';
+import { listDataSources } from '../api/settings';
+import { listMetricProfiles, queryByDataSource, queryMetrics } from 
'../api/metrics';
+import type { DataSource } from '../api/settings';
 import type { MetricData, MetricMapping, MetricProfile, MetricSeries } from 
'../api/metrics';
 import { useLang } from '../i18n/LangContext';
 
@@ -223,6 +225,7 @@ const MetricsExplorer = () => {
           queryError: 'Prometheus 查询失败',
           noProfiles: '暂无指标模板',
           noSamples: '暂无标量数据',
+          defaultDataSource: '默认数据源',
         }
       : {
           title: 'Prometheus Metrics',
@@ -234,6 +237,7 @@ const MetricsExplorer = () => {
           queryError: 'Prometheus query failed',
           noProfiles: 'No metric profiles',
           noSamples: 'No scalar samples',
+          defaultDataSource: 'Default source',
         };
   const [profiles, setProfiles] = useState<MetricProfile[]>([]);
   const [profileId, setProfileId] = useState('');
@@ -244,6 +248,9 @@ const MetricsExplorer = () => {
   const [queryLoading, setQueryLoading] = useState(false);
   const [profileError, setProfileError] = useState(false);
   const [queryError, setQueryError] = useState(false);
+  const [dataSources, setDataSources] = useState<DataSource[]>([]);
+  const [dataSourceKey, setDataSourceKey] = useState('');
+  const [dataSourcesLoading, setDataSourcesLoading] = useState(true);
   const requestId = useRef(0);
 
   const selectedProfile = useMemo(
@@ -261,15 +268,18 @@ const MetricsExplorer = () => {
       if (!metric) return;
       const currentRequest = ++requestId.current;
       const end = Math.floor(Date.now() / 1000);
+      const query = {
+        metric: metric.promql,
+        start: end - range.seconds,
+        end,
+        step: range.step,
+      };
       setQueryLoading(true);
       setQueryError(false);
       try {
-        const result = await queryMetrics({
-          metric: metric.promql,
-          start: end - range.seconds,
-          end,
-          step: range.step,
-        });
+        const result = dataSourceKey
+          ? await queryByDataSource({ key: dataSourceKey, query })
+          : await queryMetrics(query);
         if (currentRequest === requestId.current) setData(result);
       } catch {
         if (currentRequest === requestId.current) {
@@ -280,7 +290,7 @@ const MetricsExplorer = () => {
         if (currentRequest === requestId.current) setQueryLoading(false);
       }
     },
-    [],
+    [dataSourceKey],
   );
 
   useEffect(() => {
@@ -332,6 +342,29 @@ const MetricsExplorer = () => {
     void loadMetrics(selectedMetric, nextRange);
   };
 
+  const handleDataSourceChange = (nextKey: string) => {
+    setDataSourceKey(nextKey);
+    setData(null);
+    void loadMetrics(selectedMetric, selectedRange);
+  };
+
+  useEffect(() => {
+    let cancelled = false;
+    void listDataSources()
+      .then((next) => {
+        if (!cancelled) setDataSources(next);
+      })
+      .catch(() => {
+        /* data-source list is optional; the default source still works */
+      })
+      .finally(() => {
+        if (!cancelled) setDataSourcesLoading(false);
+      });
+    return () => {
+      cancelled = true;
+    };
+  }, []);
+
   return (
     <section aria-labelledby="metrics-explorer-title" style={{ marginTop: 24 
}}>
       <Flex
@@ -349,6 +382,17 @@ const MetricsExplorer = () => {
         </Flex>
 
         <Flex gap={8} wrap="wrap" align="center" style={{ maxWidth: '100%' }}>
+          <Select
+            aria-label="数据源"
+            value={dataSourceKey || undefined}
+            loading={dataSourcesLoading}
+            onChange={handleDataSourceChange}
+            options={[
+              { label: copy.defaultDataSource, value: '' },
+              ...dataSources.map((ds) => ({ label: ds.name, value: ds.key })),
+            ]}
+            style={{ width: 200, maxWidth: '100%' }}
+          />
           <Select
             aria-label={copy.profile}
             value={profileId || undefined}
diff --git a/web/src/components/__tests__/MetricsExplorer.test.tsx 
b/web/src/components/__tests__/MetricsExplorer.test.tsx
index 8149f0db..1d8d7293 100644
--- a/web/src/components/__tests__/MetricsExplorer.test.tsx
+++ b/web/src/components/__tests__/MetricsExplorer.test.tsx
@@ -21,12 +21,18 @@ import userEvent from '@testing-library/user-event';
 import type React from 'react';
 import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 
'vitest';
 
-import { listMetricProfiles, queryMetrics } from '../../api/metrics';
+import { listDataSources } from '../../api/settings';
+import { listMetricProfiles, queryByDataSource, queryMetrics } from 
'../../api/metrics';
 import { LangProvider } from '../../i18n/LangContext';
 import MetricsExplorer from '../MetricsExplorer';
 
+vi.mock('../../api/settings', () => ({
+  listDataSources: vi.fn(),
+}));
+
 vi.mock('../../api/metrics', () => ({
   listMetricProfiles: vi.fn(),
+  queryByDataSource: vi.fn(),
   queryMetrics: vi.fn(),
 }));
 
@@ -96,7 +102,9 @@ beforeAll(() => {
 
 beforeEach(() => {
   vi.spyOn(Date, 'now').mockReturnValue(1_800_000_000_000);
+  vi.mocked(listDataSources).mockResolvedValue([]);
   vi.mocked(listMetricProfiles).mockResolvedValue(profiles);
+  vi.mocked(queryByDataSource).mockResolvedValue(metricData);
   vi.mocked(queryMetrics).mockResolvedValue(metricData);
 });
 
@@ -161,7 +169,7 @@ describe('MetricsExplorer', () => {
     renderWithProviders(<MetricsExplorer />);
 
     await screen.findByRole('img', { name: 'Message In TPS time series' });
-    await user.click(screen.getAllByRole('combobox')[0]);
+    await user.click(screen.getByRole('combobox', { name: '指标模板' }));
     await user.click(
       await screen.findByText('RocketMQ 4.x Exporter', {
         selector: '.ant-select-item-option-content',
@@ -223,4 +231,42 @@ describe('MetricsExplorer', () => {
 
     expect(await screen.findByText('暂无标量数据')).toBeInTheDocument();
   });
+
+  it('queries the selected data source through the datasource endpoint', async 
() => {
+    const user = userEvent.setup();
+    vi.mocked(listDataSources).mockResolvedValue([
+      {
+        key: 'ds-prom-1',
+        name: 'Prometheus 生产',
+        type: 'Prometheus',
+        url: '',
+        auth: 'None',
+        status: 'healthy',
+      },
+    ]);
+    vi.mocked(queryByDataSource).mockResolvedValue(metricData);
+
+    renderWithProviders(<MetricsExplorer />);
+
+    await screen.findByRole('combobox', { name: '数据源' });
+    await user.click(screen.getByRole('combobox', { name: '数据源' }));
+    await user.click(
+      await screen.findByText('Prometheus 生产', { selector: 
'.ant-select-item-option-content' }),
+    );
+
+    const queryMetricsCallsBefore = vi.mocked(queryMetrics).mock.calls.length;
+
+    await waitFor(() =>
+      expect(queryByDataSource).toHaveBeenCalledWith({
+        key: 'ds-prom-1',
+        query: {
+          metric: 'sum(rate(rocketmq_messages_in_total[1m])) by (cluster, 
node_id)',
+          start: 1_799_996_400,
+          end: 1_800_000_000,
+          step: '30s',
+        },
+      }),
+    );
+    
expect(vi.mocked(queryMetrics).mock.calls.length).toBe(queryMetricsCallsBefore);
+  });
 });
diff --git a/web/src/pages/settings/__tests__/DataSourceTab.test.tsx 
b/web/src/pages/settings/__tests__/DataSourceTab.test.tsx
index d8012277..005d432f 100644
--- a/web/src/pages/settings/__tests__/DataSourceTab.test.tsx
+++ b/web/src/pages/settings/__tests__/DataSourceTab.test.tsx
@@ -249,6 +249,36 @@ describe('DataSourceTab', () => {
       });
     });
   });
+
+  it('offers Cortex and ARMS alongside the other backend types', async () => {
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    render(
+      <App>
+        <DataSourceTab />
+      </App>,
+    );
+
+    await screen.findByText('Prometheus prod');
+    await user.click(screen.getByRole('button', { name: /添加数据源/ }));
+    await user.click(screen.getByLabelText('类型'));
+
+    const popup = await waitFor(() => {
+      const element = document.getElementById('type_list');
+      if (!element) throw new Error('Missing popup type_list');
+      return element;
+    });
+
+    for (const type of [
+      'Prometheus',
+      'VictoriaMetrics',
+      'Thanos',
+      'Grafana Mimir',
+      'Cortex',
+      'ARMS',
+    ]) {
+      expect(within(popup).getByRole('option', { name: type 
})).toBeInTheDocument();
+    }
+  });
 });
 
 // antd's Select dropdown keeps `pointer-events: none` while its open 
animation runs,
diff --git a/web/src/pages/settings/index.tsx b/web/src/pages/settings/index.tsx
index 374b486d..effed65c 100644
--- a/web/src/pages/settings/index.tsx
+++ b/web/src/pages/settings/index.tsx
@@ -69,8 +69,21 @@ const typeTagColor: Record<string, string> = {
   VictoriaMetrics: 'blue',
   Thanos: 'purple',
   Mimir: 'cyan',
+  Cortex: 'green',
+  ARMS: 'red',
 };
 
+// Backend types mirror the Prometheus-compatible backends the server accepts
+// (see SettingsService.PROMETHEUS_COMPATIBLE_TYPES and MetricsBackendType).
+const DATA_SOURCE_TYPE_OPTIONS = [
+  { value: 'Prometheus', label: 'Prometheus' },
+  { value: 'VictoriaMetrics', label: 'VictoriaMetrics' },
+  { value: 'Thanos', label: 'Thanos' },
+  { value: 'Mimir', label: 'Grafana Mimir' },
+  { value: 'Cortex', label: 'Cortex' },
+  { value: 'ARMS', label: 'ARMS' },
+];
+
 type DataSourceFormValues = Partial<DataSource>;
 
 const secretFieldNames = ['username', 'password', 'bearerToken'] as const;
@@ -457,16 +470,7 @@ export const DataSourceTab = () => {
             name="type"
             rules={[{ required: true, message: '请选择数据源类型' }]}
           >
-            <Select
-              placeholder="请选择"
-              virtual={false}
-              options={[
-                { value: 'Prometheus', label: 'Prometheus' },
-                { value: 'VictoriaMetrics', label: 'VictoriaMetrics' },
-                { value: 'Thanos', label: 'Thanos' },
-                { value: 'Mimir', label: 'Grafana Mimir' },
-              ]}
-            />
+            <Select placeholder="请选择" virtual={false} 
options={DATA_SOURCE_TYPE_OPTIONS} />
           </Form.Item>
 
           <Form.Item

Reply via email to