Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
7a27c63
fix: align frontend API success response handling (#429)
btlqql Jul 14, 2026
f971efc
ci: add build workflow and PR review skill (#430)
lizhimins Jul 15, 2026
a002477
fix: connect K8s certificate page to APIs (#440)
btlqql Jul 17, 2026
6f43bff
feat: extend translation keys & add useLanguage compatible alias in L…
zhaohai666 Jul 17, 2026
b5f7da4
fix: validate audit query and cleanup parameters (#442)
btlqql Jul 17, 2026
23f20a2
chore: fix Dockerfile missing style/ copy and standardize pr-review s…
terrancesli Jul 17, 2026
decb079
feat: add centralized ThemeContext and useTheme hook for dark mode ma…
zhaohai666 Jul 17, 2026
d9a1ac3
fix: preserve SSE framing for AI chat (#438)
btlqql Jul 17, 2026
407fe51
feat: add Prometheus range query adapter (#432)
Kris20030907 Jul 22, 2026
6c853c4
feat: implement login page, auth & AI modules, simplify theme managem…
zhaohai666 Jul 22, 2026
13bef2d
feat: add Ops page (NameServer management, VIPChannel, TLS) (#474)
zhaohai666 Jul 22, 2026
4c24628
feat: add Producer page (#475)
zhaohai666 Jul 22, 2026
428c00b
feat: add AlertManagement page for alert rule operations (#476)
zhaohai666 Jul 22, 2026
7a1ac22
feat: add SslSettings page for SSL/TLS configuration management (#477)
zhaohai666 Jul 22, 2026
eb72ab5
feat: add broker cluster overview page (#479)
zhaohai666 Jul 22, 2026
ae87f31
feat: add consumer group management page (#480)
zhaohai666 Jul 22, 2026
9a0b9df
feat: add lite topic management page (#481)
zhaohai666 Jul 22, 2026
a584ff7
feat: add Proxy management page (#482)
zhaohai666 Jul 22, 2026
c694b94
feat: add LLM Settings configuration page (#483)
zhaohai666 Jul 22, 2026
bc3ab94
feat: connect frontend pages to backend APIs and align contracts (#445)
Loyal-Young Jul 22, 2026
cc8e0a5
feat: add service capabilities and interaction enhancements (#462)
Loyal-Young Jul 22, 2026
9cd4a30
feat: add preferences persistence, accessibility and env config (#465)
Loyal-Young Jul 22, 2026
a63059e
fix: include remaining consolidated frontend changes (#490)
lizhimins Jul 22, 2026
bfa75f9
[studio] feat: enhance UI with improved styles, MiniLine chart compon…
zhaohai666 Jul 23, 2026
8b111cd
[studio] test: add unit tests for MiniLine chart component
zhaohai666 Jul 23, 2026
ebfd043
Merge remote-tracking branch 'upstream/rocketmq-studio' into temp-pr-…
zhaohai666 Jul 29, 2026
8a47d60
fix: resolve PR review feedback and test issues
zhaohai666 Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
539 changes: 539 additions & 0 deletions docs/open-pr-report-20260722.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ private void validatePagination(int page, int pageSize) {
}
}

private LocalDateTime parseDate(String dateStr, boolean startOfDay, String parameterName) {
private LocalDateTime parseDate(String dateStr, boolean startOfDay, String parameterName) {
if (dateStr == null || dateStr.isEmpty()) {
return null;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* 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 com.rocketmq.studio.cluster.metrics;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(MetricsController.class)
@AutoConfigureMockMvc(addFilters = false)
class MetricsControllerTest {

@Autowired
private MockMvc mockMvc;

@MockBean
private MetricsService metricsService;

@Test
void queryShouldReturnBadRequestWhenFieldTypeIsInvalid() throws Exception {
mockMvc.perform(post("/api/metrics/query")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"metric":"up","start":"abc","end":123,"step":"30s"}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400))
.andExpect(jsonPath("$.message").value("Invalid request body"));

verifyNoInteractions(metricsService);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,321 @@
/*
* 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 com.rocketmq.studio.cluster.metrics;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.web.client.RestClient;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
import java.util.concurrent.atomic.AtomicReference;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class PrometheusMetricsSourceTest {

private HttpServer server;
private String baseUrl;

@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);
}

@Test
void queryShouldPreserveSeriesLabelsDecimalValuesAndWarnings() {
AtomicReference<String> requestBody = new AtomicReference<>();
AtomicReference<String> requestMethod = new AtomicReference<>();
AtomicReference<String> contentType = new AtomicReference<>();
server.createContext("/api/v1/query_range", exchange -> {
requestMethod.set(exchange.getRequestMethod());
contentType.set(exchange.getRequestHeaders().getFirst("Content-Type"));
requestBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
respond(exchange, 200, """
{
"status": "success",
"data": {
"resultType": "matrix",
"result": [
{
"metric": {"node_id": "broker-a", "cluster": "cluster-a"},
"values": [[1784107658, "0.30000000000000004"], [1784107688, "NaN"]]
},
{
"metric": {"node_id": "broker-b", "cluster": "cluster-a"},
"values": [[1784107658.5, "1.25"]]
}
]
},
"warnings": ["partial response"]
}
""");
});

PrometheusMetricsSource source = source(Duration.ofSeconds(2));
MetricDataVO result = source.query(query());

assertThat(result.getResultType()).isEqualTo("matrix");
assertThat(result.getSeries()).hasSize(2);
assertThat(result.getSeries().get(0).getLabels())
.containsEntry("node_id", "broker-a")
.containsEntry("cluster", "cluster-a");
assertThat(result.getSeries().get(0).getValues().get(0).getValue())
.isEqualTo("0.30000000000000004");
assertThat(result.getSeries().get(0).getValues().get(1).getValue()).isEqualTo("NaN");
assertThat(result.getSeries().get(1).getValues().get(0).getTimestamp()).isEqualTo(1784107658.5D);
assertThat(result.getWarnings()).containsExactly("partial response");

assertThat(requestMethod.get()).isEqualTo("POST");
assertThat(contentType.get()).startsWith("application/x-www-form-urlencoded");
String decodedBody = URLDecoder.decode(requestBody.get(), StandardCharsets.UTF_8);
assertThat(decodedBody).contains("query=sum(rate(rocketmq_messages_in_total[1m]))");
assertThat(decodedBody).contains("start=1784107658");
assertThat(decodedBody).contains("end=1784108558");
assertThat(decodedBody).contains("step=30s");
}

@Test
void queryShouldPreserveHistogramOnlySeries() {
server.createContext("/api/v1/query_range", exchange -> respond(exchange, 200, """
{
"status": "success",
"data": {
"resultType": "matrix",
"result": [{
"metric": {"__name__": "rocketmq_rpc_latency"},
"histograms": [[1784107658, {
"count": "12",
"sum": "3.5",
"buckets": [[3, "-0.5", "0.5", "4"], [0, "0.5", "+Inf", "8"]]
}]]
}]
}
}
"""));

MetricDataVO.MetricSeriesVO series = source(Duration.ofSeconds(2)).query(query()).getSeries().get(0);

assertThat(series.getValues()).isEmpty();
assertThat(series.getHistograms()).hasSize(1);
assertThat(series.getHistograms().get(0).getTimestamp()).isEqualTo(1784107658D);
assertThat(series.getHistograms().get(0).getHistogram().path("count").asText()).isEqualTo("12");
assertThat(series.getHistograms().get(0).getHistogram().path("sum").asText()).isEqualTo("3.5");
assertThat(series.getHistograms().get(0).getHistogram().path("buckets")).hasSize(2);
}

@Test
void queryShouldPreserveFloatAndHistogramSamplesInSameSeries() {
server.createContext("/api/v1/query_range", exchange -> respond(exchange, 200, """
{
"status": "success",
"data": {
"resultType": "matrix",
"result": [{
"metric": {"__name__": "request_duration_seconds"},
"values": [[1784107658, "1.25"]],
"histograms": [[1784107688, {
"count": "2",
"sum": "1.5",
"buckets": [[3, "-0.5", "0.5", "2"]]
}]]
}]
}
}
"""));

MetricDataVO.MetricSeriesVO series = source(Duration.ofSeconds(2)).query(query()).getSeries().get(0);

assertThat(series.getValues()).hasSize(1);
assertThat(series.getValues().get(0).getValue()).isEqualTo("1.25");
assertThat(series.getHistograms()).hasSize(1);
assertThat(series.getHistograms().get(0).getHistogram().path("count").asText()).isEqualTo("2");
}

@Test
void queryShouldApplyBasicAuthentication() {
AtomicReference<String> authorization = new AtomicReference<>();
server.createContext("/api/v1/query_range", exchange -> {
authorization.set(exchange.getRequestHeaders().getFirst("Authorization"));
respond(exchange, 200, successResponse());
});
PrometheusProperties properties = properties(Duration.ofSeconds(2));
properties.setUsername("studio");
properties.setPassword("secret");

source(properties).query(query());

String credentials = Base64.getEncoder().encodeToString("studio:secret".getBytes(StandardCharsets.UTF_8));
assertThat(authorization.get()).isEqualTo("Basic " + credentials);
}

@Test
void queryShouldPreferBearerAuthentication() {
AtomicReference<String> authorization = new AtomicReference<>();
server.createContext("/api/v1/query_range", exchange -> {
authorization.set(exchange.getRequestHeaders().getFirst("Authorization"));
respond(exchange, 200, successResponse());
});
PrometheusProperties properties = properties(Duration.ofSeconds(2));
properties.setBearerToken("test-token");
properties.setUsername("ignored-user");
properties.setPassword("ignored-password");

source(properties).query(query());

assertThat(authorization.get()).isEqualTo("Bearer test-token");
}

@Test
void queryShouldExposePrometheusErrorDetails() {
server.createContext("/api/v1/query_range", exchange -> respond(exchange, 422, """
{"status":"error","errorType":"execution","error":"invalid expression"}
"""));

PrometheusMetricsSource source = source(Duration.ofSeconds(2));

assertThatThrownBy(() -> source.query(query()))
.isInstanceOf(PrometheusException.class)
.satisfies(exception -> {
PrometheusException prometheusException = (PrometheusException) exception;
assertThat(prometheusException.getStatusCode()).isEqualTo(422);
assertThat(prometheusException.getMessage())
.isEqualTo("Prometheus query failed (execution): invalid expression");
});
}

@Test
void queryShouldRejectMalformedPrometheusResponse() {
server.createContext("/api/v1/query_range", exchange -> respond(exchange, 200, """
{"status":"success","data":{"resultType":"matrix","result":{}}}
"""));

assertThatThrownBy(() -> source(Duration.ofSeconds(2)).query(query()))
.isInstanceOf(PrometheusException.class)
.satisfies(exception -> assertThat(((PrometheusException) exception).getStatusCode())
.isEqualTo(HttpStatus.BAD_GATEWAY.value()))
.hasMessage("Prometheus returned a malformed response");
}

@Test
void queryShouldRejectEndEarlierThanStart() {
MetricQueryDTO invalidQuery = MetricQueryDTO.builder()
.metric("up")
.start(2L)
.end(1L)
.step("30s")
.build();

assertThatThrownBy(() -> source(Duration.ofSeconds(2)).query(invalidQuery))
.isInstanceOf(PrometheusException.class)
.satisfies(exception -> assertThat(((PrometheusException) exception).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST.value()))
.hasMessage("Metric query end must not be earlier than start");
}

@Test
void queryShouldFailLoudWhenPrometheusIsNotConfigured() {
PrometheusProperties properties = new PrometheusProperties();
PrometheusMetricsSource source = new PrometheusMetricsSource(
RestClient.builder(), new ObjectMapper(), properties);

assertThatThrownBy(() -> source.query(query()))
.isInstanceOf(PrometheusException.class)
.satisfies(exception -> assertThat(((PrometheusException) exception).getStatusCode())
.isEqualTo(HttpStatus.SERVICE_UNAVAILABLE.value()))
.hasMessage("Prometheus base URL is not configured");
}

@Test
void queryShouldReportReadTimeout() {
server.createContext("/api/v1/query_range", exchange -> {
try {
Thread.sleep(300);
respond(exchange, 200, "{\"status\":\"success\",\"data\":{\"resultType\":\"matrix\",\"result\":[]}}");
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
} catch (IOException ignored) {
// The client closes the exchange after the expected timeout.
}
});

PrometheusMetricsSource source = source(Duration.ofMillis(50));

assertThatThrownBy(() -> source.query(query()))
.isInstanceOf(PrometheusException.class)
.satisfies(exception -> {
PrometheusException prometheusException = (PrometheusException) exception;
assertThat(prometheusException.getStatusCode())
.isEqualTo(HttpStatus.GATEWAY_TIMEOUT.value());
})
.hasMessage("Prometheus query timed out");
}

private PrometheusMetricsSource source(Duration readTimeout) {
return source(properties(readTimeout));
}

private PrometheusMetricsSource source(PrometheusProperties properties) {
return new PrometheusMetricsSource(RestClient.builder(), new ObjectMapper(), properties);
}

private PrometheusProperties properties(Duration readTimeout) {
PrometheusProperties properties = new PrometheusProperties();
properties.setBaseUrl(baseUrl);
properties.setConnectTimeout(Duration.ofSeconds(1));
properties.setReadTimeout(readTimeout);
return properties;
}

private MetricQueryDTO query() {
return MetricQueryDTO.builder()
.metric("sum(rate(rocketmq_messages_in_total[1m]))")
.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();
}

private String successResponse() {
return "{\"status\":\"success\",\"data\":{\"resultType\":\"matrix\",\"result\":[]}}";
}
}
6 changes: 3 additions & 3 deletions web/.env.production
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Backend not ready yet - use mock data
# Set to "false" when real API is available
VITE_USE_MOCK=true
# Production: use real API
# Set to "true" only if backend is unavailable
VITE_USE_MOCK=false
Loading
Loading