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>();