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 990ba092 feat: proxy config hot-reload with registered-address guard 
(#1038)
990ba092 is described below

commit 990ba092d54fe29ffd6b8fd8bd1cf5ec4ac249fe
Author: zhaohai <[email protected]>
AuthorDate: Tue Aug 11 14:39:11 2026 +0800

    feat: proxy config hot-reload with registered-address guard (#1038)
---
 .../studio/cluster/proxy/ProxyAddressService.java  | 51 ++++++++++++++++++
 .../studio/cluster/proxy/ProxyController.java      | 27 ++++++++++
 .../cluster/proxy/ProxyAddressServiceTest.java     |  8 +++
 .../studio/cluster/proxy/ProxyControllerTest.java  | 62 +++++++++++++++++++++-
 web/src/api/proxy.ts                               | 15 ++++++
 web/src/i18n/translations.ts                       |  7 +++
 web/src/pages/studio/Proxy.tsx                     | 59 +++++++++++++++++++-
 web/src/pages/studio/__tests__/Proxy.test.tsx      | 27 ++++++++--
 8 files changed, 248 insertions(+), 8 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressService.java
index 3b6271d1..08ccab66 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressService.java
@@ -19,7 +19,15 @@ package org.apache.rocketmq.studio.cluster.proxy;
 
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatusCode;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
 import org.springframework.stereotype.Service;
+import org.springframework.web.client.HttpStatusCodeException;
+import org.springframework.web.client.ResourceAccessException;
+import org.springframework.web.client.RestTemplate;
+
+import java.time.Duration;
 
 import java.util.ArrayList;
 import java.util.LinkedHashSet;
@@ -37,8 +45,18 @@ public class ProxyAddressService {
     private static final int MIN_PORT = 1;
     private static final int MAX_PORT = 65535;
 
+    private static final String RELOAD_PATH = "/admin/reloadConfig";
+
     private final Set<String> proxyAddrs = new 
LinkedHashSet<>(List.of("127.0.0.1:8081"));
     private String currentProxyAddr = "127.0.0.1:8081";
+    private final RestTemplate restTemplate;
+
+    public ProxyAddressService() {
+        SimpleClientHttpRequestFactory factory = new 
SimpleClientHttpRequestFactory();
+        factory.setConnectTimeout(Duration.ofSeconds(3));
+        factory.setReadTimeout(Duration.ofSeconds(3));
+        this.restTemplate = new RestTemplate(factory);
+    }
 
     public synchronized ProxyHomeVO getHomePage() {
         return ProxyHomeVO.builder()
@@ -67,6 +85,39 @@ public class ProxyAddressService {
         log.info("Removed Proxy address {}", normalized);
     }
 
+    /**
+     * Trigger a configuration hot-reload for the proxy at the given address.
+     * POSTs to {@code http://<addr>/admin/reloadConfig}. Throws {@link 
BusinessException}
+     * on transport or protocol failure so the caller receives a structured 
error response.
+     */
+    public void reloadConfig(String addr) {
+        String normalized = normalizeProxyAddr(addr, "addr");
+        synchronized (this) {
+            if (!proxyAddrs.contains(normalized)) {
+                throw new BusinessException(400, "addr is not a registered 
proxy address");
+            }
+        }
+        String url = "http://"; + normalized + RELOAD_PATH;
+        try {
+            ResponseEntity<String> response = restTemplate.postForEntity(url, 
null, String.class);
+            HttpStatusCode status = response.getStatusCode();
+            if (!status.is2xxSuccessful()) {
+                throw new BusinessException(502, "Proxy returned " + status);
+            }
+            log.info("Proxy {} accepted config reload", normalized);
+        } catch (HttpStatusCodeException ex) {
+            throw new BusinessException(502, "Proxy returned " + 
ex.getStatusCode());
+        } catch (ResourceAccessException ex) {
+            log.warn("Unable to reach proxy {} for config reload: {}", 
normalized, ex.getMessage());
+            throw new BusinessException(502, "Unable to reach proxy: " + 
ex.getMessage());
+        } catch (BusinessException ex) {
+            throw ex;
+        } catch (Exception ex) {
+            log.warn("Proxy config reload via {} failed: {}", url, 
ex.getMessage());
+            throw new BusinessException(500, "Config reload failed: " + 
ex.getMessage());
+        }
+    }
+
     private String normalizeProxyAddr(String proxyAddr, String fieldName) {
         if (proxyAddr == null || proxyAddr.trim().isEmpty()) {
             throw new BusinessException(400, fieldName + " is required");
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyController.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyController.java
index 4c4e9646..dec10d8b 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyController.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyController.java
@@ -21,17 +21,38 @@ import org.apache.rocketmq.studio.common.domain.Result;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
+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;
+import java.util.Map;
+
 @RestController
 @RequestMapping("/api/proxies")
 @RequiredArgsConstructor
 public class ProxyController {
 
     private final ClusterService clusterService;
+    private final ProxyAddressService proxyAddressService;
+
+    @GetMapping
+    public Result<List<ProxyVO>> listProxies(@RequestParam(required = false) 
String clusterId) {
+        requireClusterId(clusterId);
+        List<ProxyVO> proxies = 
proxyAddressService.getHomePage().getProxyAddrList().stream()
+                .map(addr -> ProxyVO.builder().addr(addr).build())
+                .toList();
+        return Result.ok(proxies);
+    }
+
+    @PostMapping("/config/reload")
+    public Result<Map<String, Boolean>> reloadProxyConfig(@Valid @RequestBody 
RestartProxyDTO command) {
+        proxyAddressService.reloadConfig(command.getAddr());
+        return Result.ok(Map.of("success", true));
+    }
 
     @PostMapping("/restart")
     public Result<Void> restartProxy(@Valid @RequestBody RestartProxyDTO 
command) {
@@ -41,4 +62,10 @@ public class ProxyController {
         }
         return Result.ok();
     }
+
+    private void requireClusterId(String clusterId) {
+        if (clusterId == null || clusterId.isBlank()) {
+            throw new BusinessException(400, "clusterId is required");
+        }
+    }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressServiceTest.java
index 82648842..94a14d11 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressServiceTest.java
@@ -125,4 +125,12 @@ class ProxyAddressServiceTest {
                 .hasMessage("Proxy address not found: 10.0.0.1:8081")
                 .satisfies(ex -> assertThat(((BusinessException) 
ex).getCode()).isEqualTo(404));
     }
+
+    @Test
+    void reloadConfigShouldRejectUnregisteredAddressTest() {
+        assertThatThrownBy(() -> 
proxyAddressService.reloadConfig("10.0.0.1:8081"))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("addr is not a registered proxy address")
+                .satisfies(ex -> assertThat(((BusinessException) 
ex).getCode()).isEqualTo(400));
+    }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyControllerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyControllerTest.java
index c99efe9b..ee3d9404 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyControllerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyControllerTest.java
@@ -26,10 +26,13 @@ import org.springframework.boot.test.mock.mockito.MockBean;
 import org.springframework.http.MediaType;
 import org.springframework.test.web.servlet.MockMvc;
 
+import java.util.List;
+
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
+import static 
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
 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;
@@ -99,4 +102,61 @@ class ProxyControllerTest {
 
         verifyNoInteractions(clusterService);
     }
-}
+
+    @Test
+    void listProxiesShouldReturnProxiesForCluster() throws Exception {
+        when(proxyAddressService.getHomePage())
+                .thenReturn(ProxyHomeVO.builder()
+                        .proxyAddrList(List.of("127.0.0.1:8081"))
+                        .currentProxyAddr("127.0.0.1:8081")
+                        .build());
+
+        mockMvc.perform(get("/api/proxies").param("clusterId", "cluster-1"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data[0].addr").value("127.0.0.1:8081"));
+
+        verify(proxyAddressService).getHomePage();
+    }
+
+    @Test
+    void listProxiesShouldRejectMissingClusterId() throws Exception {
+        mockMvc.perform(get("/api/proxies"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("clusterId is 
required"));
+    }
+
+    @Test
+    void reloadProxyConfigShouldReturnSuccess() throws Exception {
+        RestartProxyDTO request = RestartProxyDTO.builder()
+                .clusterId("cluster-1")
+                .addr("127.0.0.1:8081")
+                .build();
+
+        mockMvc.perform(post("/api/proxies/config/reload")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(objectMapper.writeValueAsString(request)))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.success").value(true));
+
+        verify(proxyAddressService).reloadConfig("127.0.0.1:8081");
+    }
+
+    @Test
+    void reloadProxyConfigShouldRejectMissingAddr() throws Exception {
+        RestartProxyDTO request = RestartProxyDTO.builder()
+                .clusterId("cluster-1")
+                .build();
+
+        mockMvc.perform(post("/api/proxies/config/reload")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(objectMapper.writeValueAsString(request)))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("addr is required"));
+
+        verifyNoInteractions(proxyAddressService);
+    }
+}
\ No newline at end of file
diff --git a/web/src/api/proxy.ts b/web/src/api/proxy.ts
index 46cc06cf..aabab462 100644
--- a/web/src/api/proxy.ts
+++ b/web/src/api/proxy.ts
@@ -43,3 +43,18 @@ export async function queryProxyHomePage(): 
Promise<ProxyHomePageData> {
   const res = await client.get<{ data: ProxyHomePageData 
}>('/proxy/homePage.query');
   return res.data.data;
 }
+
+/**
+ * Trigger a configuration hot-reload for a proxy.
+ * Uses the same DTO as restartProxy ({ clusterId, addr }).
+ */
+export async function reloadProxyConfig(
+  clusterId: string,
+  addr: string,
+): Promise<{ success: boolean }> {
+  const res = await client.post<{ data: { success: boolean } 
}>('/proxies/config/reload', {
+    clusterId,
+    addr,
+  });
+  return res.data.data;
+}
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 6c4be765..c0b62711 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -1067,6 +1067,13 @@ const translations: Record<string, Record<Lang, string>> 
= {
   'proxy.healthy': { zh: '健康', en: 'Healthy' },
   'proxy.unhealthy': { zh: '不健康', en: 'Unhealthy' },
   'proxy.warning': { zh: '警告', en: 'Warning' },
+  'proxy.clusterId': { zh: '集群 ID', en: 'Cluster ID' },
+  'proxy.clusterIdPlaceholder': { zh: '例:DefaultCluster', en: 'e.g. 
DefaultCluster' },
+  'proxy.reloadConfig': { zh: '重载配置', en: 'Reload Config' },
+  'proxy.reloadSuccess': { zh: '配置重载成功', en: 'Config reload succeeded' },
+  'proxy.reloadFailed': { zh: '配置重载失败', en: 'Config reload failed' },
+  'proxy.statusOffline': { zh: '离线', en: 'Offline' },
+  'proxy.statusError': { zh: '错误', en: 'Error' },
 
   // ─── Broker Cluster ───
   'brokerCluster.title': { zh: 'Broker 集群', en: 'Broker Cluster' },
diff --git a/web/src/pages/studio/Proxy.tsx b/web/src/pages/studio/Proxy.tsx
index bc9a8abd..5cd3c850 100644
--- a/web/src/pages/studio/Proxy.tsx
+++ b/web/src/pages/studio/Proxy.tsx
@@ -32,6 +32,7 @@ import {
   Tooltip,
   App,
   Typography,
+  Input,
 } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
 import {
@@ -44,7 +45,7 @@ import {
 } from '@phosphor-icons/react';
 import PageHeader from '../../components/PageHeader';
 import { useLang } from '../../i18n/LangContext';
-import { queryProxyHomePage, type ProxyNode } from '../../api/proxy';
+import { queryProxyHomePage, reloadProxyConfig, type ProxyNode } from 
'../../api/proxy';
 
 const { Text } = Typography;
 
@@ -56,6 +57,9 @@ const ProxyPage: React.FC = () => {
   const [proxyNodes, setProxyNodes] = useState<ProxyNode[]>([]);
   const [selectedNode, setSelectedNode] = useState<ProxyNode | null>(null);
   const [configModalOpen, setConfigModalOpen] = useState(false);
+  const [clusterId, setClusterId] = useState<string>(
+    localStorage.getItem('clusterId') || 'DefaultCluster',
+  );
   const loadRequestId = useRef(0);
 
   const [clusterStats, setClusterStats] = useState({
@@ -97,6 +101,7 @@ const ProxyPage: React.FC = () => {
       } else if (proxyAddrList && proxyAddrList.length > 0) {
         localStorage.setItem('proxyAddr', proxyAddrList[0]);
       }
+
       return true;
     } catch {
       if (requestId !== loadRequestId.current) return false;
@@ -129,6 +134,26 @@ const ProxyPage: React.FC = () => {
     }
   };
 
+  const handleClusterIdChange = (value: string) => {
+    setClusterId(value);
+    if (value) {
+      localStorage.setItem('clusterId', value);
+    }
+  };
+
+  const handleReloadConfig = async (node: ProxyNode) => {
+    try {
+      const result = await reloadProxyConfig(clusterId, node.address);
+      if (result.success) {
+        message.success(t('proxy.reloadSuccess'));
+      } else {
+        message.warning(t('proxy.reloadFailed'));
+      }
+    } catch {
+      message.error(t('proxy.reloadFailed'));
+    }
+  };
+
   const renderStatus = (status: string) => {
     const map: Record<string, { color: string; icon: React.ReactNode; label: 
string }> = {
       healthy: {
@@ -146,6 +171,16 @@ const ProxyPage: React.FC = () => {
         icon: <Warning size={12} weight="fill" />,
         label: t('proxy.warning'),
       },
+      error: {
+        color: 'error',
+        icon: <XCircle size={12} weight="fill" />,
+        label: t('proxy.statusError'),
+      },
+      offline: {
+        color: 'default',
+        icon: null,
+        label: t('proxy.statusOffline'),
+      },
       unknown: {
         color: 'default',
         icon: null,
@@ -262,6 +297,15 @@ const ProxyPage: React.FC = () => {
               onClick={() => handleViewConfig(record)}
             />
           </Tooltip>
+          <Tooltip title={t('proxy.reloadConfig')}>
+            <Button
+              type="link"
+              size="small"
+              icon={<ArrowClockwise size={14} />}
+              aria-label={t('proxy.reloadConfig')}
+              onClick={() => handleReloadConfig(record)}
+            />
+          </Tooltip>
         </Space>
       ),
     },
@@ -276,6 +320,13 @@ const ProxyPage: React.FC = () => {
 
         extra={
           <Space>
+            <Input
+              placeholder={t('proxy.clusterIdPlaceholder')}
+              value={clusterId}
+              onChange={(e) => handleClusterIdChange(e.target.value)}
+              style={{ width: 200 }}
+              aria-label={t('proxy.clusterId')}
+            />
             <Button type="primary" icon={<ArrowClockwise size={14} />} 
onClick={handleRefresh}>
               {t('common.refresh')}
             </Button>
@@ -329,7 +380,11 @@ const ProxyPage: React.FC = () => {
         </Row>
 
         {/* Node Table */}
-        <Card title={t('proxy.nodes')} bordered={false} style={{ borderRadius: 
8 }}>
+        <Card
+          title={t('proxy.nodes')}
+          bordered={false}
+          style={{ borderRadius: 8, marginBottom: 24 }}
+        >
           <Table columns={columns} dataSource={proxyNodes} pagination={false} 
size="middle" />
         </Card>
       </Spin>
diff --git a/web/src/pages/studio/__tests__/Proxy.test.tsx 
b/web/src/pages/studio/__tests__/Proxy.test.tsx
index 2416dd50..b8c1ae8d 100644
--- a/web/src/pages/studio/__tests__/Proxy.test.tsx
+++ b/web/src/pages/studio/__tests__/Proxy.test.tsx
@@ -19,12 +19,13 @@ import { beforeAll, beforeEach, describe, expect, it, vi } 
from 'vitest';
 import { act, render, screen, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { App } from 'antd';
-import { queryProxyHomePage } from '../../../api/proxy';
+import { queryProxyHomePage, reloadProxyConfig } from '../../../api/proxy';
 import { LangProvider } from '../../../i18n/LangContext';
 import ProxyPage from '../Proxy';
 
 vi.mock('../../../api/proxy', () => ({
   queryProxyHomePage: vi.fn(),
+  reloadProxyConfig: vi.fn(),
 }));
 
 beforeAll(() => {
@@ -70,6 +71,9 @@ describe('ProxyPage', () => {
   beforeEach(() => {
     vi.clearAllMocks();
     vi.mocked(queryProxyHomePage).mockResolvedValue(proxyHome);
+    vi.mocked(reloadProxyConfig).mockResolvedValue({
+      success: true,
+    });
   });
 
   it('loads Proxy nodes once after the page mounts', async () => {
@@ -82,7 +86,7 @@ describe('ProxyPage', () => {
   it('shows success after the proxy list refreshes', async () => {
     const user = userEvent.setup();
     renderPage();
-    await screen.findByText('127.0.0.1:8081');
+    await screen.findAllByText('127.0.0.1:8081');
 
     await user.click(screen.getByRole('button', { name: '刷新' }));
 
@@ -93,7 +97,7 @@ describe('ProxyPage', () => {
   it('does not show success when the proxy list refresh fails', async () => {
     const user = userEvent.setup();
     renderPage();
-    await screen.findByText('127.0.0.1:8081');
+    await screen.findAllByText('127.0.0.1:8081');
     vi.mocked(queryProxyHomePage).mockRejectedValueOnce(new Error('network 
error'));
 
     await user.click(screen.getByRole('button', { name: '刷新' }));
@@ -106,7 +110,7 @@ describe('ProxyPage', () => {
   it('does not render simulated proxy configuration values', async () => {
     const user = userEvent.setup();
     renderPage();
-    await screen.findByText('127.0.0.1:8081');
+    await screen.findAllByText('127.0.0.1:8081');
 
     await user.click(screen.getByRole('button', { name: '查看配置' }));
 
@@ -120,11 +124,24 @@ describe('ProxyPage', () => {
 
   it('marks runtime metrics unavailable when proxy API only returns 
addresses', async () => {
     renderPage();
-    await screen.findByText('127.0.0.1:8081');
+    await screen.findAllByText('127.0.0.1:8081');
 
     expect(screen.queryByText('5.3.0')).not.toBeInTheDocument();
     expect(screen.getAllByText('N/A').length).toBeGreaterThanOrEqual(5);
   });
+  it('calls reloadProxyConfig when the reload button is clicked', async () => {
+    const user = userEvent.setup();
+    renderPage();
+    await screen.findAllByText('127.0.0.1:8081');
+
+    await user.click(screen.getByRole('button', { name: '重载配置' }));
+
+    await waitFor(() =>
+      expect(reloadProxyConfig).toHaveBeenCalledWith('DefaultCluster', 
'127.0.0.1:8081'),
+    );
+    expect(await screen.findByText('配置重载成功')).toBeInTheDocument();
+  });
+
   it('keeps the latest Proxy list when an older refresh resolves last', async 
() => {
     const older = createDeferred<typeof proxyHome>();
     const latest = createDeferred<typeof proxyHome>();

Reply via email to