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