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 }}

Reply via email to