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 eb44e159 fix: harden runtime integrity across instances, alerts and
settings (#1966)
eb44e159 is described below
commit eb44e1594408082fe422f2da57f12a5586a81120
Author: aias00 <[email protected]>
AuthorDate: Fri Aug 14 16:06:17 2026 +0800
fix: harden runtime integrity across instances, alerts and settings (#1966)
Consolidates #1830, #1833, #1907, #1966, #1969, #1971, #1993, #1995,
#1997, #1999, #2005: keep observability asset load failures retryable,
make global layout commands keyboard accessible, pause runtime polling
while the tab is hidden, test metric data sources through the correct
backend path, validate resource plan subscribed topics, hide unsupported
general settings controls, deduplicate producer connections per cluster,
preserve structured reset-offset failures, report instance update and
deletion integrity, preserve K8s certificate mutation integrity, and
acknowledge system alerts with an atomic update.
---
.../studio/cluster/k8s/K8sCertRepository.java | 2 +-
.../studio/cluster/k8s/K8sCertService.java | 35 ++++--
.../cluster/k8s/MybatisPlusK8sCertRepository.java | 4 +-
.../studio/cluster/metrics/MetricsBackendType.java | 22 ++--
.../studio/instance/InstanceRepository.java | 2 +-
.../rocketmq/studio/instance/InstanceService.java | 4 +-
.../instance/MybatisPlusInstanceRepository.java | 4 +-
.../rocketmq/studio/ops/alert/AlertRepository.java | 2 +-
.../rocketmq/studio/ops/alert/AlertService.java | 8 +-
.../ops/alert/MybatisPlusAlertRepository.java | 10 +-
.../provider/apache/RocketMQAdminClientImpl.java | 3 +
.../provider/apache/RocketMQClientProvider.java | 3 +-
.../rocketmq/studio/settings/SettingsService.java | 8 +-
.../studio/cluster/k8s/K8sCertServiceTest.java | 40 +++++++
.../k8s/MybatisPlusK8sCertRepositoryTest.java | 12 +++
.../cluster/metrics/MetricsBackendTypeTest.java | 11 ++
.../studio/instance/InstanceServiceTest.java | 22 ++++
.../MybatisPlusInstanceRepositoryTest.java | 10 +-
.../studio/ops/alert/AlertServiceTest.java | 19 +++-
.../ops/alert/MybatisPlusAlertRepositoryTest.java | 17 +++
.../apache/RocketMQAdminClientImplTest.java | 27 +++++
.../apache/RocketMQClientProviderTest.java | 19 ++++
.../studio/settings/SettingsServiceTest.java | 8 +-
web/src/api/settings.ts | 2 +-
web/src/components/AlertRuleAssetList.tsx | 74 +++++++++----
web/src/components/GrafanaDashboardList.tsx | 74 +++++++++----
web/src/components/MetricsExplorer.tsx | 36 +++++--
.../__tests__/AlertRuleAssetList.test.tsx | 17 +++
.../__tests__/GrafanaDashboardList.test.tsx | 24 +++++
.../components/__tests__/MetricsExplorer.test.tsx | 10 ++
.../src/hooks/useVisiblePolling.ts | 31 ++++--
web/src/i18n/translations.ts | 13 +++
web/src/layouts/MainLayout.test.tsx | 44 +++++++-
web/src/layouts/MainLayout.tsx | 119 +++++++++++++++------
.../pages/instance/__tests__/InstancePage.test.tsx | 32 +++++-
web/src/pages/instance/index.tsx | 14 +--
.../settings/__tests__/DataSourceTab.test.tsx | 49 ++++++++-
.../settings/__tests__/GeneralSettingsTab.test.tsx | 29 +++++
web/src/pages/settings/index.tsx | 67 +++++-------
web/src/pages/studio/BrokerCluster.tsx | 24 ++---
web/src/pages/studio/GroupManagement.tsx | 10 +-
.../pages/studio/__tests__/BrokerCluster.test.tsx | 24 ++++-
.../studio/__tests__/GroupManagement.test.tsx | 26 +++--
web/src/services/resourcePlanService.test.ts | 29 +++++
web/src/services/resourcePlanService.ts | 15 +++
45 files changed, 832 insertions(+), 223 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertRepository.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertRepository.java
index 95a10c3f..5ef5d48d 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertRepository.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertRepository.java
@@ -28,5 +28,5 @@ public interface K8sCertRepository {
K8sCertVO save(K8sCertVO cert);
- void deleteById(String id);
+ boolean deleteById(String id);
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertService.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertService.java
index b13f9804..8de29cfb 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertService.java
@@ -97,20 +97,24 @@ public class K8sCertService {
.orElseThrow(() -> new BusinessException(404, "Certificate not
found: " + command.getId()));
K8sCertVO updated = copyOf(existing);
- if (command.getName() != null) {
- updated.setName(command.getName());
+ String name = normalizeOptionalIdentity(command.getName(), "name");
+ String namespace = normalizeOptionalIdentity(command.getNamespace(),
"namespace");
+ String cluster = normalizeOptionalIdentity(command.getCluster(),
"cluster");
+ String issuer = normalizeOptionalIdentity(command.getIssuer(),
"issuer");
+ if (name != null) {
+ updated.setName(name);
}
- if (command.getNamespace() != null) {
- updated.setNamespace(command.getNamespace());
+ if (namespace != null) {
+ updated.setNamespace(namespace);
}
- if (command.getCluster() != null) {
- updated.setCluster(command.getCluster());
+ if (cluster != null) {
+ updated.setCluster(cluster);
}
if (command.getType() != null) {
updated.setType(CertType.valueOf(command.getType()));
}
- if (command.getIssuer() != null) {
- updated.setIssuer(command.getIssuer());
+ if (issuer != null) {
+ updated.setIssuer(issuer);
}
if (command.getSan() != null) {
updated.setSan(command.getSan());
@@ -151,7 +155,9 @@ public class K8sCertService {
log.info("Deleting K8s certificate: {}", command.getId());
k8sCertRepository.findById(command.getId())
.orElseThrow(() -> new BusinessException(404, "Certificate not
found: " + command.getId()));
- k8sCertRepository.deleteById(command.getId());
+ if (!k8sCertRepository.deleteById(command.getId())) {
+ throw new BusinessException(404, "Certificate not found: " +
command.getId());
+ }
recordAudit("DELETE_K8S_CERTIFICATE", "K8S_CERTIFICATE",
command.getId(), null,
null);
log.info("K8s certificate deleted: {}", command.getId());
@@ -163,6 +169,17 @@ public class K8sCertService {
}
}
+ private String normalizeOptionalIdentity(String value, String field) {
+ if (value == null) {
+ return null;
+ }
+ String normalized = value.trim();
+ if (normalized.isEmpty()) {
+ throw new BusinessException(400, "Certificate " + field + " cannot
be blank");
+ }
+ return normalized;
+ }
+
private K8sCertVO refreshExpirationState(K8sCertVO cert, LocalDateTime
now) {
K8sCertVO refreshed = copyOf(cert);
LocalDateTime notAfter = refreshed.getNotAfter();
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/MybatisPlusK8sCertRepository.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/MybatisPlusK8sCertRepository.java
index 11bb8b33..b81061ae 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/MybatisPlusK8sCertRepository.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/MybatisPlusK8sCertRepository.java
@@ -76,8 +76,8 @@ public class MybatisPlusK8sCertRepository implements
K8sCertRepository {
}
@Override
- public void deleteById(String id) {
- certMapper.deleteById(id);
+ public boolean deleteById(String id) {
+ return certMapper.deleteById(id) > 0;
}
private K8sCertVO toVO(RmqK8sCertificate entity) {
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsBackendType.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsBackendType.java
index ac5e256e..e30b8c02 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsBackendType.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsBackendType.java
@@ -29,24 +29,30 @@ import java.util.Locale;
*/
public enum MetricsBackendType {
- PROMETHEUS("/api/v1/query_range"),
- VICTORIA_METRICS("/select/0/prometheus/api/v1/query_range"),
- THANOS("/api/v1/query_range"),
- CORTEX("/api/v1/query_range"),
- MIMIR("/prometheus/api/v1/query_range"),
- ARMS("/api/v1/query_range"),
- CUSTOM("/api/v1/query_range");
+ PROMETHEUS("/api/v1/query_range", "/api/v1/query"),
+ VICTORIA_METRICS("/select/0/prometheus/api/v1/query_range",
"/select/0/prometheus/api/v1/query"),
+ THANOS("/api/v1/query_range", "/api/v1/query"),
+ CORTEX("/api/v1/query_range", "/api/v1/query"),
+ MIMIR("/prometheus/api/v1/query_range", "/prometheus/api/v1/query"),
+ ARMS("/api/v1/query_range", "/api/v1/query"),
+ CUSTOM("/api/v1/query_range", "/api/v1/query");
private final String queryPath;
+ private final String instantQueryPath;
- MetricsBackendType(String queryPath) {
+ MetricsBackendType(String queryPath, String instantQueryPath) {
this.queryPath = queryPath;
+ this.instantQueryPath = instantQueryPath;
}
public String getQueryPath() {
return queryPath;
}
+ public String getInstantQueryPath() {
+ return instantQueryPath;
+ }
+
/**
* Resolves a provider type name (as stored in {@code
MetricsDataSourceConfig.providerType})
* to a backend type, defaulting to {@link #PROMETHEUS} for unknown values.
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceRepository.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceRepository.java
index a48af8bc..a6011609 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceRepository.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceRepository.java
@@ -48,7 +48,7 @@ public interface InstanceRepository {
InstanceVO save(InstanceVO instance);
- void deleteById(String id);
+ boolean deleteById(String id);
boolean existsByCredentialId(String credentialId);
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
index ffdff159..999467bd 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
@@ -279,7 +279,9 @@ public class InstanceService {
"Cannot delete instance with managed resources: topics=%d,
consumerGroups=%d",
topicCount, consumerGroupCount));
}
- instanceRepository.deleteById(id);
+ if (!instanceRepository.deleteById(id)) {
+ throw new BusinessException(404, "InstanceVO not found: " + id);
+ }
releaseApacheEndpointIfUnused(existing, null);
recordAudit("DELETE_INSTANCE", "INSTANCE", id, null,
instanceAuditDetail(existing));
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/MybatisPlusInstanceRepository.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/MybatisPlusInstanceRepository.java
index fed034c9..e4042f1d 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/MybatisPlusInstanceRepository.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/MybatisPlusInstanceRepository.java
@@ -123,8 +123,8 @@ public class MybatisPlusInstanceRepository implements
InstanceRepository {
}
@Override
- public void deleteById(String id) {
- instanceMapper.deleteById(id);
+ public boolean deleteById(String id) {
+ return instanceMapper.deleteById(id) > 0;
}
@Override
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRepository.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRepository.java
index a7b74dcc..dd98b928 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRepository.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRepository.java
@@ -30,7 +30,7 @@ public interface AlertRepository {
List<SystemAlertVO> findAlerts(String level);
- SystemAlertVO saveAlert(SystemAlertVO alert);
+ boolean acknowledgeAlert(SystemAlertVO alert);
int deleteAcknowledgedAlerts();
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
index 79b03d12..52f57b19 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
@@ -231,10 +231,12 @@ public class AlertService {
.findFirst()
.orElseThrow(() -> new
org.apache.rocketmq.studio.common.exception.BusinessException(404, "System
alert not found: " + id));
alert.setAcknowledged(true);
- SystemAlertVO saved = alertRepository.saveAlert(alert);
- recordAudit("ACKNOWLEDGE_SYSTEM_ALERT", "SYSTEM_ALERT", saved.getId(),
null,
+ if (!alertRepository.acknowledgeAlert(alert)) {
+ throw new BusinessException(404, "System alert not found: " + id);
+ }
+ recordAudit("ACKNOWLEDGE_SYSTEM_ALERT", "SYSTEM_ALERT", alert.getId(),
null,
"acknowledged=true");
- return saved;
+ return alert;
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepository.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepository.java
index 2c04157c..0fdc700b 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepository.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepository.java
@@ -86,14 +86,8 @@ public class MybatisPlusAlertRepository implements
AlertRepository {
}
@Override
- public SystemAlertVO saveAlert(SystemAlertVO alert) {
- RmqSystemAlert entity = toAlertEntity(alert);
- if (entity.getId() != null && alertMapper.selectById(entity.getId())
!= null) {
- alertMapper.updateById(entity);
- } else {
- alertMapper.insert(entity);
- }
- return alert;
+ public boolean acknowledgeAlert(SystemAlertVO alert) {
+ return alertMapper.updateById(toAlertEntity(alert)) > 0;
}
@Override
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImpl.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImpl.java
index 687de002..9f65959b 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImpl.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImpl.java
@@ -518,6 +518,9 @@ public class RocketMQAdminClientImpl implements AdminClient
{
}
recordAudit("RESET_OFFSET", name,
"instanceId=" + instanceId + ", topic=" + topic + ",
timestamp=" + timestamp, "SUCCESS");
+ } catch (BusinessException e) {
+ recordAudit("RESET_OFFSET", name, e.getMessage(), "FAILED");
+ throw e;
} catch (Exception e) {
recordAudit("RESET_OFFSET", name, e.getMessage(), "FAILED");
throw new BusinessException(500, "Failed to reset offset: " +
e.getMessage());
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQClientProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQClientProvider.java
index 7ab62af5..051d22bf 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQClientProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQClientProvider.java
@@ -183,7 +183,8 @@ public class RocketMQClientProvider implements
ClientProvider {
if (producerInfo == null) {
continue;
}
- String key = producerGroup + '\0'
+ String key = Objects.toString(clusterId, "") + '\0'
+ + producerGroup + '\0'
+ Objects.toString(producerInfo.getClientId(), "") +
'\0'
+ Objects.toString(producerInfo.getRemoteIP(), "");
connections.putIfAbsent(key, toConnectionVO(producerInfo,
producerGroup, clusterId));
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 b1c58209..a64a8187 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
@@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.studio.audit.OperationAuditService;
+import org.apache.rocketmq.studio.cluster.metrics.MetricsBackendType;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.common.util.UrlHostGuard;
import java.net.InetAddress;
@@ -242,7 +243,7 @@ public class SettingsService {
try {
JsonNode response = restClient.get()
- .uri(prometheusQueryUri(request.getUrl()))
+ .uri(prometheusQueryUri(request.getUrl(),
request.getType()))
.accept(MediaType.APPLICATION_JSON)
.headers(headers -> applyAuthentication(headers, request))
.retrieve()
@@ -307,7 +308,7 @@ public class SettingsService {
return auth.trim().replaceAll("\\s+", " ").toLowerCase(Locale.ROOT);
}
- private URI prometheusQueryUri(String baseUrl) throws URISyntaxException {
+ private URI prometheusQueryUri(String baseUrl, String providerType) throws
URISyntaxException {
if (!StringUtils.hasText(baseUrl)) {
throw new IllegalArgumentException("Data source URL is required");
}
@@ -324,7 +325,8 @@ public class SettingsService {
throw new IllegalArgumentException(
"Data source URL must not point to a local or private
address");
}
- return UriComponentsBuilder.fromUriString(normalized + "/api/v1/query")
+ String queryPath =
MetricsBackendType.fromProviderType(providerType).getInstantQueryPath();
+ return UriComponentsBuilder.fromUriString(normalized + queryPath)
.queryParam("query", PROMETHEUS_TEST_QUERY)
.build()
.toUri();
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertServiceTest.java
index 72382060..645965d5 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertServiceTest.java
@@ -34,13 +34,16 @@ import java.time.ZoneOffset;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
+import java.util.function.Consumer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@@ -300,6 +303,28 @@ class K8sCertServiceTest {
assertThat(result.getIssuer()).isEqualTo("letsencrypt");
}
+ @Test
+ void updateCertShouldRejectBlankIdentityFields() {
+
when(k8sCertRepository.findById("cert-1")).thenReturn(Optional.of(sampleCert));
+ List<Map.Entry<String, Consumer<UpdateCertDTO>>> invalidUpdates =
List.of(
+ Map.entry("name", command -> command.setName(" ")),
+ Map.entry("namespace", command -> command.setNamespace("\t")),
+ Map.entry("cluster", command -> command.setCluster("\n")),
+ Map.entry("issuer", command -> command.setIssuer(" ")));
+
+ for (Map.Entry<String, Consumer<UpdateCertDTO>> invalidUpdate :
invalidUpdates) {
+ UpdateCertDTO command =
UpdateCertDTO.builder().id("cert-1").build();
+ invalidUpdate.getValue().accept(command);
+
+ assertThatThrownBy(() -> k8sCertService.updateCert(command))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("Certificate " + invalidUpdate.getKey() + "
cannot be blank")
+ .satisfies(error -> assertThat(((BusinessException)
error).getCode()).isEqualTo(400));
+ }
+
+ verify(k8sCertRepository, never()).save(any(K8sCertVO.class));
+ }
+
@Test
void updateCertShouldThrowWhenNotFound() {
when(k8sCertRepository.findById("nonexistent")).thenReturn(Optional.empty());
@@ -404,6 +429,7 @@ class K8sCertServiceTest {
@Test
void deleteCertShouldDeleteWhenFound() {
when(k8sCertRepository.findById("cert-1")).thenReturn(Optional.of(sampleCert));
+ when(k8sCertRepository.deleteById("cert-1")).thenReturn(true);
DeleteCertDTO command = DeleteCertDTO.builder().id("cert-1").build();
@@ -414,6 +440,20 @@ class K8sCertServiceTest {
eq("cert-1"), eq(null), eq(null), eq("SUCCESS"), eq(null));
}
+ @Test
+ void deleteCertShouldRejectConcurrentRemoval() {
+
when(k8sCertRepository.findById("cert-1")).thenReturn(Optional.of(sampleCert));
+ when(k8sCertRepository.deleteById("cert-1")).thenReturn(false);
+
+ DeleteCertDTO command = DeleteCertDTO.builder().id("cert-1").build();
+
+ assertThatThrownBy(() -> k8sCertService.deleteCert(command))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("Certificate not found: cert-1")
+ .satisfies(error -> assertThat(((BusinessException)
error).getCode()).isEqualTo(404));
+ verifyNoInteractions(operationAuditService);
+ }
+
@Test
void deleteCertShouldThrowWhenNotFound() {
when(k8sCertRepository.findById("nonexistent")).thenReturn(Optional.empty());
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/cluster/k8s/MybatisPlusK8sCertRepositoryTest.java
b/server/src/test/java/org/apache/rocketmq/studio/cluster/k8s/MybatisPlusK8sCertRepositoryTest.java
index 1ea2aa0c..d60c993f 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/cluster/k8s/MybatisPlusK8sCertRepositoryTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/cluster/k8s/MybatisPlusK8sCertRepositoryTest.java
@@ -62,6 +62,18 @@ class MybatisPlusK8sCertRepositoryTest {
((BusinessException) error).getCode()).isEqualTo(409));
}
+ @Test
+ void deleteByIdShouldReportWhetherARowWasRemoved() {
+ RmqK8sCertificateMapper mapper = mock(RmqK8sCertificateMapper.class);
+ when(mapper.deleteById("deleted")).thenReturn(1);
+ when(mapper.deleteById("missing")).thenReturn(0);
+
+ MybatisPlusK8sCertRepository repository = repository(mapper);
+
+ assertThat(repository.deleteById("deleted")).isTrue();
+ assertThat(repository.deleteById("missing")).isFalse();
+ }
+
@Test
void findByIdSurfacesInvalidPersistedCertificateType() {
RmqK8sCertificateMapper mapper = mock(RmqK8sCertificateMapper.class);
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricsBackendTypeTest.java
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricsBackendTypeTest.java
index f47aa601..2054bb66 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricsBackendTypeTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricsBackendTypeTest.java
@@ -70,4 +70,15 @@ class MetricsBackendTypeTest {
assertThat(MetricsBackendType.CORTEX.getQueryPath()).isEqualTo("/api/v1/query_range");
assertThat(MetricsBackendType.ARMS.getQueryPath()).isEqualTo("/api/v1/query_range");
}
+
+ @Test
+ void shouldExposeDistinctInstantQueryPathsForBackends() {
+
assertThat(MetricsBackendType.PROMETHEUS.getInstantQueryPath()).isEqualTo("/api/v1/query");
+ assertThat(MetricsBackendType.VICTORIA_METRICS.getInstantQueryPath())
+ .isEqualTo("/select/0/prometheus/api/v1/query");
+
assertThat(MetricsBackendType.MIMIR.getInstantQueryPath()).isEqualTo("/prometheus/api/v1/query");
+
assertThat(MetricsBackendType.THANOS.getInstantQueryPath()).isEqualTo("/api/v1/query");
+
assertThat(MetricsBackendType.CORTEX.getInstantQueryPath()).isEqualTo("/api/v1/query");
+
assertThat(MetricsBackendType.ARMS.getInstantQueryPath()).isEqualTo("/api/v1/query");
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
index b428cc1b..c282db8a 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
@@ -652,6 +652,7 @@ class InstanceServiceTest {
when(providerRegistry.forVendor(InstanceVendor.APACHE)).thenReturn(instanceProvider);
when(instanceProvider.countTopics("inst-1")).thenReturn(0);
when(instanceProvider.countGroups("inst-1")).thenReturn(0);
+ when(instanceRepository.deleteById("inst-1")).thenReturn(true);
instanceService.deleteInstance("inst-1");
@@ -710,6 +711,7 @@ class InstanceServiceTest {
when(instanceRepository.findById("inst-1")).thenReturn(Optional.of(existing));
when(instanceRepository.findAll()).thenReturn(List.of());
when(providerRegistry.forVendor(InstanceVendor.APACHE)).thenReturn(instanceProvider);
+ when(instanceRepository.deleteById("inst-1")).thenReturn(true);
instanceService.deleteInstance("inst-1");
@@ -717,6 +719,26 @@ class InstanceServiceTest {
verify(adminFactory).release("namesrv:9876");
}
+ @Test
+ void deleteInstanceShouldRejectConcurrentRemoval() {
+ InstanceVO existing = InstanceVO.builder()
+ .name("concurrently-removed")
+ .endpoint("namesrv:9876")
+ .build();
+ existing.setId("inst-1");
+
when(instanceRepository.findById("inst-1")).thenReturn(Optional.of(existing));
+
when(providerRegistry.forVendor(InstanceVendor.APACHE)).thenReturn(instanceProvider);
+ when(instanceRepository.deleteById("inst-1")).thenReturn(false);
+
+ assertThatThrownBy(() -> instanceService.deleteInstance("inst-1"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("InstanceVO not found: inst-1")
+ .satisfies(error -> assertThat(((BusinessException)
error).getCode()).isEqualTo(404));
+
+ verify(adminFactory, never()).release(any());
+ verifyNoInteractions(operationAuditService);
+ }
+
@Test
void deleteInstanceShouldThrowWhenIdIsNull() {
assertThatThrownBy(() -> instanceService.deleteInstance(null))
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/MybatisPlusInstanceRepositoryTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/MybatisPlusInstanceRepositoryTest.java
index a280fc68..14e0aab5 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/MybatisPlusInstanceRepositoryTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/MybatisPlusInstanceRepositoryTest.java
@@ -196,10 +196,18 @@ class MybatisPlusInstanceRepositoryTest {
@Test
void deleteByIdShouldDelegateToMapper() {
repository.deleteById("instance-direct-1");
-
verify(instanceMapper).deleteById("instance-direct-1");
}
+ @Test
+ void deleteByIdShouldReportWhetherARowWasRemoved() {
+ when(instanceMapper.deleteById("deleted")).thenReturn(1);
+ when(instanceMapper.deleteById("missing")).thenReturn(0);
+
+ assertThat(repository.deleteById("deleted")).isTrue();
+ assertThat(repository.deleteById("missing")).isFalse();
+ }
+
private RmqInstance entity(String id, InstanceType type) {
RmqInstance entity = new RmqInstance();
entity.setId(id);
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
index f4bc37b6..c7a95955 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
@@ -723,12 +723,12 @@ class AlertServiceTest {
SystemAlertVO existing =
SystemAlertVO.builder().id("a1").level(AlertLevel.error)
.title("Broker Down").acknowledged(false).build();
when(alertRepository.findAlerts(null)).thenReturn(List.of(existing));
-
when(alertRepository.saveAlert(any(SystemAlertVO.class))).thenAnswer(invocation
-> invocation.getArgument(0));
+
when(alertRepository.acknowledgeAlert(any(SystemAlertVO.class))).thenReturn(true);
SystemAlertVO result = alertService.acknowledgeAlert("a1");
assertThat(result.isAcknowledged()).isTrue();
- verify(alertRepository).saveAlert(result);
+ verify(alertRepository).acknowledgeAlert(result);
verify(operationAuditService).record(eq("ACKNOWLEDGE_SYSTEM_ALERT"),
eq("SYSTEM_ALERT"), eq("a1"),
eq(null), eq("acknowledged=true"), eq("SUCCESS"), eq(null));
}
@@ -761,6 +761,21 @@ class AlertServiceTest {
.hasMessageContaining("System alert not found: non-existent");
}
+ @Test
+ void acknowledgeAlertShouldRejectConcurrentRemoval() {
+ SystemAlertVO existing =
SystemAlertVO.builder().id("a1").level(AlertLevel.error)
+ .title("Broker Down").acknowledged(false).build();
+ when(alertRepository.findAlerts(null)).thenReturn(List.of(existing));
+
when(alertRepository.acknowledgeAlert(any(SystemAlertVO.class))).thenReturn(false);
+
+ assertThatThrownBy(() -> alertService.acknowledgeAlert("a1"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("System alert not found: a1")
+ .satisfies(error -> assertThat(((BusinessException)
error).getCode()).isEqualTo(404));
+
+ verify(operationAuditService, never()).record(any(), any(), any(),
any(), any(), any(), any());
+ }
+
@Test
void clearAcknowledgedShouldReturnDeletedCount() {
when(alertRepository.deleteAcknowledgedAlerts()).thenReturn(3);
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepositoryTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepositoryTest.java
index 7b7a0458..bf2a6c45 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepositoryTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepositoryTest.java
@@ -34,6 +34,7 @@ import java.util.Locale;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -69,6 +70,22 @@ class MybatisPlusAlertRepositoryTest {
org.apache.rocketmq.studio.common.domain.enums.AlertLevel.warning));
}
+ @Test
+ void acknowledgeAlertShouldReportUpdateOutcomeWithoutInserting() {
+ SystemAlertVO deleted =
SystemAlertVO.builder().id("deleted").acknowledged(true).build();
+ SystemAlertVO existing =
SystemAlertVO.builder().id("existing").acknowledged(true).build();
+ when(alertMapper.updateById(argThat((RmqSystemAlert entity) ->
+ entity != null && "deleted".equals(entity.getId()))))
+ .thenReturn(0);
+ when(alertMapper.updateById(argThat((RmqSystemAlert entity) ->
+ entity != null && "existing".equals(entity.getId()))))
+ .thenReturn(1);
+
+ assertThat(repository.acknowledgeAlert(deleted)).isFalse();
+ assertThat(repository.acknowledgeAlert(existing)).isTrue();
+ verify(alertMapper, never()).insert(any(RmqSystemAlert.class));
+ }
+
@Test
void findAlertsShouldNormalizeLevelIndependentlyOfDefaultLocale() {
when(alertMapper.selectList(any())).thenReturn(List.of());
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImplTest.java
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImplTest.java
index 1d7da8ac..024facef 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImplTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImplTest.java
@@ -148,6 +148,33 @@ class RocketMQAdminClientImplTest {
verifyNoInteractions(auditService);
}
+ @Test
+ void resetOffsetShouldPreserveStructuredResolverFailure() {
+
when(runtimeAdminClientResolver.execute(org.mockito.ArgumentMatchers.eq("missing-instance"),
any()))
+ .thenThrow(new BusinessException(404, "Instance not found:
missing-instance"));
+
+ assertThatThrownBy(() -> adminClient.resetOffset(
+ "missing-instance", "cg-orders", 1784246400000L, "orders"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("Instance not found: missing-instance")
+ .satisfies(error -> assertThat(((BusinessException)
error).getCode()).isEqualTo(404));
+ verify(auditService).record("RESET_OFFSET", "cg-orders",
+ "Instance not found: missing-instance", "FAILED");
+ }
+
+ @Test
+ void resetOffsetShouldWrapUnexpectedAdminFailure() {
+
when(runtimeAdminClientResolver.execute(org.mockito.ArgumentMatchers.eq("instance-a"),
any()))
+ .thenThrow(new IllegalStateException("broker unavailable"));
+
+ assertThatThrownBy(() -> adminClient.resetOffset(
+ "instance-a", "cg-orders", 1784246400000L, "orders"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("Failed to reset offset: broker unavailable")
+ .satisfies(error -> assertThat(((BusinessException)
error).getCode()).isEqualTo(500));
+ verify(auditService).record("RESET_OFFSET", "cg-orders", "broker
unavailable", "FAILED");
+ }
+
@Test
void getConsumerGroupSurfacesAdminTimeout() throws Exception {
when(adminExt.examineConsumerConnectionInfo("orders"))
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQClientProviderTest.java
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQClientProviderTest.java
index 3a083e63..60b41c17 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQClientProviderTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQClientProviderTest.java
@@ -115,6 +115,25 @@ class RocketMQClientProviderTest {
verify(adminExt, never()).examineProducerConnectionInfo(anyString(),
anyString());
}
+ @Test
+ void producerScanPreservesIdenticalConnectionsAcrossClusters() throws
Exception {
+
when(adminExt.examineBrokerClusterInfo()).thenReturn(clusterInfo(Map.of(
+ "127.0.0.1:10911", "cluster-a",
+ "127.0.0.2:10911", "cluster-b")));
+ ProducerInfo shared = producerInfo("producer-client", "10.0.0.1:1000");
+ when(adminExt.getAllProducerInfo("127.0.0.1:10911"))
+ .thenReturn(new ProducerTableInfo(Map.of("pg-order",
List.of(shared))));
+ when(adminExt.getAllProducerInfo("127.0.0.2:10911"))
+ .thenReturn(new ProducerTableInfo(Map.of("pg-order",
List.of(shared))));
+
+ List<ClientConnectionVO> connections =
provider.findConnections("instance-a", null, "Producer");
+
+ assertThat(connections).hasSize(2);
+ assertThat(connections)
+ .extracting(ClientConnectionVO::getClusterName)
+ .containsExactlyInAnyOrder("cluster-a", "cluster-b");
+ }
+
@Test
void clientScanUsesActualBrokerClustersAndFiltersRequestedCluster() throws
Exception {
when(adminExt.examineBrokerClusterInfo()).thenReturn(clusterInfo(Map.of(
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 d14736d6..842212eb 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
@@ -60,6 +60,10 @@ class SettingsServiceTest {
private static final String PROMETHEUS_BASE_URL = "http://192.0.2.1:9090";
private static final String PROMETHEUS_QUERY_URL = PROMETHEUS_BASE_URL +
"/api/v1/query?query=up";
+ private static final String VICTORIA_METRICS_QUERY_URL =
+ PROMETHEUS_BASE_URL + "/select/0/prometheus/api/v1/query?query=up";
+ private static final String MIMIR_QUERY_URL =
+ PROMETHEUS_BASE_URL + "/prometheus/api/v1/query?query=up";
private static final String PROMETHEUS_SUCCESS_BODY =
"{\"status\":\"success\",\"data\":{\"resultType\":\"vector\",\"result\":[]}}";
@@ -459,7 +463,7 @@ class SettingsServiceTest {
void
testConnectionShouldNormalizeIdentifiersIndependentlyOfDefaultLocale() {
String expectedAuthorization = "Basic "
+
Base64.getEncoder().encodeToString("prom:secret".getBytes(StandardCharsets.UTF_8));
- prometheusServer.expect(requestTo(PROMETHEUS_QUERY_URL))
+ prometheusServer.expect(requestTo(MIMIR_QUERY_URL))
.andExpect(method(HttpMethod.GET))
.andExpect(header(HttpHeaders.AUTHORIZATION,
expectedAuthorization))
.andRespond(withSuccess(PROMETHEUS_SUCCESS_BODY,
MediaType.APPLICATION_JSON));
@@ -628,7 +632,7 @@ class SettingsServiceTest {
@Test
void testConnectionShouldReturnPrometheusErrorDetails() {
- prometheusServer.expect(requestTo(PROMETHEUS_QUERY_URL))
+ prometheusServer.expect(requestTo(VICTORIA_METRICS_QUERY_URL))
.andExpect(method(HttpMethod.GET))
.andRespond(withStatus(HttpStatus.UNPROCESSABLE_ENTITY)
.contentType(MediaType.APPLICATION_JSON)
diff --git a/web/src/api/settings.ts b/web/src/api/settings.ts
index 2e51d840..2ae6226b 100644
--- a/web/src/api/settings.ts
+++ b/web/src/api/settings.ts
@@ -45,7 +45,7 @@ export interface DataSource {
username?: string;
password?: string;
bearerToken?: string;
- status: string;
+ status?: string | null;
instanceIds?: string[];
}
diff --git a/web/src/components/AlertRuleAssetList.tsx
b/web/src/components/AlertRuleAssetList.tsx
index 2614d001..395dfe06 100644
--- a/web/src/components/AlertRuleAssetList.tsx
+++ b/web/src/components/AlertRuleAssetList.tsx
@@ -15,10 +15,10 @@
* limitations under the License.
*/
-import { useEffect, useRef, useState } from 'react';
-import { App, Button, Modal, Space, Table, Tag, Typography } from 'antd';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { Alert, App, Button, Modal, Space, Table, Tag, Typography } from
'antd';
import type { ColumnsType } from 'antd/es/table';
-import { DownloadSimple, Eye } from '@phosphor-icons/react';
+import { ArrowClockwise, DownloadSimple, Eye } from '@phosphor-icons/react';
import { useLang } from '../i18n/LangContext';
import {
exportAlertRuleAsset,
@@ -40,30 +40,44 @@ export const AlertRuleAssetList: React.FC = () => {
const { message } = App.useApp();
const [assets, setAssets] = useState<AlertRuleAssetInfo[]>([]);
const [loading, setLoading] = useState(true);
+ const [loadError, setLoadError] = useState(false);
const [viewing, setViewing] = useState<AlertRuleAssetInfo | null>(null);
const [viewContent, setViewContent] = useState('');
const [viewLoading, setViewLoading] = useState(false);
+ const mountedRef = useRef(true);
+ const listRequestId = useRef(0);
const viewRequestId = useRef(0);
const [exportingNames, setExportingNames] = useState<Set<string>>(() => new
Set());
- useEffect(() => {
- let cancelled = false;
- const load = async () => {
- try {
- const data = await listAlertRuleAssets();
- if (!cancelled) setAssets(data);
- } catch {
- if (!cancelled) message.error(t('alertAssets.loadFailed'));
- } finally {
- if (!cancelled) setLoading(false);
+ const loadAssets = useCallback(async () => {
+ const requestId = ++listRequestId.current;
+ setLoading(true);
+ setLoadError(false);
+ try {
+ const data = await listAlertRuleAssets();
+ if (mountedRef.current && requestId === listRequestId.current) {
+ setAssets(data);
}
- };
- void load();
+ } catch {
+ if (mountedRef.current && requestId === listRequestId.current) {
+ setLoadError(true);
+ message.error(t('alertAssets.loadFailed'));
+ }
+ } finally {
+ if (mountedRef.current && requestId === listRequestId.current) {
+ setLoading(false);
+ }
+ }
+ }, [message, t]);
+
+ useEffect(() => {
+ mountedRef.current = true;
+ const timeoutId = window.setTimeout(() => void loadAssets());
return () => {
- cancelled = true;
- viewRequestId.current += 1;
+ window.clearTimeout(timeoutId);
+ mountedRef.current = false;
};
- }, [t, message]);
+ }, [loadAssets]);
const handleView = async (info: AlertRuleAssetInfo) => {
const requestId = ++viewRequestId.current;
@@ -72,15 +86,15 @@ export const AlertRuleAssetList: React.FC = () => {
setViewLoading(true);
try {
const yaml = await getAlertRuleAsset(info.name);
- if (requestId === viewRequestId.current) {
+ if (mountedRef.current && requestId === viewRequestId.current) {
setViewContent(yaml);
}
} catch {
- if (requestId === viewRequestId.current) {
+ if (mountedRef.current && requestId === viewRequestId.current) {
message.error(t('alertAssets.loadFailed'));
}
} finally {
- if (requestId === viewRequestId.current) {
+ if (mountedRef.current && requestId === viewRequestId.current) {
setViewLoading(false);
}
}
@@ -177,6 +191,24 @@ export const AlertRuleAssetList: React.FC = () => {
return (
<div>
+ {loadError && (
+ <Alert
+ showIcon
+ type="error"
+ message={t('alertAssets.loadFailed')}
+ action={
+ <Button
+ size="small"
+ icon={<ArrowClockwise size={16} />}
+ onClick={() => void loadAssets()}
+ >
+ {t('common.retry')}
+ </Button>
+ }
+ style={{ marginBottom: 12 }}
+ />
+ )}
+
<Table
columns={columns}
dataSource={assets}
diff --git a/web/src/components/GrafanaDashboardList.tsx
b/web/src/components/GrafanaDashboardList.tsx
index 65f6acfd..f440d155 100644
--- a/web/src/components/GrafanaDashboardList.tsx
+++ b/web/src/components/GrafanaDashboardList.tsx
@@ -15,10 +15,10 @@
* limitations under the License.
*/
-import { useEffect, useRef, useState } from 'react';
-import { App, Button, Modal, Space, Table, Tag, Typography } from 'antd';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { Alert, App, Button, Modal, Space, Table, Tag, Typography } from
'antd';
import type { ColumnsType } from 'antd/es/table';
-import { DownloadSimple, Eye } from '@phosphor-icons/react';
+import { ArrowClockwise, DownloadSimple, Eye } from '@phosphor-icons/react';
import { useLang } from '../i18n/LangContext';
import {
getGrafanaDashboard,
@@ -35,31 +35,45 @@ export const GrafanaDashboardList: React.FC = () => {
const { message } = App.useApp();
const [dashboards, setDashboards] = useState<GrafanaDashboardInfo[]>([]);
const [loading, setLoading] = useState(true);
+ const [loadError, setLoadError] = useState(false);
const [viewing, setViewing] = useState<GrafanaDashboardInfo | null>(null);
const [viewContent, setViewContent] = useState('');
const [viewLoading, setViewLoading] = useState(false);
+ const mountedRef = useRef(true);
+ const listRequestId = useRef(0);
const viewRequestId = useRef(0);
const [exportingUids, setExportingUids] = useState<Set<string>>(() => new
Set());
const [exportingAll, setExportingAll] = useState(false);
- useEffect(() => {
- let cancelled = false;
- const load = async () => {
- try {
- const data = await listGrafanaDashboards();
- if (!cancelled) setDashboards(data);
- } catch {
- if (!cancelled) message.error(t('grafana.loadFailed'));
- } finally {
- if (!cancelled) setLoading(false);
+ const loadDashboards = useCallback(async () => {
+ const requestId = ++listRequestId.current;
+ setLoading(true);
+ setLoadError(false);
+ try {
+ const data = await listGrafanaDashboards();
+ if (mountedRef.current && requestId === listRequestId.current) {
+ setDashboards(data);
}
- };
- void load();
+ } catch {
+ if (mountedRef.current && requestId === listRequestId.current) {
+ setLoadError(true);
+ message.error(t('grafana.loadFailed'));
+ }
+ } finally {
+ if (mountedRef.current && requestId === listRequestId.current) {
+ setLoading(false);
+ }
+ }
+ }, [message, t]);
+
+ useEffect(() => {
+ mountedRef.current = true;
+ const timeoutId = window.setTimeout(() => void loadDashboards());
return () => {
- cancelled = true;
- viewRequestId.current += 1;
+ window.clearTimeout(timeoutId);
+ mountedRef.current = false;
};
- }, [t, message]);
+ }, [loadDashboards]);
const handleView = async (info: GrafanaDashboardInfo) => {
const requestId = ++viewRequestId.current;
@@ -68,15 +82,15 @@ export const GrafanaDashboardList: React.FC = () => {
setViewLoading(true);
try {
const model = await getGrafanaDashboard(info.uid);
- if (requestId === viewRequestId.current) {
+ if (mountedRef.current && requestId === viewRequestId.current) {
setViewContent(JSON.stringify(model, null, 2));
}
} catch {
- if (requestId === viewRequestId.current) {
+ if (mountedRef.current && requestId === viewRequestId.current) {
message.error(t('grafana.loadFailed'));
}
} finally {
- if (requestId === viewRequestId.current) {
+ if (mountedRef.current && requestId === viewRequestId.current) {
setViewLoading(false);
}
}
@@ -192,6 +206,24 @@ export const GrafanaDashboardList: React.FC = () => {
</Button>
</Space>
+ {loadError && (
+ <Alert
+ showIcon
+ type="error"
+ message={t('grafana.loadFailed')}
+ action={
+ <Button
+ size="small"
+ icon={<ArrowClockwise size={16} />}
+ onClick={() => void loadDashboards()}
+ >
+ {t('common.retry')}
+ </Button>
+ }
+ style={{ marginBottom: 12 }}
+ />
+ )}
+
<Table
columns={columns}
dataSource={dashboards}
diff --git a/web/src/components/MetricsExplorer.tsx
b/web/src/components/MetricsExplorer.tsx
index 1eb990fc..627dac61 100644
--- a/web/src/components/MetricsExplorer.tsx
+++ b/web/src/components/MetricsExplorer.tsx
@@ -230,6 +230,25 @@ interface DataSourceCredentials extends AuthFormValues {
key: string;
}
+const getQueryErrorMessage = (error: unknown, fallback: string): string => {
+ if (typeof error !== 'object' || error === null || !('response' in error)) {
+ return fallback;
+ }
+
+ const response = error.response;
+ if (typeof response !== 'object' || response === null || !('data' in
response)) {
+ return fallback;
+ }
+
+ const data = response.data;
+ if (typeof data !== 'object' || data === null || !('message' in data)) {
+ return fallback;
+ }
+
+ const message = data.message;
+ return typeof message === 'string' && message.trim() ? message : fallback;
+};
+
const getDataSourceAuthMode = (auth: string): DataSourceAuthMode => {
const normalized = auth.trim().toLowerCase();
if (normalized === 'basic' || normalized === 'basic auth') return 'basic';
@@ -239,6 +258,7 @@ const getDataSourceAuthMode = (auth: string):
DataSourceAuthMode => {
const MetricsExplorer = ({ instanceId }: MetricsExplorerProps) => {
const { lang } = useLang();
+ const queryErrorFallback = lang === 'zh' ? 'Prometheus 查询失败' : 'Prometheus
query failed';
const copy =
lang === 'zh'
? {
@@ -248,7 +268,7 @@ const MetricsExplorer = ({ instanceId }:
MetricsExplorerProps) => {
range: '时间范围',
refresh: '刷新指标',
profileError: '指标模板加载失败',
- queryError: 'Prometheus 查询失败',
+ queryError: queryErrorFallback,
noProfiles: '暂无指标模板',
noSamples: '暂无标量数据',
defaultDataSource: '默认数据源',
@@ -268,7 +288,7 @@ const MetricsExplorer = ({ instanceId }:
MetricsExplorerProps) => {
range: 'Time range',
refresh: 'Refresh metrics',
profileError: 'Failed to load metric profiles',
- queryError: 'Prometheus query failed',
+ queryError: queryErrorFallback,
noProfiles: 'No metric profiles',
noSamples: 'No scalar samples',
defaultDataSource: 'Default source',
@@ -291,7 +311,7 @@ const MetricsExplorer = ({ instanceId }:
MetricsExplorerProps) => {
const [profilesLoading, setProfilesLoading] = useState(true);
const [queryLoading, setQueryLoading] = useState(false);
const [profileError, setProfileError] = useState(false);
- const [queryError, setQueryError] = useState(false);
+ const [queryError, setQueryError] = useState<string | null>(null);
const [dataSources, setDataSources] = useState<DataSource[]>([]);
const [dataSourceKey, setDataSourceKey] = useState('');
const [dataSourcesLoading, setDataSourcesLoading] = useState(true);
@@ -333,7 +353,7 @@ const MetricsExplorer = ({ instanceId }:
MetricsExplorerProps) => {
step: range.step,
};
setQueryLoading(true);
- setQueryError(false);
+ setQueryError(null);
try {
const currentDataSourceKey = dataSourceKeyRef.current;
const credentials =
@@ -353,16 +373,16 @@ const MetricsExplorer = ({ instanceId }:
MetricsExplorerProps) => {
})
: await queryMetrics(query);
if (currentRequest === requestId.current) setData(result);
- } catch {
+ } catch (error) {
if (currentRequest === requestId.current) {
setData(null);
- setQueryError(true);
+ setQueryError(getQueryErrorMessage(error, queryErrorFallback));
}
} finally {
if (currentRequest === requestId.current) setQueryLoading(false);
}
},
- [instanceId],
+ [instanceId, queryErrorFallback],
);
useEffect(() => {
@@ -563,7 +583,7 @@ const MetricsExplorer = ({ instanceId }:
MetricsExplorerProps) => {
) : profiles.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE}
description={copy.noProfiles} />
) : queryError ? (
- <Alert type="error" showIcon message={copy.queryError} />
+ <Alert type="error" showIcon message={queryError} />
) : queryLoading && !data ? (
<Skeleton active paragraph={{ rows: 5 }} />
) : data && selectedMetric ? (
diff --git a/web/src/components/__tests__/AlertRuleAssetList.test.tsx
b/web/src/components/__tests__/AlertRuleAssetList.test.tsx
index 5da6d383..8555827e 100644
--- a/web/src/components/__tests__/AlertRuleAssetList.test.tsx
+++ b/web/src/components/__tests__/AlertRuleAssetList.test.tsx
@@ -81,6 +81,23 @@ describe('AlertRuleAssetList', () => {
expect(screen.getByText('rocketmq-consumer-lag-high')).toBeInTheDocument();
});
+ it('keeps a failed list request visible and recovers when retried', async ()
=> {
+ vi.mocked(alertRuleAssetService.listAlertRuleAssets)
+ .mockRejectedValueOnce(new Error('temporary failure'))
+ .mockResolvedValueOnce(sampleAssets);
+
+ renderWithProviders(<AlertRuleAssetList />);
+
+ const retryButton = await screen.findByRole('button', { name: /Retry|重试/
});
+ expect(alertRuleAssetService.listAlertRuleAssets).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(retryButton);
+
+ expect(await
screen.findByText('rocketmq-broker-down')).toBeInTheDocument();
+ expect(alertRuleAssetService.listAlertRuleAssets).toHaveBeenCalledTimes(2);
+ expect(screen.queryByRole('button', { name: /Retry|重试/
})).not.toBeInTheDocument();
+ });
+
it('opens a modal with yaml content when View is clicked', async () => {
vi.mocked(alertRuleAssetService.listAlertRuleAssets).mockResolvedValue(sampleAssets);
vi.mocked(alertRuleAssetService.getAlertRuleAsset).mockResolvedValue(
diff --git a/web/src/components/__tests__/GrafanaDashboardList.test.tsx
b/web/src/components/__tests__/GrafanaDashboardList.test.tsx
index 84014700..d2248c48 100644
--- a/web/src/components/__tests__/GrafanaDashboardList.test.tsx
+++ b/web/src/components/__tests__/GrafanaDashboardList.test.tsx
@@ -100,6 +100,30 @@ describe('GrafanaDashboardList', () => {
expect(screen.getByText('RocketMQ Broker')).toBeInTheDocument();
});
+ it('keeps a failed list request visible and recovers when retried', async ()
=> {
+ vi.mocked(listGrafanaDashboards)
+ .mockRejectedValueOnce(new Error('temporary failure'))
+ .mockResolvedValueOnce(dashboards);
+ const user = userEvent.setup();
+
+ render(
+ <App>
+ <LangProvider>
+ <GrafanaDashboardList />
+ </LangProvider>
+ </App>,
+ );
+
+ const retryButton = await screen.findByRole('button', { name: /Retry|重试/
});
+ expect(listGrafanaDashboards).toHaveBeenCalledTimes(1);
+
+ await user.click(retryButton);
+
+ expect(await screen.findByText('RocketMQ Cluster
Overview')).toBeInTheDocument();
+ expect(listGrafanaDashboards).toHaveBeenCalledTimes(2);
+ expect(screen.queryByRole('button', { name: /Retry|重试/
})).not.toBeInTheDocument();
+ });
+
it('opens the view modal and renders the dashboard JSON', async () => {
const user = userEvent.setup();
render(
diff --git a/web/src/components/__tests__/MetricsExplorer.test.tsx
b/web/src/components/__tests__/MetricsExplorer.test.tsx
index d1a60ccb..fb73dcd9 100644
--- a/web/src/components/__tests__/MetricsExplorer.test.tsx
+++ b/web/src/components/__tests__/MetricsExplorer.test.tsx
@@ -224,6 +224,16 @@ describe('MetricsExplorer', () => {
expect(screen.getByRole('button', { name: '刷新指标' })).toBeInTheDocument();
});
+ it('shows the actionable message returned by the metrics API', async () => {
+ vi.mocked(queryMetrics).mockRejectedValue({
+ response: { data: { message: 'Prometheus base URL is not configured' } },
+ });
+
+ renderWithProviders(<MetricsExplorer />);
+
+ expect(await screen.findByText('Prometheus base URL is not
configured')).toBeInTheDocument();
+ });
+
it('shows an empty state when Prometheus returns no scalar samples', async
() => {
vi.mocked(queryMetrics).mockResolvedValue({ ...metricData, series: [] });
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertRepository.java
b/web/src/hooks/useVisiblePolling.ts
similarity index 55%
copy from
server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertRepository.java
copy to web/src/hooks/useVisiblePolling.ts
index 95a10c3f..0d586e05 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertRepository.java
+++ b/web/src/hooks/useVisiblePolling.ts
@@ -14,19 +14,28 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package org.apache.rocketmq.studio.cluster.k8s;
+import { useEffect } from 'react';
-import java.util.List;
-import java.util.Optional;
+export function useVisiblePolling(
+ enabled: boolean,
+ intervalMs: number,
+ poll: () => void | Promise<void>,
+): void {
+ useEffect(() => {
+ if (!enabled) return;
-public interface K8sCertRepository {
+ const pollWhenVisible = () => {
+ if (document.visibilityState === 'visible') {
+ void poll();
+ }
+ };
+ const intervalId = window.setInterval(pollWhenVisible, intervalMs);
+ document.addEventListener('visibilitychange', pollWhenVisible);
- List<K8sCertVO> findAll();
-
- Optional<K8sCertVO> findById(String id);
-
- K8sCertVO save(K8sCertVO cert);
-
- void deleteById(String id);
+ return () => {
+ window.clearInterval(intervalId);
+ document.removeEventListener('visibilitychange', pollWhenVisible);
+ };
+ }, [enabled, intervalMs, poll]);
}
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index b7ff012f..e91e34f7 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -76,6 +76,18 @@ const translations: Record<string, Record<Lang, string>> = {
'common.no': { zh: '否', en: 'No' },
'common.retry': { zh: '重试', en: 'Retry' },
+ // ─── Global layout controls ───
+ 'layout.skipToMain': { zh: '跳到主要内容', en: 'Skip to main content' },
+ 'layout.goHome': { zh: '返回首页', en: 'Go to home' },
+ 'layout.openSearch': { zh: '打开导航搜索', en: 'Open navigation search' },
+ 'layout.switchToRealData': { zh: '切换到真实数据', en: 'Switch to real data' },
+ 'layout.switchToMockData': { zh: '切换到模拟数据', en: 'Switch to mock data' },
+ 'layout.switchToEnglish': { zh: '切换到英语', en: 'Switch to English' },
+ 'layout.switchToChinese': { zh: '切换到中文', en: 'Switch to Chinese' },
+ 'layout.switchToLightTheme': { zh: '切换到浅色主题', en: 'Switch to light theme' },
+ 'layout.switchToDarkTheme': { zh: '切换到深色主题', en: 'Switch to dark theme' },
+ 'layout.openUserMenu': { zh: '打开用户菜单', en: 'Open user menu' },
+
// ─── Dashboard ───
'dashboard.title': { zh: '监控面板', en: 'Dashboard' },
'dashboard.subtitle': { zh: 'RocketMQ 集群运行概览', en: 'RocketMQ Cluster
Overview' },
@@ -553,6 +565,7 @@ const translations: Record<string, Record<Lang, string>> = {
// ─── Settings ───
'settings.title': { zh: '设置', en: 'Settings' },
'settings.subtitle': { zh: '管理应用配置与数据源', en: 'Manage app settings and data
sources' },
+ 'settings.dataSourceNotTested': { zh: '未检测', en: 'Not tested' },
// ─── Certs ───
'cert.clusterName': { zh: 'K8s 集群名称', en: 'K8s Cluster Name' },
diff --git a/web/src/layouts/MainLayout.test.tsx
b/web/src/layouts/MainLayout.test.tsx
index 6e2dcc59..a52ef9ec 100644
--- a/web/src/layouts/MainLayout.test.tsx
+++ b/web/src/layouts/MainLayout.test.tsx
@@ -39,7 +39,12 @@ vi.mock('antd', async () => {
return {
Layout,
Menu: () => null,
- Breadcrumb: () => null,
+ Breadcrumb: ({ items }: { items: Array<{ key: string; title:
React.ReactNode }> }) =>
+ React.createElement(
+ 'nav',
+ null,
+ items.map((item) => React.createElement(React.Fragment, { key:
item.key }, item.title)),
+ ),
Avatar: () => React.createElement('span', null, 'avatar'),
Dropdown: ({ children, menu }: { children?: React.ReactNode; menu:
DropdownMenu }) =>
React.createElement(
@@ -66,6 +71,7 @@ vi.mock('antd', async () => {
describe('MainLayout authentication navigation', () => {
beforeEach(() => {
+ localStorage.clear();
vi.mocked(logout).mockReset().mockResolvedValue(undefined);
useAuthStore.getState().login('test-token', 'admin', true);
});
@@ -95,4 +101,40 @@ describe('MainLayout authentication navigation', () => {
expect(logout).toHaveBeenCalledOnce();
expect(localStorage.getItem('token')).toBeNull();
});
+
+ it('exposes global layout commands as localized semantic buttons', () => {
+ render(
+ <LangProvider>
+ <ThemeProvider>
+ <MemoryRouter initialEntries={['/']}>
+ <Routes>
+ <Route path="/" element={<MainLayout />}>
+ <Route index element={<div>protected home</div>} />
+ </Route>
+ </Routes>
+ </MemoryRouter>
+ </ThemeProvider>
+ </LangProvider>,
+ );
+
+ expect(screen.getByRole('button', { name: '返回首页' })).toBeInTheDocument();
+ const searchButton = screen.getByRole('button', { name: '打开导航搜索' });
+ expect(searchButton).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: '切换到模拟数据' })).toHaveAttribute(
+ 'aria-pressed',
+ 'false',
+ );
+ expect(screen.getByRole('button', { name: '切换到英语' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: '切换到深色主题' })).toHaveAttribute(
+ 'aria-pressed',
+ 'false',
+ );
+ expect(screen.getByRole('button', { name: '打开用户菜单' })).toBeInTheDocument();
+
+ fireEvent.click(searchButton);
+ expect(screen.getByRole('button', { name: '首页' })).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: '切换到英语' }));
+ expect(screen.getByRole('button', { name: 'Switch to Chinese'
})).toBeInTheDocument();
+ });
});
diff --git a/web/src/layouts/MainLayout.tsx b/web/src/layouts/MainLayout.tsx
index 5fd75121..35bd7f7d 100644
--- a/web/src/layouts/MainLayout.tsx
+++ b/web/src/layouts/MainLayout.tsx
@@ -202,14 +202,25 @@ const MainLayout = () => {
[t],
);
- const pathSnippets = location.pathname.split('/').filter((i) => i);
- const breadcrumbItems = useMemo(
- () => [
+ const breadcrumbItems = useMemo(() => {
+ const pathSnippets = location.pathname.split('/').filter((segment) =>
segment);
+ return [
{
title: (
- <span onClick={() => navigate('/')} style={{ cursor: 'pointer' }}>
+ <button
+ type="button"
+ aria-label={t('layout.goHome')}
+ onClick={() => navigate('/')}
+ style={{
+ cursor: 'pointer',
+ border: 0,
+ padding: 0,
+ background: 'transparent',
+ font: 'inherit',
+ }}
+ >
🏠
- </span>
+ </button>
),
key: 'home',
},
@@ -224,9 +235,8 @@ const MainLayout = () => {
key: path,
};
}),
- ],
- [location.pathname, navigate, breadcrumbMap, instanceScopedMatch],
- );
+ ];
+ }, [location.pathname, navigate, breadcrumbMap, instanceScopedMatch, t]);
const userMenu = {
onClick: handleUserMenuClick,
@@ -273,7 +283,7 @@ const MainLayout = () => {
event.currentTarget.style.transform = 'translateY(-150%)';
}}
>
- 跳到主要内容
+ {t('layout.skipToMain')}
</a>
<Layout style={{ height: '100vh', minHeight: 0, overflow: 'hidden' }}>
<Sider
@@ -339,7 +349,9 @@ const MainLayout = () => {
{/* Right: Search + Lang + Theme + User */}
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
{/* Search button */}
- <div
+ <button
+ type="button"
+ aria-label={t('layout.openSearch')}
onClick={() => setSearchOpen(true)}
style={{
display: 'flex',
@@ -352,6 +364,8 @@ const MainLayout = () => {
fontSize: 13,
color: '#9CA3AF',
minWidth: 160,
+ background: 'transparent',
+ font: 'inherit',
}}
>
<MagnifyingGlass size={14} />
@@ -368,10 +382,15 @@ const MainLayout = () => {
>
⌘K
</span>
- </div>
+ </button>
{/* Data mode toggle */}
- <div
+ <button
+ type="button"
+ aria-label={
+ useMock ? t('layout.switchToRealData') :
t('layout.switchToMockData')
+ }
+ aria-pressed={useMock}
onClick={handleDataModeToggle}
style={{
cursor: 'pointer',
@@ -385,12 +404,10 @@ const MainLayout = () => {
fontWeight: 500,
color: useMock ? '#d48806' : '#389e0d',
transition: 'all 0.2s',
+ background: 'transparent',
+ font: 'inherit',
}}
- title={
- useMock
- ? 'Data Mode: Mock (click to switch to Real)'
- : 'Data Mode: Real (click to switch to Mock)'
- }
+ title={useMock ? t('layout.switchToRealData') :
t('layout.switchToMockData')}
>
<span
style={{
@@ -402,10 +419,14 @@ const MainLayout = () => {
}}
/>
{useMock ? 'Mock' : 'Real'}
- </div>
+ </button>
{/* Language toggle */}
- <div
+ <button
+ type="button"
+ aria-label={
+ lang === 'zh' ? t('layout.switchToEnglish') :
t('layout.switchToChinese')
+ }
onClick={() => setLang(lang === 'zh' ? 'en' : 'zh')}
style={{
cursor: 'pointer',
@@ -419,14 +440,25 @@ const MainLayout = () => {
fontWeight: 600,
color: '#1677ff',
transition: 'background 0.2s',
+ border: 0,
+ padding: 0,
+ background: 'transparent',
+ font: 'inherit',
}}
- title={lang === 'zh' ? 'Switch to English' : '切换到中文'}
+ title={
+ lang === 'zh' ? t('layout.switchToEnglish') :
t('layout.switchToChinese')
+ }
>
{lang === 'zh' ? 'En' : '中'}
- </div>
+ </button>
{/* Theme toggle */}
- <div
+ <button
+ type="button"
+ aria-label={
+ darkMode ? t('layout.switchToLightTheme') :
t('layout.switchToDarkTheme')
+ }
+ aria-pressed={darkMode}
onClick={toggleTheme}
style={{
cursor: 'pointer',
@@ -437,23 +469,41 @@ const MainLayout = () => {
height: 28,
borderRadius: 6,
transition: 'background 0.2s',
+ border: 0,
+ padding: 0,
+ background: 'transparent',
+ font: 'inherit',
}}
- title={darkMode ? 'Light mode' : 'Dark mode'}
+ title={
+ darkMode ? t('layout.switchToLightTheme') :
t('layout.switchToDarkTheme')
+ }
>
{darkMode ? (
<Sun size={18} color="#9CA3AF" weight="fill" />
) : (
<Moon size={18} color="#9CA3AF" weight="fill" />
)}
- </div>
+ </button>
{/* User avatar */}
<Dropdown menu={userMenu} trigger={['click']}>
- <Avatar
- size={28}
- style={{ backgroundColor: '#1677ff', cursor: 'pointer' }}
- icon={<UserGear size={16} />}
- />
+ <button
+ type="button"
+ aria-label={t('layout.openUserMenu')}
+ style={{
+ cursor: 'pointer',
+ border: 0,
+ padding: 0,
+ background: 'transparent',
+ font: 'inherit',
+ }}
+ >
+ <Avatar
+ size={28}
+ style={{ backgroundColor: '#1677ff' }}
+ icon={<UserGear size={16} />}
+ />
+ </button>
</Dropdown>
</div>
</div>
@@ -509,7 +559,8 @@ const MainLayout = () => {
<div style={{ maxHeight: 360, overflow: 'auto', padding: '8px 12px
12px' }}>
{searchResults.length ? (
searchResults.map((item) => (
- <div
+ <button
+ type="button"
key={item.key}
onClick={() => {
navigate(item.key as string);
@@ -525,6 +576,12 @@ const MainLayout = () => {
cursor: 'pointer',
fontSize: 14,
transition: 'background 0.15s',
+ width: '100%',
+ border: 0,
+ background: 'transparent',
+ color: 'inherit',
+ textAlign: 'left',
+ font: 'inherit',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = darkMode ? '#1a1a1a' :
'#f5f5f5';
@@ -535,7 +592,7 @@ const MainLayout = () => {
>
{item.icon}
<span>{item.label}</span>
- </div>
+ </button>
))
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="未找到匹配页面"
/>
diff --git a/web/src/pages/instance/__tests__/InstancePage.test.tsx
b/web/src/pages/instance/__tests__/InstancePage.test.tsx
index 2338d8f8..941945b0 100644
--- a/web/src/pages/instance/__tests__/InstancePage.test.tsx
+++ b/web/src/pages/instance/__tests__/InstancePage.test.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { App } from 'antd';
+import { App, Modal } from 'antd';
import { act, fireEvent, render, screen, waitFor, within } from
'@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
@@ -277,6 +277,36 @@ describe('InstancePage', () => {
expect(instanceService.listInstances).toHaveBeenLastCalledWith({ type:
'DIRECT' });
});
+ it('reloads the latest filters after a pending instance deletion completes',
async () => {
+ const user = userEvent.setup();
+ const pendingDelete = deferred<void>();
+
vi.mocked(instanceService.deleteInstance).mockReturnValue(pendingDelete.promise);
+ const confirmSpy = vi.spyOn(Modal, 'confirm').mockImplementation((config)
=> {
+ void config.onOk?.();
+ return { destroy: vi.fn(), update: vi.fn() } as unknown as
ReturnType<typeof Modal.confirm>;
+ });
+ renderPage();
+
+ const proxyName = await screen.findByText('production-proxy');
+ await user.click(within(proxyName.closest('tr')!).getByRole('button', {
name: /删除/ }));
+ await waitFor(() =>
expect(instanceService.deleteInstance).toHaveBeenCalledWith('proxy-1'));
+
+ const typeSelect = screen.getByRole('combobox');
+ fireEvent.mouseDown(typeSelect.parentElement!);
+ await user.click(
+ await screen.findByText('Direct 模式', { selector:
'.ant-select-item-option-content' }),
+ );
+ await waitFor(() =>
+ expect(instanceService.listInstances).toHaveBeenLastCalledWith({ type:
'DIRECT' }),
+ );
+
+ await act(async () => pendingDelete.resolve());
+
+ await waitFor(() =>
expect(instanceService.listInstances).toHaveBeenCalledTimes(3));
+ expect(instanceService.listInstances).toHaveBeenLastCalledWith({ type:
'DIRECT' });
+ confirmSpy.mockRestore();
+ });
+
it('shows vendor tabs in the add instance modal and switches description',
async () => {
const user = userEvent.setup();
renderPage();
diff --git a/web/src/pages/instance/index.tsx b/web/src/pages/instance/index.tsx
index e75828f4..06a18b23 100644
--- a/web/src/pages/instance/index.tsx
+++ b/web/src/pages/instance/index.tsx
@@ -124,6 +124,7 @@ const InstancePage = () => {
const [submitting, setSubmitting] = useState(false);
const requestIdRef = useRef(0);
const mutationInFlightRef = useRef(false);
+ const listQueryRef = useRef<InstanceQuery>({});
useEffect(() => {
const timer = window.setTimeout(() => setDebouncedSearch(search.trim()),
300);
@@ -132,10 +133,7 @@ const InstancePage = () => {
const loadInstances = useCallback(async () => {
const requestId = ++requestIdRef.current;
- const query: InstanceQuery = {
- ...(typeFilter === 'ALL' ? {} : { type: typeFilter }),
- ...(debouncedSearch ? { search: debouncedSearch } : {}),
- };
+ const query = listQueryRef.current;
setLoading(true);
try {
@@ -152,16 +150,20 @@ const InstancePage = () => {
setLoading(false);
}
}
- }, [debouncedSearch, typeFilter]);
+ }, []);
useEffect(() => {
+ listQueryRef.current = {
+ ...(typeFilter === 'ALL' ? {} : { type: typeFilter }),
+ ...(debouncedSearch ? { search: debouncedSearch } : {}),
+ };
const timer = window.setTimeout(() => void loadInstances(), 0);
return () => {
window.clearTimeout(timer);
requestIdRef.current += 1;
};
- }, [loadInstances]);
+ }, [debouncedSearch, loadInstances, typeFilter]);
const cloudVendor = vendor === 'ALIYUN' || vendor === 'TENCENT';
diff --git a/web/src/pages/settings/__tests__/DataSourceTab.test.tsx
b/web/src/pages/settings/__tests__/DataSourceTab.test.tsx
index 9175457d..a377e35e 100644
--- a/web/src/pages/settings/__tests__/DataSourceTab.test.tsx
+++ b/web/src/pages/settings/__tests__/DataSourceTab.test.tsx
@@ -21,6 +21,7 @@ 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 { LangProvider } from '../../../i18n/LangContext';
import { DataSourceTab } from '../index';
vi.mock('../../../api/settings', () => ({
@@ -40,7 +41,6 @@ const sources: DataSource[] = [
type: 'Prometheus',
url: 'http://prometheus:9090',
auth: 'None',
- status: 'healthy',
},
{
key: 'thanos-dr',
@@ -52,6 +52,16 @@ const sources: DataSource[] = [
},
];
+const deferred = <T,>() => {
+ let resolve!: (value: T) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise<T>((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+};
+
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
@@ -74,6 +84,43 @@ describe('DataSourceTab', () => {
vi.mocked(listDataSources).mockResolvedValue(sources);
});
+ it('keeps data source creation disabled until the initial list is ready',
async () => {
+ const initialList = deferred<DataSource[]>();
+ vi.mocked(listDataSources).mockReturnValue(initialList.promise);
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ render(
+ <App>
+ <DataSourceTab />
+ </App>,
+ );
+
+ const addButton = screen.getByRole('button', { name: /添加数据源/ });
+ expect(addButton).toBeDisabled();
+ await user.click(addButton);
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+
+ initialList.resolve(sources);
+
+ await waitFor(() => expect(addButton).toBeEnabled());
+ await user.click(addButton);
+ expect(await screen.findByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('does not report a data source as offline when the backend has not tested
it', async () => {
+ render(
+ <LangProvider>
+ <App>
+ <DataSourceTab />
+ </App>
+ </LangProvider>,
+ );
+
+ await screen.findByText('Prometheus prod');
+
+ expect(screen.getByText('未检测')).toBeInTheDocument();
+ expect(screen.queryByText('离线')).not.toBeInTheDocument();
+ });
+
it('shows connection test loading only on the clicked row', async () => {
let resolveTest: (value: { success: boolean; message: string }) => void =
() => undefined;
vi.mocked(testDataSource).mockReturnValue(
diff --git a/web/src/pages/settings/__tests__/GeneralSettingsTab.test.tsx
b/web/src/pages/settings/__tests__/GeneralSettingsTab.test.tsx
index 50b08a26..df9e39e8 100644
--- a/web/src/pages/settings/__tests__/GeneralSettingsTab.test.tsx
+++ b/web/src/pages/settings/__tests__/GeneralSettingsTab.test.tsx
@@ -83,4 +83,33 @@ describe('GeneralSettingsTab', () => {
expect(await screen.findByDisplayValue('30')).toBeInTheDocument();
expect(screen.getByLabelText('会话超时单位')).toHaveValue('分钟');
});
+
+ it('hides unsupported appearance and notification controls while preserving
their values', async () => {
+ vi.mocked(saveGeneralSettings).mockResolvedValue();
+ render(
+ <App>
+ <GeneralSettingsTab />
+ </App>,
+ );
+
+ const saveButton = await screen.findByRole('button', { name: '保存设置' });
+ expect(screen.queryByText('主题模式')).not.toBeInTheDocument();
+ expect(screen.queryByText('紧凑模式')).not.toBeInTheDocument();
+ expect(screen.queryByText('桌面通知')).not.toBeInTheDocument();
+ expect(screen.queryByText('通知声音')).not.toBeInTheDocument();
+
+ fireEvent.click(saveButton);
+
+ await waitFor(() =>
+ expect(saveGeneralSettings).toHaveBeenCalledWith(
+ expect.objectContaining({
+ theme: 'system',
+ compact: false,
+ desktopNotify: false,
+ notifySound: false,
+ }),
+ ),
+ );
+
+ });
});
diff --git a/web/src/pages/settings/index.tsx b/web/src/pages/settings/index.tsx
index da0573a7..be63e358 100644
--- a/web/src/pages/settings/index.tsx
+++ b/web/src/pages/settings/index.tsx
@@ -27,10 +27,8 @@ import {
InputNumber,
Modal,
Popconfirm,
- Radio,
Select,
Space,
- Switch,
Table,
Tabs,
Tag,
@@ -88,6 +86,10 @@ const DATA_SOURCE_TYPE_OPTIONS = [
];
type DataSourceFormValues = Partial<DataSource>;
+type CompatibilitySettings = Pick<
+ GeneralSettingsUpdate,
+ 'theme' | 'compact' | 'desktopNotify' | 'notifySound'
+>;
const secretFieldNames = ['username', 'password', 'bearerToken'] as const;
const authNeedsSecret = (auth?: string) => auth === 'Basic Auth' || auth ===
'Bearer Token';
@@ -113,6 +115,12 @@ export const GeneralSettingsTab = () => {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const saveInFlightRef = useRef(false);
+ const compatibilitySettingsRef = useRef<CompatibilitySettings>({
+ theme: 'system',
+ compact: false,
+ desktopNotify: false,
+ notifySound: false,
+ });
const [apiKeyConfigured, setApiKeyConfigured] = useState(false);
const clearApiKey = Form.useWatch('clearApiKey', form);
@@ -122,6 +130,12 @@ export const GeneralSettingsTab = () => {
.then((settings) => {
if (!cancelled) {
setApiKeyConfigured(settings.apiKeyConfigured);
+ compatibilitySettingsRef.current = {
+ theme: settings.theme,
+ compact: settings.compact,
+ desktopNotify: settings.desktopNotify,
+ notifySound: settings.notifySound,
+ };
form.setFieldsValue({ ...settings, apiKey: undefined, clearApiKey:
false });
}
})
@@ -142,7 +156,7 @@ export const GeneralSettingsTab = () => {
saveInFlightRef.current = true;
setSaving(true);
try {
- await saveGeneralSettings(values);
+ await saveGeneralSettings({ ...values,
...compatibilitySettingsRef.current });
setApiKeyConfigured(
values.clearApiKey ? false : apiKeyConfigured ||
Boolean(values.apiKey?.trim()),
);
@@ -165,45 +179,6 @@ export const GeneralSettingsTab = () => {
onFinish={handleFinish}
style={{ maxWidth: 800 }}
>
- {/* ── 外观 ── */}
- <Divider orientation="left">
- <Title level={5} style={{ margin: 0 }}>
- 外观
- </Title>
- </Divider>
-
- <Form.Item label="主题模式" name="theme">
- <Radio.Group>
- <Radio value="light">浅色</Radio>
- <Radio value="dark">深色</Radio>
- <Radio value="system">跟随系统</Radio>
- </Radio.Group>
- </Form.Item>
-
- <Form.Item label="紧凑模式" name="compact" valuePropName="checked">
- <Switch />
- </Form.Item>
-
- {/* ── 通知 ── */}
- <Divider orientation="left">
- <Title level={5} style={{ margin: 0 }}>
- 通知
- </Title>
- </Divider>
-
- <Form.Item
- label="桌面通知"
- name="desktopNotify"
- valuePropName="checked"
- extra="启用后将通过浏览器推送告警通知"
- >
- <Switch />
- </Form.Item>
-
- <Form.Item label="通知声音" name="notifySound" valuePropName="checked">
- <Switch />
- </Form.Item>
-
{/* ── 安全 ── */}
<Divider orientation="left">
<Title level={5} style={{ margin: 0 }}>
@@ -286,6 +261,7 @@ export const GeneralSettingsTab = () => {
// ─── Data Source Tab ────────────────────────────────────────────────────────
export const DataSourceTab = () => {
+ const { t } = useLang();
const [dataSources, setDataSources] = useState<DataSource[]>([]);
const [instances, setInstances] = useState<Instance[]>([]);
const [loading, setLoading] = useState(true);
@@ -431,7 +407,12 @@ export const DataSourceTab = () => {
title: '状态',
dataIndex: 'status',
key: 'status',
- render: (s: DataSource['status']) => <StatusBadge status={s as keyof
typeof STATUS_MAP} />,
+ render: (status: DataSource['status']) =>
+ status && STATUS_MAP[status] ? (
+ <StatusBadge status={status} />
+ ) : (
+ <Text type="secondary">{t('settings.dataSourceNotTested')}</Text>
+ ),
},
{
title: '操作',
diff --git a/web/src/pages/studio/BrokerCluster.tsx
b/web/src/pages/studio/BrokerCluster.tsx
index 4c5e8af2..85e29c5d 100644
--- a/web/src/pages/studio/BrokerCluster.tsx
+++ b/web/src/pages/studio/BrokerCluster.tsx
@@ -23,6 +23,7 @@ import { listClusters } from '../../services/clusterService';
import type { ClusterInfo } from '../../api/cluster';
import { listInstances } from '../../services/instanceService';
import type { Instance } from '../../api/instance';
+import { useVisiblePolling } from '../../hooks/useVisiblePolling';
// ─── Types ──────────────────────────────────────────────────────
type NodeStatus = 'running' | 'readonly' | 'maintenance' | 'unknown';
@@ -150,6 +151,7 @@ const BrokerClusterPage = () => {
const [proxyData, setProxyData] = useState<ProxyRecord[]>([]);
const [instances, setInstances] = useState<Instance[]>([]);
const [selectedInstanceId, setSelectedInstanceId] = useState('');
+ const mountedRef = useRef(true);
const loadRequestId = useRef(0);
const { t } = useLang();
const { message } = App.useApp();
@@ -169,16 +171,16 @@ const BrokerClusterPage = () => {
setLoading(true);
try {
const clusters = await listClusters(selectedInstanceId);
- if (requestId !== loadRequestId.current) return;
+ if (!mountedRef.current || requestId !== loadRequestId.current) return;
const mapped = mapClusters(clusters);
setBrokerData(mapped.brokers);
setNameServerData(mapped.nameServers);
setProxyData(mapped.proxies);
} catch {
- if (requestId !== loadRequestId.current) return;
+ if (!mountedRef.current || requestId !== loadRequestId.current) return;
message.error(t('common.refreshFailed'));
} finally {
- if (requestId === loadRequestId.current) {
+ if (mountedRef.current && requestId === loadRequestId.current) {
setLoading(false);
}
}
@@ -203,23 +205,17 @@ const BrokerClusterPage = () => {
}, [clearData, message, t]);
useEffect(() => {
+ mountedRef.current = true;
const requestId = loadRequestId.current;
- // The state updates are performed by the asynchronous cluster API
request, not by this effect itself.
- // eslint-disable-next-line react-hooks/set-state-in-effect
- void loadData();
+ const timeoutId = window.setTimeout(() => void loadData());
return () => {
+ window.clearTimeout(timeoutId);
loadRequestId.current = requestId + 1;
+ mountedRef.current = false;
};
}, [loadData]);
- useEffect(() => {
- if (!autoRefresh) return;
-
- const intervalId = window.setInterval(() => {
- void loadData();
- }, REFRESH_INTERVAL_MS);
- return () => window.clearInterval(intervalId);
- }, [autoRefresh, loadData]);
+ useVisiblePolling(autoRefresh, REFRESH_INTERVAL_MS, loadData);
const renderStatus = (status: string) => {
const config: Record<string, { color: string; label: string }> = {
diff --git a/web/src/pages/studio/GroupManagement.tsx
b/web/src/pages/studio/GroupManagement.tsx
index 69f5e859..792a645f 100644
--- a/web/src/pages/studio/GroupManagement.tsx
+++ b/web/src/pages/studio/GroupManagement.tsx
@@ -39,6 +39,7 @@ import {
getConsumerSubscriptions,
listConsumerGroups,
} from '../../services/consumerService';
+import { useVisiblePolling } from '../../hooks/useVisiblePolling';
// ─── Helpers ────────────────────────────────────────────────────
type GroupStatus = 'running' | 'warning' | 'stopped';
@@ -121,14 +122,7 @@ const GroupManagementPage = () => {
};
}, [loadGroups]);
- useEffect(() => {
- if (!autoRefresh) return;
-
- const intervalId = window.setInterval(() => {
- void loadGroups();
- }, GROUP_REFRESH_INTERVAL_MS);
- return () => window.clearInterval(intervalId);
- }, [autoRefresh, loadGroups]);
+ useVisiblePolling(autoRefresh, GROUP_REFRESH_INTERVAL_MS, loadGroups);
const handleRefresh = useCallback(() => {
void loadGroups();
diff --git a/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
b/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
index 19a325ac..d99c7765 100644
--- a/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
+++ b/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
@@ -150,6 +150,7 @@ describe('BrokerCluster Page', () => {
afterEach(() => {
vi.useRealTimers();
+ vi.restoreAllMocks();
});
it('should render the page title', () => {
@@ -231,24 +232,37 @@ describe('BrokerCluster Page', () => {
expect(screen.queryByText('proxy-a')).not.toBeInTheDocument();
});
- it('polls only while live refresh is enabled', async () => {
- vi.useFakeTimers();
+ it('polls only while live refresh is enabled and the document is visible',
async () => {
+ const visibilityState = vi
+ .spyOn(document, 'visibilityState', 'get')
+ .mockReturnValue('hidden');
renderWithProviders(<BrokerCluster />);
- await act(async () => {
- await vi.advanceTimersByTimeAsync(0);
- });
+ await screen.findByText('broker-api-a');
expect(listClusters).toHaveBeenCalledTimes(1);
+ vi.useFakeTimers();
const liveRefreshSwitch = screen.getByRole('switch');
fireEvent.click(liveRefreshSwitch);
await act(async () => {
await vi.advanceTimersByTimeAsync(6000);
});
+ expect(listClusters).toHaveBeenCalledTimes(1);
+
+ visibilityState.mockReturnValue('visible');
+ await act(async () => {
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
+ expect(listClusters).toHaveBeenCalledTimes(2);
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(4000);
+ });
expect(listClusters).toHaveBeenCalledTimes(4);
fireEvent.click(liveRefreshSwitch);
await act(async () => {
+ document.dispatchEvent(new Event('visibilitychange'));
await vi.advanceTimersByTimeAsync(4000);
});
expect(listClusters).toHaveBeenCalledTimes(4);
diff --git a/web/src/pages/studio/__tests__/GroupManagement.test.tsx
b/web/src/pages/studio/__tests__/GroupManagement.test.tsx
index 27068102..52e90e71 100644
--- a/web/src/pages/studio/__tests__/GroupManagement.test.tsx
+++ b/web/src/pages/studio/__tests__/GroupManagement.test.tsx
@@ -102,6 +102,7 @@ describe('GroupManagement Page', () => {
afterEach(() => {
vi.useRealTimers();
+ vi.restoreAllMocks();
});
it('should render the page title', () => {
@@ -218,27 +219,40 @@ describe('GroupManagement Page', () => {
expect(screen.queryByText('initial-group')).not.toBeInTheDocument();
});
- it('polls only while auto refresh is enabled', async () => {
- vi.useFakeTimers();
+ it('polls only while auto refresh is enabled and the document is visible',
async () => {
+ const visibilityState = vi
+ .spyOn(document, 'visibilityState', 'get')
+ .mockReturnValue('hidden');
renderWithProviders(<GroupManagement />);
- await act(async () => {
- await vi.advanceTimersByTimeAsync(0);
- });
+ await screen.findByText('order-consumer-group');
expect(consumerService.listConsumerGroups).toHaveBeenCalledTimes(1);
+ vi.useFakeTimers();
const autoRefreshSwitch = screen.getByRole('switch');
fireEvent.click(autoRefreshSwitch);
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
+ expect(consumerService.listConsumerGroups).toHaveBeenCalledTimes(1);
+
+ visibilityState.mockReturnValue('visible');
+ await act(async () => {
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
expect(consumerService.listConsumerGroups).toHaveBeenCalledTimes(2);
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(2000);
+ });
+ expect(consumerService.listConsumerGroups).toHaveBeenCalledTimes(3);
+
fireEvent.click(autoRefreshSwitch);
await act(async () => {
+ document.dispatchEvent(new Event('visibilitychange'));
await vi.advanceTimersByTimeAsync(4000);
});
- expect(consumerService.listConsumerGroups).toHaveBeenCalledTimes(2);
+ expect(consumerService.listConsumerGroups).toHaveBeenCalledTimes(3);
});
it('should filter groups by search text', async () => {
diff --git a/web/src/services/resourcePlanService.test.ts
b/web/src/services/resourcePlanService.test.ts
index 1a2fdfe2..877a5a99 100644
--- a/web/src/services/resourcePlanService.test.ts
+++ b/web/src/services/resourcePlanService.test.ts
@@ -115,4 +115,33 @@ describe('resource plan service', () => {
{ field: 'writeQueues', currentValue: '16', desiredValue: '32' },
]);
});
+
+ it('marks malformed subscribedTopics as invalid without aborting the
preview', async () => {
+ topicServiceMocks.listTopics.mockResolvedValue([]);
+ consumerServiceMocks.listConsumerGroups.mockResolvedValue([existingGroup]);
+
+ const bundle = parseResourceBundle(`{
+ "consumerGroups": [
+ {"name": "cg-new", "subscribedTopics": "order-create"},
+ {"name": "cg-order-notify", "subscribedTopics": ["order-create", 42]}
+ ]
+ }`);
+ const plan = await previewResourcePlan({ instanceId: 'instance-proxy-1',
...bundle });
+
+ expect(plan.summary).toMatchObject({ total: 2, invalids: 2, applicable: 0
});
+ expect(plan.entries).toEqual([
+ expect.objectContaining({
+ name: 'cg-new',
+ action: 'INVALID',
+ applicable: false,
+ reason: 'Consumer group subscribedTopics must be an array of strings',
+ }),
+ expect.objectContaining({
+ name: 'cg-order-notify',
+ action: 'INVALID',
+ applicable: false,
+ reason: 'Consumer group subscribedTopics must be an array of strings',
+ }),
+ ]);
+ });
});
diff --git a/web/src/services/resourcePlanService.ts
b/web/src/services/resourcePlanService.ts
index 92904b49..d4c346be 100644
--- a/web/src/services/resourcePlanService.ts
+++ b/web/src/services/resourcePlanService.ts
@@ -271,6 +271,14 @@ function planConsumerGroupEntries(
'Consumer group delaySeconds must be zero or positive',
);
}
+ if (!isStringArrayOrUndefined(group.subscribedTopics)) {
+ return invalidEntry(
+ 'CONSUMER_GROUP',
+ name,
+ index,
+ 'Consumer group subscribedTopics must be an array of strings',
+ );
+ }
const existing = existingGroups.get(name);
if (!existing) {
@@ -393,3 +401,10 @@ function sortedTopics(topics?: string[]): string |
undefined {
.sort()
.join(',');
}
+
+function isStringArrayOrUndefined(value: unknown): value is string[] |
undefined {
+ return (
+ value === undefined ||
+ (Array.isArray(value) && value.every((item) => typeof item === 'string'))
+ );
+}