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 e52f1596 feat: harden data source management (#643)
e52f1596 is described below
commit e52f1596f08862405c9e61508e294bf484e72095
Author: aias00 <[email protected]>
AuthorDate: Tue Jul 28 07:30:56 2026 -0700
feat: harden data source management (#643)
* [Studio] Harden data source management
* fix: support authenticated data source tests
---
.../studio/settings/DataSourceTestDTO.java | 6 +
.../rocketmq/studio/settings/DataSourceVO.java | 4 +
.../studio/settings/SettingsController.java | 6 +-
.../rocketmq/studio/settings/SettingsService.java | 174 ++++++++++++++++-
.../studio/settings/SettingsControllerTest.java | 51 +++++
.../studio/settings/SettingsServiceTest.java | 155 ++++++++++++++-
web/src/api/settings.ts | 12 +-
.../settings/__tests__/DataSourceTab.test.tsx | 214 +++++++++++++++++++++
web/src/pages/settings/index.tsx | 88 +++++++--
9 files changed, 677 insertions(+), 33 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/settings/DataSourceTestDTO.java
b/server/src/main/java/org/apache/rocketmq/studio/settings/DataSourceTestDTO.java
index 0168b9c5..ecfba0ec 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/settings/DataSourceTestDTO.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/settings/DataSourceTestDTO.java
@@ -16,6 +16,7 @@
*/
package org.apache.rocketmq.studio.settings;
+import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@@ -26,7 +27,12 @@ import lombok.NoArgsConstructor;
@NoArgsConstructor
@AllArgsConstructor
public class DataSourceTestDTO {
+ @NotBlank(message = "url is required")
private String url;
+ @NotBlank(message = "type is required")
private String type;
private String auth;
+ private String username;
+ private String password;
+ private String bearerToken;
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/settings/DataSourceVO.java
b/server/src/main/java/org/apache/rocketmq/studio/settings/DataSourceVO.java
index c3110e77..8d6da80c 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/settings/DataSourceVO.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/settings/DataSourceVO.java
@@ -16,6 +16,7 @@
*/
package org.apache.rocketmq.studio.settings;
+import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@@ -27,8 +28,11 @@ import lombok.NoArgsConstructor;
@AllArgsConstructor
public class DataSourceVO {
private String key;
+ @NotBlank(message = "name is required")
private String name;
+ @NotBlank(message = "type is required")
private String type;
+ @NotBlank(message = "url is required")
private String url;
private String auth;
private String status;
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsController.java
b/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsController.java
index 10a09205..2b2ee43f 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsController.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsController.java
@@ -52,12 +52,12 @@ public class SettingsController {
}
@PostMapping("/datasources/create")
- public Result<DataSourceVO> createDataSource(@RequestBody DataSourceVO
dataSource) {
+ public Result<DataSourceVO> createDataSource(@Valid @RequestBody
DataSourceVO dataSource) {
return Result.ok(settingsService.createDataSource(dataSource));
}
@PostMapping("/datasources/update")
- public Result<DataSourceVO> updateDataSource(@RequestBody DataSourceVO
dataSource) {
+ public Result<DataSourceVO> updateDataSource(@Valid @RequestBody
DataSourceVO dataSource) {
return Result.ok(settingsService.updateDataSource(dataSource));
}
@@ -68,7 +68,7 @@ public class SettingsController {
}
@PostMapping("/datasources/test")
- public Result<DataSourceTestResultVO> testDataSource(@RequestBody
DataSourceTestDTO request) {
+ public Result<DataSourceTestResultVO> testDataSource(@Valid @RequestBody
DataSourceTestDTO request) {
return Result.ok(settingsService.testDataSource(request));
}
}
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 c1868fb3..d98a1cc5 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
@@ -16,20 +16,55 @@
*/
package org.apache.rocketmq.studio.settings;
-import lombok.RequiredArgsConstructor;
+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.MediaType;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
-
+import org.springframework.web.client.ResourceAccessException;
+import org.springframework.web.client.RestClient;
+import org.springframework.web.client.RestClientException;
+import org.springframework.web.client.RestClientResponseException;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.SocketTimeoutException;
+import java.time.Duration;
import java.util.List;
+import java.util.Set;
import java.util.UUID;
@Slf4j
@Service
-@RequiredArgsConstructor
public class SettingsService {
+ private static final Set<String> PROMETHEUS_COMPATIBLE_TYPES = Set.of(
+ "prometheus", "victoriametrics", "thanos", "mimir");
+ private static final String PROMETHEUS_TEST_QUERY = "up";
+ private static final String AUTH_NONE = "none";
+ private static final String AUTH_BASIC = "basic auth";
+ private static final String AUTH_BEARER = "bearer token";
+ private static final Duration DATA_SOURCE_TEST_CONNECT_TIMEOUT =
Duration.ofSeconds(3);
+ private static final Duration DATA_SOURCE_TEST_READ_TIMEOUT =
Duration.ofSeconds(5);
+
private final SettingsRepository settingsRepository;
+ private final RestClient restClient;
+ private final ObjectMapper objectMapper;
+
+ public SettingsService(SettingsRepository settingsRepository,
RestClient.Builder restClientBuilder,
+ ObjectMapper objectMapper) {
+ SimpleClientHttpRequestFactory requestFactory = new
SimpleClientHttpRequestFactory();
+ requestFactory.setConnectTimeout(DATA_SOURCE_TEST_CONNECT_TIMEOUT);
+ requestFactory.setReadTimeout(DATA_SOURCE_TEST_READ_TIMEOUT);
+ this.settingsRepository = settingsRepository;
+ this.restClient =
restClientBuilder.requestFactory(requestFactory).build();
+ this.objectMapper = objectMapper;
+ }
public GeneralSettingsVO getGeneralSettings() {
@@ -77,11 +112,136 @@ public class SettingsService {
public DataSourceTestResultVO testDataSource(DataSourceTestDTO request) {
- log.info("Testing data source connection: url={}, type={}",
request.getUrl(), request.getType());
- // Stub: always return success for now
+ log.info("Testing data source connection: type={}", request == null ?
null : request.getType());
+ if (request == null) {
+ return failed("Data source test request is required");
+ }
+ if (!isPrometheusCompatible(request.getType())) {
+ return failed("Unsupported data source type: " +
request.getType());
+ }
+
+ try {
+ JsonNode response = restClient.get()
+ .uri(prometheusQueryUri(request.getUrl()))
+ .accept(MediaType.APPLICATION_JSON)
+ .headers(headers -> applyAuthentication(headers, request))
+ .retrieve()
+ .body(JsonNode.class);
+ return prometheusSuccess(response);
+ } catch (IllegalArgumentException | URISyntaxException exception) {
+ return failed(exception.getMessage());
+ } catch (RestClientResponseException exception) {
+ return failed(prometheusErrorMessage(exception));
+ } catch (ResourceAccessException exception) {
+ if (hasCause(exception, SocketTimeoutException.class)) {
+ return failed("Prometheus connection timed out");
+ }
+ return failed("Failed to connect to Prometheus");
+ } catch (RestClientException exception) {
+ if (hasCause(exception, SocketTimeoutException.class)) {
+ return failed("Prometheus connection timed out");
+ }
+ return failed("Prometheus connection failed");
+ }
+ }
+
+ private boolean isPrometheusCompatible(String type) {
+ return StringUtils.hasText(type)
+ &&
PROMETHEUS_COMPATIBLE_TYPES.contains(type.replaceAll("\\s+", "").toLowerCase());
+ }
+
+ private void applyAuthentication(HttpHeaders headers, DataSourceTestDTO
request) {
+ String auth = normalizeAuth(request.getAuth());
+ if (AUTH_NONE.equals(auth)) {
+ return;
+ }
+ if (AUTH_BASIC.equals(auth)) {
+ if (!StringUtils.hasText(request.getUsername()) ||
!StringUtils.hasText(request.getPassword())) {
+ throw new IllegalArgumentException("Basic authentication
requires username and password");
+ }
+ headers.setBasicAuth(request.getUsername().trim(),
request.getPassword());
+ return;
+ }
+ if (AUTH_BEARER.equals(auth)) {
+ if (!StringUtils.hasText(request.getBearerToken())) {
+ throw new IllegalArgumentException("Bearer authentication
requires token");
+ }
+ headers.setBearerAuth(request.getBearerToken().trim());
+ return;
+ }
+ throw new IllegalArgumentException("Unsupported data source
authentication: " + request.getAuth());
+ }
+
+ private String normalizeAuth(String auth) {
+ if (!StringUtils.hasText(auth)) {
+ return AUTH_NONE;
+ }
+ return auth.trim().replaceAll("\\s+", " ").toLowerCase();
+ }
+
+ private URI prometheusQueryUri(String baseUrl) throws URISyntaxException {
+ if (!StringUtils.hasText(baseUrl)) {
+ throw new IllegalArgumentException("Data source URL is required");
+ }
+ String normalized = baseUrl.strip();
+ while (normalized.endsWith("/")) {
+ normalized = normalized.substring(0, normalized.length() - 1);
+ }
+
+ URI baseUri = new URI(normalized);
+ if (!"http".equalsIgnoreCase(baseUri.getScheme()) &&
!"https".equalsIgnoreCase(baseUri.getScheme())) {
+ throw new IllegalArgumentException("Data source URL must start
with http:// or https://");
+ }
+ return UriComponentsBuilder.fromUriString(normalized + "/api/v1/query")
+ .queryParam("query", PROMETHEUS_TEST_QUERY)
+ .build()
+ .toUri();
+ }
+
+ private DataSourceTestResultVO prometheusSuccess(JsonNode response) {
+ if (response != null &&
"success".equals(response.path("status").asText())) {
+ return DataSourceTestResultVO.builder()
+ .success(true)
+ .message("Connection successful")
+ .build();
+ }
+ return failed(prometheusBodyError(response));
+ }
+
+ private String prometheusErrorMessage(RestClientResponseException
exception) {
+ try {
+ return
prometheusBodyError(objectMapper.readTree(exception.getResponseBodyAsString()));
+ } catch (IOException ignored) {
+ return "Prometheus query failed";
+ }
+ }
+
+ private String prometheusBodyError(JsonNode response) {
+ String errorType = response == null ? "" :
response.path("errorType").asText();
+ String error = response == null ? "" : response.path("error").asText();
+ if (StringUtils.hasText(error)) {
+ return StringUtils.hasText(errorType)
+ ? "Prometheus query failed (" + errorType + "): " + error
+ : "Prometheus query failed: " + error;
+ }
+ return "Prometheus query failed";
+ }
+
+ private DataSourceTestResultVO failed(String message) {
return DataSourceTestResultVO.builder()
- .success(true)
- .message("Connection successful")
+ .success(false)
+ .message(message)
.build();
}
+
+ 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/test/java/org/apache/rocketmq/studio/settings/SettingsControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsControllerTest.java
index ea2dee06..5ab867d0 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsControllerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsControllerTest.java
@@ -203,6 +203,23 @@ class SettingsControllerTest {
.andExpect(jsonPath("$.data.status", is("connected")));
}
+ @Test
+ void createDataSourceShouldRejectMissingUrl() throws Exception {
+ mockMvc.perform(post("/api/settings/datasources/create")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {
+ "name": "New DS",
+ "type": "prometheus"
+ }
+ """))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code", is(400)))
+ .andExpect(jsonPath("$.message", is("url is required")));
+
+ verifyNoInteractions(settingsService);
+ }
+
@Test
void updateDataSourceShouldReturnUpdatedSource() throws Exception {
DataSourceVO input = DataSourceVO.builder().key("ds-1").name("Updated
DS").type("rocketmq")
@@ -218,6 +235,24 @@ class SettingsControllerTest {
.andExpect(jsonPath("$.data.name", is("Updated DS")));
}
+ @Test
+ void updateDataSourceShouldRejectMissingName() throws Exception {
+ mockMvc.perform(post("/api/settings/datasources/update")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {
+ "key": "ds-1",
+ "type": "rocketmq",
+ "url": "updated:9876"
+ }
+ """))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code", is(400)))
+ .andExpect(jsonPath("$.message", is("name is required")));
+
+ verifyNoInteractions(settingsService);
+ }
+
@Test
void deleteDataSourceShouldReturnSuccess() throws Exception {
doNothing().when(settingsService).deleteDataSource("ds-1");
@@ -250,4 +285,20 @@ class SettingsControllerTest {
.andExpect(jsonPath("$.data.success", is(true)))
.andExpect(jsonPath("$.data.message", is("Connection
successful")));
}
+
+ @Test
+ void testDataSourceShouldRejectMissingType() throws Exception {
+ mockMvc.perform(post("/api/settings/datasources/test")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {
+ "url": "localhost:9876"
+ }
+ """))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code", is(400)))
+ .andExpect(jsonPath("$.message", is("type is required")));
+
+ verifyNoInteractions(settingsService);
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsServiceTest.java
index c02eb378..ba5f4d45 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsServiceTest.java
@@ -16,15 +16,25 @@
*/
package org.apache.rocketmq.studio.settings;
+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.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.web.client.RestClient;
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
import java.util.Arrays;
+import java.util.Base64;
import java.util.Collections;
import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
@@ -38,9 +48,24 @@ class SettingsServiceTest {
@Mock
private SettingsRepository settingsRepository;
- @InjectMocks
private SettingsService settingsService;
+ private HttpServer prometheusServer;
+ private String prometheusBaseUrl;
+
+ @BeforeEach
+ void setUp() throws IOException {
+ settingsService = new SettingsService(settingsRepository,
RestClient.builder(), new ObjectMapper());
+ prometheusServer = HttpServer.create(new
InetSocketAddress("127.0.0.1", 0), 0);
+ prometheusBaseUrl = "http://127.0.0.1:" +
prometheusServer.getAddress().getPort();
+ prometheusServer.start();
+ }
+
+ @AfterEach
+ void tearDown() {
+ prometheusServer.stop(0);
+ }
+
@Test
void getGeneralSettingsShouldReturnCurrentSettings() {
GeneralSettingsVO settings = GeneralSettingsVO.builder()
@@ -207,29 +232,139 @@ class SettingsServiceTest {
}
@Test
- void testConnectionShouldReturnSuccess() {
+ void testConnectionShouldQueryPrometheusEndpoint() {
+ AtomicReference<String> requestPath = new AtomicReference<>();
+ AtomicReference<String> requestQuery = new AtomicReference<>();
+ prometheusServer.createContext("/api/v1/query", exchange -> {
+ requestPath.set(exchange.getRequestURI().getPath());
+ requestQuery.set(exchange.getRequestURI().getRawQuery());
+ respond(exchange, 200,
"{\"status\":\"success\",\"data\":{\"resultType\":\"vector\",\"result\":[]}}");
+ });
DataSourceTestDTO request = DataSourceTestDTO.builder()
- .url("localhost:9876")
- .type("rocketmq")
+ .url(prometheusBaseUrl)
+ .type("Prometheus")
.build();
DataSourceTestResultVO result =
settingsService.testDataSource(request);
assertThat(result.isSuccess()).isTrue();
assertThat(result.getMessage()).isEqualTo("Connection successful");
+ assertThat(requestPath.get()).isEqualTo("/api/v1/query");
+ assertThat(requestQuery.get()).isEqualTo("query=up");
}
@Test
- void testConnectionShouldReturnSuccessForAnyInput() {
+ void testConnectionShouldApplyBasicAuthentication() {
+ AtomicReference<String> authorization = new AtomicReference<>();
+ prometheusServer.createContext("/api/v1/query", exchange -> {
+
authorization.set(exchange.getRequestHeaders().getFirst("Authorization"));
+ respond(exchange, 200,
"{\"status\":\"success\",\"data\":{\"resultType\":\"vector\",\"result\":[]}}");
+ });
DataSourceTestDTO request = DataSourceTestDTO.builder()
- .url("invalid-host:9999")
- .type("unknown")
- .auth("bad-auth")
+ .url(prometheusBaseUrl)
+ .type("Prometheus")
+ .auth("Basic Auth")
+ .username("prom")
+ .password("secret")
.build();
DataSourceTestResultVO result =
settingsService.testDataSource(request);
assertThat(result.isSuccess()).isTrue();
- assertThat(result.getMessage()).isEqualTo("Connection successful");
+ assertThat(authorization.get()).isEqualTo("Basic "
+ +
Base64.getEncoder().encodeToString("prom:secret".getBytes(StandardCharsets.UTF_8)));
+ }
+
+ @Test
+ void testConnectionShouldApplyBearerAuthentication() {
+ AtomicReference<String> authorization = new AtomicReference<>();
+ prometheusServer.createContext("/api/v1/query", exchange -> {
+
authorization.set(exchange.getRequestHeaders().getFirst("Authorization"));
+ respond(exchange, 200,
"{\"status\":\"success\",\"data\":{\"resultType\":\"vector\",\"result\":[]}}");
+ });
+ DataSourceTestDTO request = DataSourceTestDTO.builder()
+ .url(prometheusBaseUrl)
+ .type("Prometheus")
+ .auth("Bearer Token")
+ .bearerToken("token-1")
+ .build();
+
+ DataSourceTestResultVO result =
settingsService.testDataSource(request);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(authorization.get()).isEqualTo("Bearer token-1");
+ }
+
+ @Test
+ void testConnectionShouldRejectIncompleteBasicAuthentication() {
+ DataSourceTestResultVO result =
settingsService.testDataSource(DataSourceTestDTO.builder()
+ .url(prometheusBaseUrl)
+ .type("Prometheus")
+ .auth("Basic Auth")
+ .username("prom")
+ .build());
+
+ assertThat(result.isSuccess()).isFalse();
+ assertThat(result.getMessage()).isEqualTo(
+ "Basic authentication requires username and password");
+ }
+
+ @Test
+ void testConnectionShouldRejectMissingBearerToken() {
+ DataSourceTestResultVO result =
settingsService.testDataSource(DataSourceTestDTO.builder()
+ .url(prometheusBaseUrl)
+ .type("Prometheus")
+ .auth("Bearer Token")
+ .build());
+
+ assertThat(result.isSuccess()).isFalse();
+ assertThat(result.getMessage()).isEqualTo("Bearer authentication
requires token");
+ }
+
+ @Test
+ void testConnectionShouldReturnPrometheusErrorDetails() {
+ prometheusServer.createContext("/api/v1/query", exchange ->
respond(exchange, 422,
+
"{\"status\":\"error\",\"errorType\":\"bad_data\",\"error\":\"invalid
query\"}"));
+ DataSourceTestDTO request = DataSourceTestDTO.builder()
+ .url(prometheusBaseUrl)
+ .type("VictoriaMetrics")
+ .build();
+
+ DataSourceTestResultVO result =
settingsService.testDataSource(request);
+
+ assertThat(result.isSuccess()).isFalse();
+ assertThat(result.getMessage()).isEqualTo(
+ "Prometheus query failed (bad_data): invalid query");
+ }
+
+ @Test
+ void testConnectionShouldRejectInvalidUrl() {
+ DataSourceTestResultVO result =
settingsService.testDataSource(DataSourceTestDTO.builder()
+ .url("ftp://example.com")
+ .type("Prometheus")
+ .build());
+
+ assertThat(result.isSuccess()).isFalse();
+ assertThat(result.getMessage()).isEqualTo(
+ "Data source URL must start with http:// or https://");
+ }
+
+ @Test
+ void testConnectionShouldRejectUnsupportedType() {
+ DataSourceTestResultVO result =
settingsService.testDataSource(DataSourceTestDTO.builder()
+ .url(prometheusBaseUrl)
+ .type("rocketmq")
+ .build());
+
+ assertThat(result.isSuccess()).isFalse();
+ assertThat(result.getMessage()).isEqualTo("Unsupported data source
type: rocketmq");
+ }
+
+ 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/settings.ts b/web/src/api/settings.ts
index 19829f70..5de9b4b5 100644
--- a/web/src/api/settings.ts
+++ b/web/src/api/settings.ts
@@ -42,6 +42,9 @@ export interface DataSource {
type: string;
url: string;
auth: string;
+ username?: string;
+ password?: string;
+ bearerToken?: string;
status: string;
}
@@ -78,7 +81,14 @@ export async function deleteDataSource(key: string) {
await client.post('/settings/datasources/delete', undefined, { params: { key
} });
}
-export async function testDataSource(data: { type: string; url: string; auth?:
string }) {
+export async function testDataSource(data: {
+ type: string;
+ url: string;
+ auth?: string;
+ username?: string;
+ password?: string;
+ bearerToken?: string;
+}) {
const res = await client.post<{ data: { success: boolean; message: string }
}>(
'/settings/datasources/test',
data,
diff --git a/web/src/pages/settings/__tests__/DataSourceTab.test.tsx
b/web/src/pages/settings/__tests__/DataSourceTab.test.tsx
new file mode 100644
index 00000000..2f177e02
--- /dev/null
+++ b/web/src/pages/settings/__tests__/DataSourceTab.test.tsx
@@ -0,0 +1,214 @@
+/*
+ * 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.
+ */
+
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { App } from 'antd';
+import type { DataSource } from '../../../api/settings';
+import { createDataSource, listDataSources, testDataSource } from
'../../../api/settings';
+import { DataSourceTab } from '../index';
+
+vi.mock('../../../api/settings', () => ({
+ createDataSource: vi.fn(),
+ deleteDataSource: vi.fn(),
+ getGeneralSettings: vi.fn(),
+ listDataSources: vi.fn(),
+ saveGeneralSettings: vi.fn(),
+ testDataSource: vi.fn(),
+ updateDataSource: vi.fn(),
+}));
+
+const sources: DataSource[] = [
+ {
+ key: 'prom-prod',
+ name: 'Prometheus prod',
+ type: 'Prometheus',
+ url: 'http://prometheus:9090',
+ auth: 'None',
+ status: 'healthy',
+ },
+ {
+ key: 'thanos-dr',
+ name: 'Thanos DR',
+ type: 'Thanos',
+ url: 'http://thanos:10902',
+ auth: 'Bearer Token',
+ status: 'healthy',
+ },
+];
+
+beforeAll(() => {
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+});
+
+describe('DataSourceTab', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(listDataSources).mockResolvedValue(sources);
+ });
+
+ it('shows connection test loading only on the clicked row', async () => {
+ let resolveTest: (value: { success: boolean; message: string }) => void =
() => undefined;
+ vi.mocked(testDataSource).mockReturnValue(
+ new Promise((resolve) => {
+ resolveTest = resolve;
+ }),
+ );
+
+ const user = userEvent.setup();
+ render(
+ <App>
+ <DataSourceTab />
+ </App>,
+ );
+
+ await screen.findByText('Prometheus prod');
+ const buttons = screen.getAllByRole('button', { name: /测试连接/ });
+ await user.click(buttons[0]);
+
+ await waitFor(() => {
+ expect(buttons[0]).toHaveClass('ant-btn-loading');
+ expect(buttons[1]).not.toHaveClass('ant-btn-loading');
+ });
+
+ resolveTest({ success: true, message: 'ok' });
+ });
+
+ it('submits basic auth credentials when testing from the modal', async () =>
{
+ vi.mocked(testDataSource).mockResolvedValue({ success: true, message: 'ok'
});
+
+ const user = userEvent.setup();
+ render(
+ <App>
+ <DataSourceTab />
+ </App>,
+ );
+
+ await screen.findByText('Prometheus prod');
+ await user.click(screen.getByRole('button', { name: /添加数据源/ }));
+ await selectAntdOption(user, '类型', 'Prometheus');
+ await user.type(screen.getByLabelText('URL'), 'http://prometheus:9090');
+ await selectAntdOption(user, '认证方式', 'Basic Auth');
+ await user.type(screen.getByLabelText('用户名'), 'prom');
+ await user.type(screen.getByLabelText('密码'), 'secret');
+
+ const testButtons = screen.getAllByRole('button', { name: /测试连接/ });
+ await user.click(testButtons[testButtons.length - 1]);
+
+ await waitFor(() => {
+ expect(testDataSource).toHaveBeenCalledWith({
+ type: 'Prometheus',
+ url: 'http://prometheus:9090',
+ auth: 'Basic Auth',
+ username: 'prom',
+ password: 'secret',
+ });
+ });
+ });
+
+ it('submits bearer token when testing from the modal', async () => {
+ vi.mocked(testDataSource).mockResolvedValue({ success: true, message: 'ok'
});
+
+ const user = userEvent.setup();
+ render(
+ <App>
+ <DataSourceTab />
+ </App>,
+ );
+
+ await screen.findByText('Prometheus prod');
+ await user.click(screen.getByRole('button', { name: /添加数据源/ }));
+ await selectAntdOption(user, '类型', 'Thanos');
+ await user.type(screen.getByLabelText('URL'), 'http://thanos:10902');
+ await selectAntdOption(user, '认证方式', 'Bearer Token');
+ await user.type(screen.getByLabelText('Bearer Token'), 'token-1');
+
+ const testButtons = screen.getAllByRole('button', { name: /测试连接/ });
+ await user.click(testButtons[testButtons.length - 1]);
+
+ await waitFor(() => {
+ expect(testDataSource).toHaveBeenCalledWith({
+ type: 'Thanos',
+ url: 'http://thanos:10902',
+ auth: 'Bearer Token',
+ bearerToken: 'token-1',
+ });
+ });
+ });
+
+ it('does not persist modal-only credentials when creating a data source',
async () => {
+ vi.mocked(createDataSource).mockResolvedValue({
+ key: 'prom-secure',
+ name: 'Prometheus secure',
+ type: 'Prometheus',
+ url: 'http://prometheus:9090',
+ auth: 'Basic Auth',
+ status: 'healthy',
+ });
+
+ const user = userEvent.setup();
+ render(
+ <App>
+ <DataSourceTab />
+ </App>,
+ );
+
+ await screen.findByText('Prometheus prod');
+ await user.click(screen.getByRole('button', { name: /添加数据源/ }));
+ await user.type(screen.getByLabelText('名称'), 'Prometheus secure');
+ await selectAntdOption(user, '类型', 'Prometheus');
+ await user.type(screen.getByLabelText('URL'), 'http://prometheus:9090');
+ await selectAntdOption(user, '认证方式', 'Basic Auth');
+ await user.type(screen.getByLabelText('用户名'), 'prom');
+ await user.type(screen.getByLabelText('密码'), 'secret');
+
+ await user.click(screen.getByRole('button', { name: 'OK' }));
+
+ await waitFor(() => {
+ expect(createDataSource).toHaveBeenCalledWith({
+ name: 'Prometheus secure',
+ type: 'Prometheus',
+ url: 'http://prometheus:9090',
+ auth: 'Basic Auth',
+ });
+ });
+ });
+});
+
+async function selectAntdOption(user: ReturnType<typeof userEvent.setup>,
label: string, option: string) {
+ await user.click(screen.getByLabelText(label));
+ const popupId = label === '类型' ? 'type_list' : 'auth_list';
+ const popup = await waitFor(() => {
+ const element = document.getElementById(popupId);
+ if (!element) throw new Error(`Missing popup ${popupId}`);
+ return element;
+ });
+ await user.click(within(popup).getByRole('option', { name: option }));
+}
diff --git a/web/src/pages/settings/index.tsx b/web/src/pages/settings/index.tsx
index 9344eea0..47080e4d 100644
--- a/web/src/pages/settings/index.tsx
+++ b/web/src/pages/settings/index.tsx
@@ -70,6 +70,25 @@ const typeTagColor: Record<string, string> = {
Thanos: 'purple',
};
+type DataSourceFormValues = Partial<DataSource>;
+
+const secretFieldNames = ['username', 'password', 'bearerToken'] as const;
+const authNeedsSecret = (auth?: string) => auth === 'Basic Auth' || auth ===
'Bearer Token';
+
+const testFieldNames = (auth?: string) => {
+ if (auth === 'Basic Auth') return ['type', 'url', 'auth', 'username',
'password'];
+ if (auth === 'Bearer Token') return ['type', 'url', 'auth', 'bearerToken'];
+ return ['type', 'url', 'auth'];
+};
+
+const withoutSecrets = (values: DataSourceFormValues): Partial<DataSource> => {
+ const sanitized = { ...values };
+ secretFieldNames.forEach((field) => {
+ delete sanitized[field];
+ });
+ return sanitized;
+};
+
// ─── General Settings Tab ───────────────────────────────────────────────────
const GeneralSettingsTab = () => {
@@ -237,13 +256,14 @@ const GeneralSettingsTab = () => {
// ─── Data Source Tab ────────────────────────────────────────────────────────
-const DataSourceTab = () => {
+export const DataSourceTab = () => {
const [dataSources, setDataSources] = useState<DataSource[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editingDataSource, setEditingDataSource] = useState<DataSource |
null>(null);
const [dsForm] = Form.useForm();
- const [testing, setTesting] = useState(false);
+ const authValue = Form.useWatch('auth', dsForm);
+ const [testingKey, setTestingKey] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
@@ -264,8 +284,15 @@ const DataSourceTab = () => {
};
}, []);
- const handleTestConnection = async (data: Pick<DataSource, 'type' | 'url' |
'auth'>) => {
- setTesting(true);
+ const handleTestConnection = async (
+ data: Pick<DataSource, 'type' | 'url' | 'auth'> & Partial<DataSource>,
+ key: string,
+ ) => {
+ if (key !== 'modal' && authNeedsSecret(data.auth)) {
+ message.warning('认证数据源请编辑后输入凭据再测试连接');
+ return;
+ }
+ setTestingKey(key);
try {
const result = await testDataSource(data);
if (result.success) message.success(result.message);
@@ -273,7 +300,7 @@ const DataSourceTab = () => {
} catch {
message.error('连接测试失败,请稍后重试');
} finally {
- setTesting(false);
+ setTestingKey(null);
}
};
@@ -293,10 +320,11 @@ const DataSourceTab = () => {
const handleSubmit = async () => {
try {
const values = await dsForm.validateFields();
+ const dataSourceValues = withoutSecrets(values);
setSubmitting(true);
const saved = editingDataSource
- ? await updateDataSource({ ...editingDataSource, ...values })
- : await createDataSource(values);
+ ? await updateDataSource({ ...editingDataSource, ...dataSourceValues })
+ : await createDataSource(dataSourceValues);
setDataSources((previous) =>
editingDataSource
? previous.map((dataSource) => (dataSource.key === saved.key ? saved
: dataSource))
@@ -347,8 +375,10 @@ const DataSourceTab = () => {
type="link"
size="small"
icon={<ApiOutlined />}
- loading={testing}
- onClick={() => void handleTestConnection(record)}
+ loading={testingKey === record.key}
+ disabled={authNeedsSecret(record.auth)}
+ title={authNeedsSecret(record.auth) ? '认证数据源请编辑后输入凭据再测试连接' :
undefined}
+ onClick={() => void handleTestConnection(record, record.key)}
>
测试连接
</Button>
@@ -419,6 +449,7 @@ const DataSourceTab = () => {
>
<Select
placeholder="请选择"
+ virtual={false}
options={[
{ value: 'Prometheus', label: 'Prometheus' },
{ value: 'VictoriaMetrics', label: 'VictoriaMetrics' },
@@ -437,6 +468,10 @@ const DataSourceTab = () => {
<Form.Item label="认证方式" name="auth" initialValue="None">
<Select
+ virtual={false}
+ onChange={() => {
+ dsForm.setFieldsValue({ username: undefined, password:
undefined, bearerToken: undefined });
+ }}
options={[
{ value: 'None', label: 'None' },
{ value: 'Basic Auth', label: 'Basic Auth' },
@@ -445,13 +480,42 @@ const DataSourceTab = () => {
/>
</Form.Item>
+ {authValue === 'Basic Auth' && (
+ <>
+ <Form.Item
+ label="用户名"
+ name="username"
+ rules={[{ required: true, message: '请输入用户名' }]}
+ >
+ <Input autoComplete="username" placeholder="prometheus" />
+ </Form.Item>
+ <Form.Item
+ label="密码"
+ name="password"
+ rules={[{ required: true, message: '请输入密码' }]}
+ >
+ <Input.Password autoComplete="current-password"
placeholder="请输入密码" />
+ </Form.Item>
+ </>
+ )}
+
+ {authValue === 'Bearer Token' && (
+ <Form.Item
+ label="Bearer Token"
+ name="bearerToken"
+ rules={[{ required: true, message: '请输入 Bearer Token' }]}
+ >
+ <Input.Password autoComplete="off" placeholder="请输入 Token" />
+ </Form.Item>
+ )}
+
<Button
icon={<ApiOutlined />}
- loading={testing}
+ loading={testingKey === 'modal'}
onClick={() => {
void dsForm
- .validateFields(['type', 'url', 'auth'])
- .then((values) => handleTestConnection(values))
+ .validateFields(testFieldNames(authValue))
+ .then((values) => handleTestConnection(values, 'modal'))
.catch(() => undefined);
}}
style={{ marginTop: 8 }}