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 d65a47654 perf(alert): consolidate alert storage scalability (#2964)
d65a47654 is described below
commit d65a476546bb527034ef2eddb39b871ae6b29561
Author: aias00 <[email protected]>
AuthorDate: Wed Sep 2 15:13:41 2026 +0800
perf(alert): consolidate alert storage scalability (#2964)
* Bound alert notification retention
Terminal notification deliveries need independent retention so long-running
Studio instances do not accumulate every delivered or failed outbox row
forever. Add configurable duration and bounded cleanup batches while preserving
pending, retry, and sending work.
Constraint: cleanup must not remove non-terminal rows even when they are old
Rejected: clearing rows only when alerts are acknowledged | delivered and
failed deliveries can grow even when alert events remain available
Tested: JAVA_HOME=$(/usr/libexec/java_home -v 21) mvn
-Dtest='AlertSchemaMigrationTest,NotificationOutboxMapperIntegrationTest,NotificationOutboxServiceTest'
test
Tested: JAVA_HOME=$(/usr/libexec/java_home -v 21) mvn test
Confidence: high
Scope-risk: narrow
Signed-off-by: liuhy <[email protected]>
(cherry picked from commit 5c5eec7f360feed4bdcf9e6bb72cdc4eb81e45c6)
* Improve native alert snapshot aggregation indexing
Native alert aggregation reads metric snapshots through scope equality
filters before a collected_at range. Add separate clustered and global lookup
indexes so both query shapes can use equality columns before the range, while
retaining the collected_at retention index.
Constraint: keep existing migration idempotent for databases created before
native alerting indexes were added
Rejected: single index with cluster_id before availability | global rules
without cluster_id would lose the later equality and range path
Confidence: high
Scope-risk: narrow
Tested: mvn -q -Dtest=AlertSchemaMigrationTest,DemoDataSqlCompatibilityTest
test; mvn -q -DskipTests compile; MySQL 8 schema.sql execution and
INFORMATION_SCHEMA index-order check
Signed-off-by: liuhy <[email protected]>
(cherry picked from commit 2187c84084ab733313f73fa1786a22b45977397d)
* Bound alert silence queries
Use paginated inventory reads and SQL-scoped active silence candidates so
notification delivery no longer scans every historical silence before label
matching. Wire the maintenance-window modal to the paged endpoint while
preserving the legacy full-list API for compatibility.
Use an expiry-focused silence index on (ends_at, starts_at); scope
filtering remains in SQL but is not represented as a misleading range-tail
index.
Constraint: Keep wildcard rule/domain/instance and label matching semantics
unchanged.
Confidence: high
Scope-risk: moderate
Tested: JAVA_HOME=$(/usr/libexec/java_home -v 21) mvn
-Dtest=AlertSilenceServiceTest,MybatisPlusAlertSilenceRepositoryTest,AlertSilenceControllerTest,AlertSchemaMigrationTest
test
Tested: JAVA_HOME=$(/usr/libexec/java_home -v 21) mvn
-Dtest=AlertSilenceControllerTest,MybatisPlusAlertRepositoryTest,AlertRuleControllerTest,AlertServiceDefaultRulesTest,AlertRuleDurationTest,AlertNotificationSuppressionServiceTest,AlertRuleAssetControllerTest,MybatisPlusAlertSilenceRepositoryTest,ClusterAlertRuleControllerTest,AlertStateMachineTest,AlertFingerprintTest,NativeAlertProcessorTest,AlertRuleTransferServiceTest,AlertRuleEvaluatorTest,AlertRuleAssetServiceTest,AlertSchema
[...]
Tested: npm test -- src/api/ops.test.ts
src/pages/ops/__tests__/SystemAlertsPage.test.tsx
Tested: npm run build
Signed-off-by: liuhy <[email protected]>
(cherry picked from commit 40cf519a8c2bbe3d5838dfcac9c04e3683e1d6cf)
* Guard optional schema column migrations
Alert schema migration can run against partial legacy databases used by
focused tests and incremental deployments. Skip column additions when the
target non-alert table does not exist instead of failing the whole native
alerting migration.
Constraint: keep native alert table creation idempotent while preserving
existing table upgrades
Rejected: creating every legacy non-alert table from AlertSchemaMigration |
that would broaden ownership beyond alert schema compatibility
Tested: JAVA_HOME=$(/usr/libexec/java_home -v 21) mvn
-Dtest='AlertSchemaMigrationTest,NotificationOutboxMapperIntegrationTest,NotificationOutboxServiceTest,AlertSilenceServiceTest,MybatisPlusAlertSilenceRepositoryTest,AlertSilenceControllerTest'
test
Confidence: high
Scope-risk: narrow
Signed-off-by: liuhy <[email protected]>
* test(alert): isolate outbox retention mapper coverage
Signed-off-by: liuhy <[email protected]>
---------
Signed-off-by: liuhy <[email protected]>
---
docs/studio-native-alerting-design.md | 2 +
.../studio/cluster/metrics/AlertingProperties.java | 6 ++
.../studio/ops/alert/AlertSchemaMigration.java | 20 ++++
.../studio/ops/alert/AlertSilenceController.java | 8 ++
.../studio/ops/alert/AlertSilenceRepository.java | 7 ++
.../studio/ops/alert/AlertSilenceService.java | 19 +++-
.../alert/MybatisPlusAlertSilenceRepository.java | 33 +++++++
.../ops/alert/NotificationOutboxService.java | 60 +++++++++++-
.../mapper/RmqAlertNotificationOutboxMapper.java | 8 ++
server/src/main/resources/application.yml | 4 +
server/src/main/resources/db/schema.sql | 7 +-
.../studio/ops/alert/AlertSchemaMigrationTest.java | 88 ++++++++++++++++-
.../ops/alert/AlertSilenceControllerTest.java | 71 ++++++++++++++
.../studio/ops/alert/AlertSilenceServiceTest.java | 49 ++++++++-
.../MybatisPlusAlertSilenceRepositoryTest.java | 109 +++++++++++++++++++++
.../NotificationOutboxMapperIntegrationTest.java | 104 ++++++++++++++++++++
.../ops/alert/NotificationOutboxServiceTest.java | 105 ++++++++++++++++++++
web/src/api/ops.test.ts | 11 +++
web/src/api/ops.ts | 12 +++
.../pages/ops/__tests__/SystemAlertsPage.test.tsx | 94 ++++++++++++++++--
web/src/pages/ops/systemAlerts.tsx | 33 +++++--
web/src/services/opsService.ts | 14 +++
22 files changed, 838 insertions(+), 26 deletions(-)
diff --git a/docs/studio-native-alerting-design.md
b/docs/studio-native-alerting-design.md
index 92f826b18..9d8d2afc2 100644
--- a/docs/studio-native-alerting-design.md
+++ b/docs/studio-native-alerting-design.md
@@ -264,6 +264,8 @@ PENDING -> SENDING -> DELIVERED
Retries use bounded exponential backoff. Channel configuration is encrypted at
rest and only write-only secrets are returned by APIs. A test-send action uses
the same sender implementation but does not create an alert event.
+Terminal delivery rows are retained for
`studio.alerting.notification-retention` (`P30D` by default). The scheduled
cleanup only removes `DELIVERED` and `FAILED` rows older than the retention
cutoff, and it runs with bounded batches using
`studio.alerting.notification-cleanup-batch-size` and
`studio.alerting.notification-cleanup-max-batches`.
+
Silences match `domain`, rule ID, instance ID, and optional resource labels.
They suppress delivery but do not hide active state from the Alert Events page.
## APIs
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/AlertingProperties.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/AlertingProperties.java
index b75dd0f26..763e048fa 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/AlertingProperties.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/AlertingProperties.java
@@ -34,4 +34,10 @@ public class AlertingProperties {
private String collectionLeaseDuration = "PT1M";
/** Retain short-lived diagnostic samples without allowing the snapshot
table to grow indefinitely. */
private String snapshotRetention = "PT24H";
+ /** Retain terminal notification deliveries before deleting them from the
outbox. */
+ private String notificationRetention = "P30D";
+ /** Maximum number of terminal notification deliveries deleted per cleanup
batch. */
+ private int notificationCleanupBatchSize = 500;
+ /** Maximum number of cleanup batches executed during one scheduled pass.
*/
+ private int notificationCleanupMaxBatches = 10;
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSchemaMigration.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSchemaMigration.java
index b27eb1255..9a773bb9b 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSchemaMigration.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSchemaMigration.java
@@ -98,16 +98,32 @@ public class AlertSchemaMigration implements
ApplicationRunner {
new Column("rmq_system_alert", "suppression_cause_alert_id",
"BIGINT"),
new Column("rmq_system_alert", "suppression_reason",
"VARCHAR(512)"),
new Column("rmq_system_alert", "labels_json", "TEXT"),
+ new Column("rmq_alert_notification_outbox", "gmt_create",
+ "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"),
+ new Column("rmq_alert_notification_outbox", "gmt_modified",
+ "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE
CURRENT_TIMESTAMP"),
+ new Column("rmq_alert_notification_outbox", "attempt_count", "INT
NOT NULL DEFAULT 0"),
+ new Column("rmq_alert_notification_outbox", "last_error",
"VARCHAR(1000)"),
+ new Column("rmq_alert_notification_outbox", "delivered_at",
"DATETIME"),
new Column("rmq_alert_notification_outbox", "sending_started_at",
"DATETIME"),
new Column("rmq_alert_notification_outbox", "claim_token",
"VARCHAR(64)"),
new Column("rmq_alert_notification_outbox", "message_content",
"TEXT"),
new Column("rmq_instance_message", "result_snapshot",
"MEDIUMTEXT"));
private static final List<Index> INDEXES = List.of(
new Index("rmq_metric_snapshot", "idx_metric_snapshot_lookup",
"instance_id, metric_key, collected_at"),
+ new Index("rmq_metric_snapshot",
"idx_metric_snapshot_scope_cluster",
+ "instance_id, metric_key, domain, labels_hash, cluster_id,
availability, collected_at"),
+ new Index("rmq_metric_snapshot",
"idx_metric_snapshot_scope_global",
+ "instance_id, metric_key, domain, labels_hash,
availability, collected_at"),
new Index("rmq_metric_snapshot", "idx_metric_snapshot_retention",
"collected_at"),
new Index("rmq_alert_silence", "idx_alert_silence_active",
"starts_at, ends_at"),
+ new Index("rmq_alert_silence", "idx_alert_silence_expiry",
"ends_at, starts_at"),
new Index("rmq_alert_silence", "idx_alert_silence_scope", "domain,
rule_id, instance_id"),
new Index("rmq_alert_notification_outbox",
"idx_alert_notification_ready", "status, next_attempt_at"),
+ new Index("rmq_alert_notification_outbox",
"idx_alert_notification_delivered_retention",
+ "status, delivered_at"),
+ new Index("rmq_alert_notification_outbox",
"idx_alert_notification_modified_retention",
+ "status, gmt_modified"),
new Index("rmq_alert_rule", "uk_alert_rule_semantic_fingerprint",
"semantic_fingerprint", true),
new Index("rmq_system_alert", "idx_system_alert_domain_time",
"domain, time"),
new Index("rmq_system_alert", "idx_system_alert_feed", "domain,
instance_id, transition, time"));
@@ -147,6 +163,10 @@ public class AlertSchemaMigration implements
ApplicationRunner {
private static void ensureColumn(DatabaseMetaData metadata, String
catalog, Statement statement, Column column)
throws Exception {
+ if (!hasTable(metadata, catalog, column.table())) {
+ log.debug("Skipping native alerting column {}.{} because table is
missing", column.table(), column.name());
+ return;
+ }
if (hasColumn(metadata, catalog, column.table(), column.name())) {
return;
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceController.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceController.java
index 09db215f6..2d58dcfbe 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceController.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceController.java
@@ -18,6 +18,7 @@ package org.apache.rocketmq.studio.ops.alert;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
+import org.apache.rocketmq.studio.common.domain.PageResult;
import org.apache.rocketmq.studio.common.domain.Result;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
@@ -25,6 +26,7 @@ import org.springframework.web.bind.annotation.PathVariable;
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;
@@ -40,6 +42,12 @@ public class AlertSilenceController {
return Result.ok(silenceService.list());
}
+ @GetMapping("/page")
+ public Result<PageResult<AlertSilenceVO>>
listPage(@RequestParam(defaultValue = "1") int page,
+ @RequestParam(defaultValue = "20") int pageSize) {
+ return Result.ok(silenceService.listPage(page, pageSize));
+ }
+
@PostMapping
public Result<AlertSilenceVO> create(@Valid @RequestBody
CreateAlertSilenceDTO request) {
return Result.ok(silenceService.create(request));
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceRepository.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceRepository.java
index e1a9b45f7..649cd2275 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceRepository.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceRepository.java
@@ -16,6 +16,9 @@
*/
package org.apache.rocketmq.studio.ops.alert;
+import org.apache.rocketmq.studio.common.domain.PageResult;
+
+import java.time.LocalDateTime;
import java.util.List;
public interface AlertSilenceRepository {
@@ -23,5 +26,9 @@ public interface AlertSilenceRepository {
List<AlertSilenceVO> findAll();
+ PageResult<AlertSilenceVO> findPage(int page, int pageSize);
+
+ List<AlertSilenceVO> findActiveCandidates(AlertDomain domain, Long ruleId,
String instanceId, LocalDateTime now);
+
boolean deleteById(Long id);
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceService.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceService.java
index 78bb07f3b..45a36b226 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceService.java
@@ -19,6 +19,7 @@ package org.apache.rocketmq.studio.ops.alert;
import lombok.RequiredArgsConstructor;
import org.apache.rocketmq.studio.audit.OperationAuditService;
import org.apache.rocketmq.studio.auth.AuthenticatedUserContext;
+import org.apache.rocketmq.studio.common.domain.PageResult;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.springframework.stereotype.Service;
@@ -31,6 +32,8 @@ import java.util.Map;
@Service
@RequiredArgsConstructor
public class AlertSilenceService {
+ private static final int MAX_PAGE_SIZE = 100;
+
private final AlertSilenceRepository repository;
private final OperationAuditService operationAuditService;
@@ -38,6 +41,11 @@ public class AlertSilenceService {
return repository.findAll();
}
+ public PageResult<AlertSilenceVO> listPage(int page, int pageSize) {
+ validatePagination(page, pageSize);
+ return repository.findPage(page, pageSize);
+ }
+
public AlertSilenceVO create(CreateAlertSilenceDTO request) {
if (request == null || request.getStartsAt() == null ||
request.getEndsAt() == null) {
throw new BusinessException(400, "Silence start and end times are
required");
@@ -87,12 +95,21 @@ public class AlertSilenceService {
public LocalDateTime activeUntil(AlertRuleVO rule, String instanceId,
Map<String, String> labels,
LocalDateTime now) {
AlertDomain domain = rule.getDomain() == null ? AlertDomain.BUSINESS :
rule.getDomain();
- return repository.findAll().stream()
+ return repository.findActiveCandidates(domain, rule.getId(),
instanceId, now).stream()
.filter(silence -> matches(silence, rule.getId(), domain,
instanceId,
labels == null ? Map.of() : labels, now))
.map(AlertSilenceVO::getEndsAt).max(LocalDateTime::compareTo).orElse(null);
}
+ private static void validatePagination(int page, int pageSize) {
+ if (page < 1) {
+ throw new BusinessException(400, "page must be positive");
+ }
+ if (pageSize < 1 || pageSize > MAX_PAGE_SIZE) {
+ throw new BusinessException(400, "pageSize must be between 1 and "
+ MAX_PAGE_SIZE);
+ }
+ }
+
private static boolean matches(AlertSilenceVO silence, Long ruleId,
AlertDomain domain, String instanceId,
Map<String, String> labels, LocalDateTime now) {
return !now.isBefore(silence.getStartsAt()) &&
now.isBefore(silence.getEndsAt())
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertSilenceRepository.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertSilenceRepository.java
index 18c0284d8..5d2676510 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertSilenceRepository.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertSilenceRepository.java
@@ -17,13 +17,17 @@
package org.apache.rocketmq.studio.ops.alert;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
+import org.apache.rocketmq.studio.common.domain.PageResult;
import org.apache.rocketmq.studio.persistence.entity.RmqAlertSilence;
import org.apache.rocketmq.studio.persistence.mapper.RmqAlertSilenceMapper;
import org.springframework.stereotype.Repository;
+import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
@@ -49,6 +53,35 @@ public class MybatisPlusAlertSilenceRepository implements
AlertSilenceRepository
.stream().map(this::toVo).toList();
}
+ @Override
+ public PageResult<AlertSilenceVO> findPage(int page, int pageSize) {
+ IPage<RmqAlertSilence> mapperPage = mapper.selectPage(new Page<>(page,
pageSize),
+ new
QueryWrapper<RmqAlertSilence>().orderByDesc("ends_at").orderByDesc("id"));
+ return
PageResult.of(mapperPage.getRecords().stream().map(this::toVo).toList(),
mapperPage.getTotal(),
+ (int) mapperPage.getCurrent(), (int) mapperPage.getSize());
+ }
+
+ @Override
+ public List<AlertSilenceVO> findActiveCandidates(AlertDomain domain, Long
ruleId, String instanceId,
+ LocalDateTime now) {
+ QueryWrapper<RmqAlertSilence> query = new
QueryWrapper<RmqAlertSilence>()
+ .le("starts_at", now)
+ .gt("ends_at", now)
+ .and(scope -> scope.isNull("domain").or().eq("domain",
domain.name()));
+ if (ruleId == null) {
+ query.isNull("rule_id");
+ } else {
+ query.and(scope -> scope.isNull("rule_id").or().eq("rule_id",
ruleId));
+ }
+ if (instanceId == null) {
+ query.isNull("instance_id");
+ } else {
+ query.and(scope ->
scope.isNull("instance_id").or().eq("instance_id", instanceId));
+ }
+ query.orderByDesc("ends_at").orderByDesc("id");
+ return mapper.selectList(query).stream().map(this::toVo).toList();
+ }
+
@Override
public boolean deleteById(Long id) {
return mapper.deleteById(id) > 0;
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxService.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxService.java
index 8a76f8e08..fce7b9336 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxService.java
@@ -13,6 +13,7 @@ import
com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.studio.audit.OperationAuditService;
+import org.apache.rocketmq.studio.cluster.metrics.AlertingProperties;
import org.apache.rocketmq.studio.common.domain.PageResult;
import
org.apache.rocketmq.studio.common.util.NoRedirectClientHttpRequestFactory;
import org.apache.rocketmq.studio.common.util.UrlHostGuard;
@@ -65,33 +66,50 @@ public class NotificationOutboxService {
private final OperationAuditService operationAuditService;
private final RestTemplate restTemplate;
private final Supplier<JavaMailSender> mailSender;
+ private final AlertingProperties alertingProperties;
NotificationOutboxService(RmqAlertNotificationOutboxMapper mapper,
SettingsRepository settingsRepository,
AlertSilenceService silenceService, AlertRepository
alertRepository,
OperationAuditService operationAuditService) {
this(mapper, settingsRepository, silenceService, alertRepository,
operationAuditService, newClient(),
- () -> null);
+ () -> null, new AlertingProperties());
}
@Autowired
public NotificationOutboxService(RmqAlertNotificationOutboxMapper mapper,
SettingsRepository settingsRepository,
AlertSilenceService silenceService, AlertRepository
alertRepository,
- OperationAuditService operationAuditService,
ObjectProvider<JavaMailSender> mailSender) {
+ OperationAuditService operationAuditService,
ObjectProvider<JavaMailSender> mailSender,
+ AlertingProperties alertingProperties) {
this(mapper, settingsRepository, silenceService, alertRepository,
operationAuditService, newClient(),
- mailSender::getIfAvailable);
+ mailSender::getIfAvailable, alertingProperties);
}
NotificationOutboxService(RmqAlertNotificationOutboxMapper mapper,
SettingsRepository settingsRepository,
AlertSilenceService silenceService, AlertRepository
alertRepository,
OperationAuditService operationAuditService, RestTemplate
restTemplate) {
this(mapper, settingsRepository, silenceService, alertRepository,
operationAuditService, restTemplate,
- () -> null);
+ () -> null, new AlertingProperties());
+ }
+
+ NotificationOutboxService(RmqAlertNotificationOutboxMapper mapper,
SettingsRepository settingsRepository,
+ AlertSilenceService silenceService, AlertRepository
alertRepository,
+ OperationAuditService operationAuditService, AlertingProperties
alertingProperties) {
+ this(mapper, settingsRepository, silenceService, alertRepository,
operationAuditService, newClient(),
+ () -> null, alertingProperties);
}
NotificationOutboxService(RmqAlertNotificationOutboxMapper mapper,
SettingsRepository settingsRepository,
AlertSilenceService silenceService, AlertRepository
alertRepository,
OperationAuditService operationAuditService, RestTemplate
restTemplate,
Supplier<JavaMailSender> mailSender) {
+ this(mapper, settingsRepository, silenceService, alertRepository,
operationAuditService, restTemplate,
+ mailSender, new AlertingProperties());
+ }
+
+ NotificationOutboxService(RmqAlertNotificationOutboxMapper mapper,
SettingsRepository settingsRepository,
+ AlertSilenceService silenceService, AlertRepository
alertRepository,
+ OperationAuditService operationAuditService, RestTemplate
restTemplate,
+ Supplier<JavaMailSender> mailSender, AlertingProperties
alertingProperties) {
this.mapper = mapper;
this.settingsRepository = settingsRepository;
this.silenceService = silenceService;
@@ -99,6 +117,7 @@ public class NotificationOutboxService {
this.operationAuditService = operationAuditService;
this.restTemplate = restTemplate;
this.mailSender = mailSender;
+ this.alertingProperties = alertingProperties == null ? new
AlertingProperties() : alertingProperties;
}
public void enqueue(SystemAlertVO alert, AlertRuleVO rule) {
@@ -253,6 +272,39 @@ public class NotificationOutboxService {
}
}
+ @Scheduled(fixedDelayString =
"${studio.alerting.notification-cleanup-interval:PT1H}")
+ public int cleanupTerminalDeliveries() {
+ Duration retention;
+ try {
+ retention =
Duration.parse(alertingProperties.getNotificationRetention());
+ } catch (RuntimeException error) {
+ log.warn("Skipping alert notification cleanup because retention is
invalid: {}",
+ alertingProperties.getNotificationRetention());
+ return 0;
+ }
+ if (retention.isZero() || retention.isNegative()) {
+ return 0;
+ }
+ int batchSize = Math.max(1,
alertingProperties.getNotificationCleanupBatchSize());
+ int maxBatches = Math.max(1,
alertingProperties.getNotificationCleanupMaxBatches());
+ LocalDateTime cutoff = utcNow().minus(retention);
+ int total = 0;
+ for (int batch = 0; batch < maxBatches; batch++) {
+ int deleted;
+ try {
+ deleted = mapper.deleteTerminalBefore(cutoff, batchSize);
+ } catch (RuntimeException error) {
+ log.warn("Stopped alert notification cleanup after deleting {}
rows", total, error);
+ return total;
+ }
+ total += deleted;
+ if (deleted < batchSize) {
+ break;
+ }
+ }
+ return total;
+ }
+
private void send(RmqAlertNotificationOutbox row, LocalDateTime now,
String claimToken) {
try {
SystemAlertVO alert = loadAlert(row.getAlertId());
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertNotificationOutboxMapper.java
b/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertNotificationOutboxMapper.java
index 5ade98502..33a1af646 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertNotificationOutboxMapper.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertNotificationOutboxMapper.java
@@ -22,6 +22,14 @@ public interface RmqAlertNotificationOutboxMapper extends
BaseMapper<RmqAlertNot
+ "(SELECT id FROM rmq_system_alert WHERE acknowledged = 1)")
int deleteForAcknowledgedAlerts();
+ @Delete("DELETE FROM rmq_alert_notification_outbox WHERE id IN ("
+ + "SELECT id FROM (SELECT id FROM rmq_alert_notification_outbox
WHERE "
+ + "(status = 'DELIVERED' AND ((delivered_at IS NOT NULL AND
delivered_at < #{cutoff}) "
+ + "OR (delivered_at IS NULL AND gmt_modified < #{cutoff}))) "
+ + "OR (status = 'FAILED' AND gmt_modified < #{cutoff}) "
+ + "ORDER BY id LIMIT #{limit}) expired_terminal_deliveries)")
+ int deleteTerminalBefore(@Param("cutoff") LocalDateTime cutoff,
@Param("limit") int limit);
+
@Select("SELECT * FROM rmq_alert_notification_outbox WHERE "
+ "(status IN ('PENDING', 'RETRY_WAIT') AND next_attempt_at <=
#{now}) "
+ "OR (status = 'SENDING' AND (sending_started_at IS NULL OR
sending_started_at <= #{staleBefore})) "
diff --git a/server/src/main/resources/application.yml
b/server/src/main/resources/application.yml
index 83b662886..235837078 100644
--- a/server/src/main/resources/application.yml
+++ b/server/src/main/resources/application.yml
@@ -88,6 +88,10 @@ studio:
snapshot-retention: ${STUDIO_ALERTING_SNAPSHOT_RETENTION:PT24H}
snapshot-cleanup-interval:
${STUDIO_ALERTING_SNAPSHOT_CLEANUP_INTERVAL:PT1H}
notification-dispatch-interval:
${STUDIO_ALERTING_NOTIFICATION_DISPATCH_INTERVAL:PT10S}
+ notification-retention: ${STUDIO_ALERTING_NOTIFICATION_RETENTION:P30D}
+ notification-cleanup-interval:
${STUDIO_ALERTING_NOTIFICATION_CLEANUP_INTERVAL:PT1H}
+ notification-cleanup-batch-size:
${STUDIO_ALERTING_NOTIFICATION_CLEANUP_BATCH_SIZE:500}
+ notification-cleanup-max-batches:
${STUDIO_ALERTING_NOTIFICATION_CLEANUP_MAX_BATCHES:10}
llm:
token: ${RMQ_LLM_TOKEN:}
anthropic-base-url: ${RMQ_ANTHROPIC_BASE_URL:}
diff --git a/server/src/main/resources/db/schema.sql
b/server/src/main/resources/db/schema.sql
index 7c3fbb30d..d3e73952a 100644
--- a/server/src/main/resources/db/schema.sql
+++ b/server/src/main/resources/db/schema.sql
@@ -291,6 +291,8 @@ CREATE TABLE IF NOT EXISTS rmq_metric_snapshot (
`collected_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
INDEX idx_metric_snapshot_lookup (`instance_id`, `metric_key`,
`collected_at`),
+ INDEX idx_metric_snapshot_scope_cluster (`instance_id`, `metric_key`,
`domain`, `labels_hash`, `cluster_id`, `availability`, `collected_at`),
+ INDEX idx_metric_snapshot_scope_global (`instance_id`, `metric_key`,
`domain`, `labels_hash`, `availability`, `collected_at`),
INDEX idx_metric_snapshot_retention (`collected_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -337,6 +339,7 @@ CREATE TABLE IF NOT EXISTS rmq_alert_silence (
`created_by` VARCHAR(128) NOT NULL,
PRIMARY KEY (`id`),
INDEX idx_alert_silence_active (`starts_at`, `ends_at`),
+ INDEX idx_alert_silence_expiry (`ends_at`, `starts_at`),
INDEX idx_alert_silence_scope (`domain`, `rule_id`, `instance_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -356,7 +359,9 @@ CREATE TABLE IF NOT EXISTS rmq_alert_notification_outbox (
`delivered_at` DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE KEY uk_alert_notification_outbox (`alert_id`, `channel`),
- INDEX idx_alert_notification_ready (`status`, `next_attempt_at`)
+ INDEX idx_alert_notification_ready (`status`, `next_attempt_at`),
+ INDEX idx_alert_notification_delivered_retention (`status`, `delivered_at`),
+ INDEX idx_alert_notification_modified_retention (`status`, `gmt_modified`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 15. 系统告警事件
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertSchemaMigrationTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertSchemaMigrationTest.java
index 5b6a46473..85b994a3b 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertSchemaMigrationTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertSchemaMigrationTest.java
@@ -9,10 +9,17 @@ package org.apache.rocketmq.studio.ops.alert;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.DefaultApplicationArguments;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.jdbc.datasource.init.ScriptUtils;
import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.DriverManager;
import java.sql.ResultSet;
+import java.sql.SQLException;
import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@@ -53,9 +60,86 @@ class AlertSchemaMigrationTest {
try (Connection connection = dataSource.getConnection(); Statement
statement = connection.createStatement();
ResultSet result = statement.executeQuery("SELECT COUNT(*)
FROM information_schema.columns "
+ "WHERE table_name = 'rmq_alert_notification_outbox'
AND column_name IN "
- + "('sending_started_at', 'claim_token',
'message_content')")) {
+ + "('gmt_create', 'gmt_modified', 'attempt_count',
'last_error', 'delivered_at', "
+ + "'sending_started_at', 'claim_token',
'message_content')")) {
result.next();
- assertThat(result.getInt(1)).isEqualTo(3);
+ assertThat(result.getInt(1)).isEqualTo(8);
+ }
+
+ try (Connection connection = dataSource.getConnection(); Statement
statement = connection.createStatement();
+ ResultSet result = statement.executeQuery("SELECT COUNT(*)
FROM information_schema.indexes "
+ + "WHERE table_name = 'rmq_alert_notification_outbox'
AND index_name IN "
+ + "('idx_alert_notification_delivered_retention', "
+ + "'idx_alert_notification_modified_retention')")) {
+ result.next();
+ assertThat(result.getInt(1)).isEqualTo(2);
+ }
+
+ try (Connection connection = dataSource.getConnection(); Statement
statement = connection.createStatement();
+ ResultSet result = statement.executeQuery("SELECT COUNT(*)
FROM information_schema.indexes "
+ + "WHERE table_name = 'rmq_alert_silence' "
+ + "AND index_name = 'idx_alert_silence_expiry'")) {
+ result.next();
+ assertThat(result.getInt(1)).isGreaterThan(0);
+ }
+ }
+
+ @Test
+ void
createsScopeCompleteMetricSnapshotIndexesWhenMigratingExistingTablesTest()
throws Exception {
+ JdbcDataSource dataSource = new JdbcDataSource();
+
dataSource.setURL("jdbc:h2:mem:alert-schema-migration-indexes;MODE=MySQL;DB_CLOSE_DELAY=-1;"
+ + "DATABASE_TO_LOWER=TRUE");
+ dataSource.setUser("sa");
+ try (Connection connection = dataSource.getConnection(); Statement
statement = connection.createStatement()) {
+ statement.execute("CREATE TABLE rmq_alert_rule (id BIGINT PRIMARY
KEY, name VARCHAR(128))");
+ statement.execute("CREATE TABLE rmq_system_alert (id BIGINT
PRIMARY KEY, time TIMESTAMP)");
+ }
+
+ AlertSchemaMigration migration = new AlertSchemaMigration(dataSource);
+ migration.run(new DefaultApplicationArguments());
+ migration.run(new DefaultApplicationArguments());
+
+ try (Connection connection = dataSource.getConnection()) {
+ assertThat(indexColumns(connection, "rmq_metric_snapshot",
"idx_metric_snapshot_scope_cluster"))
+ .containsExactly("instance_id", "metric_key", "domain",
"labels_hash", "cluster_id",
+ "availability", "collected_at");
+ assertThat(indexColumns(connection, "rmq_metric_snapshot",
"idx_metric_snapshot_scope_global"))
+ .containsExactly("instance_id", "metric_key", "domain",
"labels_hash", "availability",
+ "collected_at");
+ assertThat(indexColumns(connection, "rmq_metric_snapshot",
"idx_metric_snapshot_retention"))
+ .containsExactly("collected_at");
+ }
+ }
+
+ @Test
+ void freshSchemaCreatesScopeCompleteMetricSnapshotIndexesTest() throws
Exception {
+ try (Connection connection = DriverManager.getConnection(
+
"jdbc:h2:mem:alert-fresh-schema-indexes;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE",
+ "sa", "")) {
+ ScriptUtils.executeSqlScript(connection, new
ClassPathResource("db/schema.sql"));
+
+ assertThat(indexColumns(connection, "rmq_metric_snapshot",
"idx_metric_snapshot_scope_cluster"))
+ .containsExactly("instance_id", "metric_key", "domain",
"labels_hash", "cluster_id",
+ "availability", "collected_at");
+ assertThat(indexColumns(connection, "rmq_metric_snapshot",
"idx_metric_snapshot_scope_global"))
+ .containsExactly("instance_id", "metric_key", "domain",
"labels_hash", "availability",
+ "collected_at");
+ assertThat(indexColumns(connection, "rmq_metric_snapshot",
"idx_metric_snapshot_retention"))
+ .containsExactly("collected_at");
+ }
+ }
+
+ private static List<String> indexColumns(Connection connection, String
table, String index) throws SQLException {
+ DatabaseMetaData metadata = connection.getMetaData();
+ List<String> columns = new ArrayList<>();
+ try (ResultSet indexes =
metadata.getIndexInfo(connection.getCatalog(), null, table, false, false)) {
+ while (indexes.next()) {
+ String indexName = indexes.getString("INDEX_NAME");
+ if (indexName != null && indexName.equalsIgnoreCase(index)) {
+ columns.add(indexes.getString("COLUMN_NAME"));
+ }
+ }
}
+ return columns;
}
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceControllerTest.java
new file mode 100644
index 000000000..86cb197f9
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceControllerTest.java
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import org.apache.rocketmq.studio.common.domain.PageResult;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import
org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static
org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static
org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(AlertSilenceController.class)
+@AutoConfigureMockMvc(addFilters = false)
+class AlertSilenceControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @MockBean
+ private AlertSilenceService silenceService;
+
+ @Test
+ void listPageShouldReturnPageResultTest() throws Exception {
+ AlertSilenceVO silence = AlertSilenceVO.builder()
+ .id(12L)
+ .domain(AlertDomain.CLUSTER)
+ .startsAt(LocalDateTime.of(2026, 8, 22, 9, 0))
+ .endsAt(LocalDateTime.of(2026, 8, 22, 10, 0))
+ .createdBy("admin")
+ .build();
+ when(silenceService.listPage(2,
10)).thenReturn(PageResult.of(List.of(silence), 31, 2, 10));
+
+ mockMvc.perform(get("/api/alert-silences/page")
+ .param("page", "2")
+ .param("pageSize", "10"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.items[0].id").value(12))
+ .andExpect(jsonPath("$.data.items[0].domain").value("CLUSTER"))
+ .andExpect(jsonPath("$.data.total").value(31))
+ .andExpect(jsonPath("$.data.page").value(2))
+ .andExpect(jsonPath("$.data.size").value(10));
+
+ verify(silenceService).listPage(eq(2), eq(10));
+ }
+}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceServiceTest.java
index ae29d6c10..29f622ff4 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceServiceTest.java
@@ -17,6 +17,7 @@
package org.apache.rocketmq.studio.ops.alert;
import org.apache.rocketmq.studio.audit.OperationAuditService;
+import org.apache.rocketmq.studio.common.domain.PageResult;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -66,7 +67,12 @@ class AlertSilenceServiceTest {
assertThat(captured.getValue().getInstanceId()).isEqualTo("local");
assertThat(captured.getValue().getStartsAt()).isEqualTo(start);
assertThat(created.getId()).isEqualTo(7L);
- when(repository.findAll()).thenReturn(List.of(created));
+ when(repository.findActiveCandidates(AlertDomain.BUSINESS, 3L,
"local", start.plusMinutes(1)))
+ .thenReturn(List.of(created));
+ when(repository.findActiveCandidates(AlertDomain.BUSINESS, 3L,
"other", start.plusMinutes(1)))
+ .thenReturn(List.of());
+ when(repository.findActiveCandidates(AlertDomain.BUSINESS, 3L,
"local", end))
+ .thenReturn(List.of());
AlertRuleVO rule =
AlertRuleVO.builder().id(3L).domain(AlertDomain.BUSINESS).build();
assertThat(service.isActive(rule, "local",
start.plusMinutes(1))).isTrue();
@@ -108,7 +114,7 @@ class AlertSilenceServiceTest {
AlertSilenceVO silence =
AlertSilenceVO.builder().domain(AlertDomain.CLUSTER).ruleId(5L)
.instanceId("local").labels(Map.of("brokerName", "broker-a"))
.startsAt(now.minusMinutes(1)).endsAt(now.plusMinutes(1)).createdBy("admin").build();
- when(repository.findAll()).thenReturn(List.of(silence));
+ when(repository.findActiveCandidates(AlertDomain.CLUSTER, 5L, "local",
now)).thenReturn(List.of(silence));
AlertRuleVO rule =
AlertRuleVO.builder().id(5L).domain(AlertDomain.CLUSTER).build();
assertThat(service.isActive(rule, "local", Map.of("brokerName",
"broker-a", "cluster", "Default"), now))
@@ -116,4 +122,43 @@ class AlertSilenceServiceTest {
assertThat(service.isActive(rule, "local", Map.of("brokerName",
"broker-b"), now)).isFalse();
assertThat(service.isActive(rule, "local", Map.of(), now)).isFalse();
}
+
+ @Test
+ void listPageShouldValidateAndDelegateToRepositoryPaginationTest() {
+ AlertSilenceService service = new AlertSilenceService(repository,
operationAuditService);
+ AlertSilenceVO silence =
AlertSilenceVO.builder().id(11L).reason("deploy").build();
+ when(repository.findPage(2,
25)).thenReturn(PageResult.of(List.of(silence), 51, 2, 25));
+
+ PageResult<AlertSilenceVO> page = service.listPage(2, 25);
+
+ assertThat(page.getTotal()).isEqualTo(51);
+ assertThat(page.getItems()).singleElement()
+ .satisfies(item -> assertThat(item.getId()).isEqualTo(11L));
+ org.mockito.Mockito.verify(repository).findPage(2, 25);
+ org.mockito.Mockito.verify(repository,
org.mockito.Mockito.never()).findAll();
+ }
+
+ @Test
+ void activeUntilShouldQueryScopedActiveCandidatesBeforeLabelMatchingTest()
{
+ AlertSilenceService service = new AlertSilenceService(repository,
operationAuditService);
+ LocalDateTime now = LocalDateTime.of(2026, 8, 22, 10, 0);
+ AlertRuleVO rule =
AlertRuleVO.builder().id(9L).domain(AlertDomain.CLUSTER).build();
+ AlertSilenceVO wrongLabel =
AlertSilenceVO.builder().id(1L).domain(AlertDomain.CLUSTER).ruleId(9L)
+ .instanceId("local").labels(Map.of("brokerName", "broker-b"))
+
.startsAt(now.minusMinutes(5)).endsAt(now.plusMinutes(10)).createdBy("admin").build();
+ AlertSilenceVO firstMatch =
AlertSilenceVO.builder().id(2L).domain(AlertDomain.CLUSTER).ruleId(9L)
+ .instanceId("local").labels(Map.of("brokerName", "broker-a"))
+
.startsAt(now.minusMinutes(5)).endsAt(now.plusMinutes(10)).createdBy("admin").build();
+ AlertSilenceVO overlappingMatch =
AlertSilenceVO.builder().id(3L).domain(AlertDomain.CLUSTER)
+ .labels(Map.of("brokerName", "broker-a"))
+
.startsAt(now.minusMinutes(1)).endsAt(now.plusMinutes(30)).createdBy("admin").build();
+ when(repository.findActiveCandidates(AlertDomain.CLUSTER, 9L, "local",
now))
+ .thenReturn(List.of(wrongLabel, firstMatch, overlappingMatch));
+
+ LocalDateTime activeUntil = service.activeUntil(rule, "local",
Map.of("brokerName", "broker-a"), now);
+
+ assertThat(activeUntil).isEqualTo(now.plusMinutes(30));
+
org.mockito.Mockito.verify(repository).findActiveCandidates(AlertDomain.CLUSTER,
9L, "local", now);
+ org.mockito.Mockito.verify(repository,
org.mockito.Mockito.never()).findAll();
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertSilenceRepositoryTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertSilenceRepositoryTest.java
new file mode 100644
index 000000000..96861057a
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertSilenceRepositoryTest.java
@@ -0,0 +1,109 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.rocketmq.studio.common.domain.PageResult;
+import org.apache.rocketmq.studio.persistence.entity.RmqAlertSilence;
+import org.apache.rocketmq.studio.persistence.mapper.RmqAlertSilenceMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class MybatisPlusAlertSilenceRepositoryTest {
+
+ @Mock
+ private RmqAlertSilenceMapper mapper;
+
+ @Test
+ void findPageShouldUseDatabasePaginationAndStableInventoryOrderingTest() {
+ MybatisPlusAlertSilenceRepository repository = new
MybatisPlusAlertSilenceRepository(
+ mapper, new ObjectMapper());
+ RmqAlertSilence entity = new RmqAlertSilence();
+ entity.setId(9L);
+ entity.setReason("maintenance");
+ Page<RmqAlertSilence> mapperPage = new Page<RmqAlertSilence>(3, 20)
+ .setRecords(List.of(entity))
+ .setTotal(41);
+ when(mapper.selectPage(any(IPage.class),
any(Wrapper.class))).thenReturn(mapperPage);
+
+ PageResult<AlertSilenceVO> result = repository.findPage(3, 20);
+
+ ArgumentCaptor<IPage<RmqAlertSilence>> pageCaptor =
ArgumentCaptor.forClass(IPage.class);
+ ArgumentCaptor<Wrapper<RmqAlertSilence>> queryCaptor =
ArgumentCaptor.forClass(Wrapper.class);
+ verify(mapper).selectPage(pageCaptor.capture(), queryCaptor.capture());
+ assertThat(pageCaptor.getValue().getCurrent()).isEqualTo(3);
+ assertThat(pageCaptor.getValue().getSize()).isEqualTo(20);
+ assertThat(result.getTotal()).isEqualTo(41);
+ assertThat(result.getItems()).singleElement()
+ .satisfies(silence ->
assertThat(silence.getReason()).isEqualTo("maintenance"));
+ QueryWrapper<RmqAlertSilence> query = (QueryWrapper<RmqAlertSilence>)
queryCaptor.getValue();
+ query.getCustomSqlSegment();
+ assertThat(query.getSqlSegment()).contains("ORDER BY ends_at DESC,id
DESC");
+ verify(mapper, never()).selectList(any());
+ }
+
+ @Test
+ void findActiveCandidatesShouldFilterTimeAndWildcardScopeInSqlTest() {
+ MybatisPlusAlertSilenceRepository repository = new
MybatisPlusAlertSilenceRepository(
+ mapper, new ObjectMapper());
+ LocalDateTime now = LocalDateTime.of(2026, 8, 22, 10, 0);
+ RmqAlertSilence entity = new RmqAlertSilence();
+ entity.setId(7L);
+ entity.setDomain(AlertDomain.CLUSTER.name());
+ entity.setRuleId(5L);
+ entity.setInstanceId("local");
+ entity.setStartsAt(now.minusMinutes(1));
+ entity.setEndsAt(now.plusMinutes(1));
+ entity.setCreatedBy("admin");
+
when(mapper.selectList(any(Wrapper.class))).thenReturn(List.of(entity));
+
+ List<AlertSilenceVO> candidates = repository.findActiveCandidates(
+ AlertDomain.CLUSTER, 5L, "local", now);
+
+ assertThat(candidates).singleElement()
+ .satisfies(silence ->
assertThat(silence.getId()).isEqualTo(7L));
+ ArgumentCaptor<Wrapper<RmqAlertSilence>> queryCaptor =
ArgumentCaptor.forClass(Wrapper.class);
+ verify(mapper).selectList(queryCaptor.capture());
+ QueryWrapper<RmqAlertSilence> query = (QueryWrapper<RmqAlertSilence>)
queryCaptor.getValue();
+ query.getCustomSqlSegment();
+ assertThat(query.getSqlSegment())
+ .contains("starts_at", "ends_at", "domain IS NULL", "rule_id
IS NULL", "instance_id IS NULL")
+ .contains("ORDER BY ends_at DESC,id DESC");
+ assertThat(query.getParamNameValuePairs())
+ .containsValue(now)
+ .containsValue(AlertDomain.CLUSTER.name())
+ .containsValue(5L)
+ .containsValue("local");
+ }
+}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxMapperIntegrationTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxMapperIntegrationTest.java
new file mode 100644
index 000000000..1eaed8c7b
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxMapperIntegrationTest.java
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import
org.apache.rocketmq.studio.persistence.entity.RmqAlertNotificationOutbox;
+import
org.apache.rocketmq.studio.persistence.mapper.RmqAlertNotificationOutboxMapper;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest(properties = {
+ "studio.auth.login-required=false",
+ "studio.alerting.notification-retention=P36500D"
+})
+class NotificationOutboxMapperIntegrationTest {
+ private static final long ALERT_ID_BASE = 2748000L;
+
+ @Autowired
+ private RmqAlertNotificationOutboxMapper mapper;
+
+ @Test
+ void
deleteTerminalBeforeShouldDeleteOnlyExpiredDeliveredAndFailedRowsTest() {
+ LocalDateTime now = LocalDateTime.now();
+ LocalDateTime expired = now.minusDays(31);
+ LocalDateTime fresh = now.minusDays(1);
+ List<Long> alertIds = List.of(ALERT_ID_BASE + 1, ALERT_ID_BASE + 2,
ALERT_ID_BASE + 3, ALERT_ID_BASE + 4,
+ ALERT_ID_BASE + 5, ALERT_ID_BASE + 6, ALERT_ID_BASE + 7);
+ cleanup(alertIds);
+ try {
+ insert(alertIds.get(0), "dingtalk",
NotificationOutboxStatus.DELIVERED, expired, expired);
+ insert(alertIds.get(1), "dingtalk",
NotificationOutboxStatus.FAILED, null, expired);
+ insert(alertIds.get(2), "dingtalk",
NotificationOutboxStatus.PENDING, null, expired);
+ insert(alertIds.get(3), "dingtalk",
NotificationOutboxStatus.RETRY_WAIT, null, expired);
+ insert(alertIds.get(4), "dingtalk",
NotificationOutboxStatus.SENDING, null, expired);
+ insert(alertIds.get(5), "dingtalk",
NotificationOutboxStatus.DELIVERED, fresh, fresh);
+ insert(alertIds.get(6), "dingtalk",
NotificationOutboxStatus.DELIVERED, null, expired);
+
+ assertThat(mapper.deleteTerminalBefore(now.minusDays(30),
10)).isEqualTo(3);
+
+ assertThat(mapper.selectList(new
QueryWrapper<RmqAlertNotificationOutbox>()
+ .in("alert_id",
alertIds)).stream().map(RmqAlertNotificationOutbox::getStatus))
+
.containsExactlyInAnyOrder(NotificationOutboxStatus.PENDING.name(),
+ NotificationOutboxStatus.RETRY_WAIT.name(),
NotificationOutboxStatus.SENDING.name(),
+ NotificationOutboxStatus.DELIVERED.name());
+ } finally {
+ cleanup(alertIds);
+ }
+ }
+
+ @Test
+ void deleteTerminalBeforeShouldRespectBatchLimitTest() {
+ LocalDateTime expired = LocalDateTime.now().minusDays(31);
+ List<Long> alertIds = List.of(ALERT_ID_BASE + 101, ALERT_ID_BASE +
102, ALERT_ID_BASE + 103);
+ cleanup(alertIds);
+ try {
+ insert(alertIds.get(0), "email", NotificationOutboxStatus.FAILED,
null, expired);
+ insert(alertIds.get(1), "email", NotificationOutboxStatus.FAILED,
null, expired);
+ insert(alertIds.get(2), "email", NotificationOutboxStatus.FAILED,
null, expired);
+
+
assertThat(mapper.deleteTerminalBefore(LocalDateTime.now().minusDays(30),
2)).isEqualTo(2);
+ assertThat(mapper.selectCount(new
QueryWrapper<RmqAlertNotificationOutbox>()
+ .in("alert_id", alertIds))).isEqualTo(1);
+ } finally {
+ cleanup(alertIds);
+ }
+ }
+
+ private void insert(Long alertId, String channel, NotificationOutboxStatus
status, LocalDateTime deliveredAt,
+ LocalDateTime modifiedAt) {
+ RmqAlertNotificationOutbox row = new RmqAlertNotificationOutbox();
+ row.setAlertId(alertId);
+ row.setChannel(channel);
+ row.setStatus(status.name());
+ row.setAttemptCount(0);
+ row.setNextAttemptAt(LocalDateTime.now());
+ row.setDeliveredAt(deliveredAt);
+ row.setGmtModified(modifiedAt);
+ mapper.insert(row);
+ }
+
+ private void cleanup(List<Long> alertIds) {
+ mapper.delete(new
QueryWrapper<RmqAlertNotificationOutbox>().in("alert_id", alertIds));
+ }
+}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxServiceTest.java
index 87954e859..e99a5da25 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxServiceTest.java
@@ -9,6 +9,7 @@ package org.apache.rocketmq.studio.ops.alert;
import org.apache.rocketmq.studio.common.domain.enums.AlertLevel;
import org.apache.rocketmq.studio.common.domain.PageResult;
import org.apache.rocketmq.studio.audit.OperationAuditService;
+import org.apache.rocketmq.studio.cluster.metrics.AlertingProperties;
import
org.apache.rocketmq.studio.persistence.entity.RmqAlertNotificationOutbox;
import
org.apache.rocketmq.studio.persistence.mapper.RmqAlertNotificationOutboxMapper;
import org.apache.rocketmq.studio.settings.GeneralSettingsVO;
@@ -45,6 +46,110 @@ import static
org.springframework.test.web.client.match.MockRestRequestMatchers.
import static
org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
class NotificationOutboxServiceTest {
+ @Test
+ void cleanupShouldDeleteOnlyTerminalDeliveriesOlderThanRetentionTest() {
+ RmqAlertNotificationOutboxMapper mapper =
mock(RmqAlertNotificationOutboxMapper.class);
+ AlertingProperties properties = new AlertingProperties();
+ properties.setNotificationRetention("PT24H");
+ properties.setNotificationCleanupBatchSize(50);
+ properties.setNotificationCleanupMaxBatches(1);
+ when(mapper.deleteTerminalBefore(any(LocalDateTime.class),
any(Integer.class))).thenReturn(3);
+
+ int deleted = new NotificationOutboxService(mapper,
mock(SettingsRepository.class),
+ mock(AlertSilenceService.class), mock(AlertRepository.class),
mock(OperationAuditService.class),
+ properties).cleanupTerminalDeliveries();
+
+ assertThat(deleted).isEqualTo(3);
+ org.mockito.ArgumentCaptor<LocalDateTime> cutoff =
+ org.mockito.ArgumentCaptor.forClass(LocalDateTime.class);
+ verify(mapper).deleteTerminalBefore(cutoff.capture(),
org.mockito.ArgumentMatchers.eq(50));
+
assertThat(Duration.between(cutoff.getValue().toInstant(ZoneOffset.UTC),
+
java.time.Instant.now().minus(Duration.ofHours(24))).abs()).isLessThan(Duration.ofSeconds(2));
+ }
+
+ @Test
+ void cleanupShouldStopAtTheConfiguredMaximumBatchCountTest() {
+ RmqAlertNotificationOutboxMapper mapper =
mock(RmqAlertNotificationOutboxMapper.class);
+ AlertingProperties properties = new AlertingProperties();
+ properties.setNotificationRetention("PT24H");
+ properties.setNotificationCleanupBatchSize(2);
+ properties.setNotificationCleanupMaxBatches(3);
+ when(mapper.deleteTerminalBefore(any(LocalDateTime.class),
any(Integer.class))).thenReturn(2, 2, 2);
+
+ int deleted = new NotificationOutboxService(mapper,
mock(SettingsRepository.class),
+ mock(AlertSilenceService.class), mock(AlertRepository.class),
mock(OperationAuditService.class),
+ properties).cleanupTerminalDeliveries();
+
+ assertThat(deleted).isEqualTo(6);
+ verify(mapper,
org.mockito.Mockito.times(3)).deleteTerminalBefore(any(LocalDateTime.class),
+ org.mockito.ArgumentMatchers.eq(2));
+ }
+
+ @Test
+ void cleanupShouldStopAfterAPartialBatchTest() {
+ RmqAlertNotificationOutboxMapper mapper =
mock(RmqAlertNotificationOutboxMapper.class);
+ AlertingProperties properties = new AlertingProperties();
+ properties.setNotificationRetention("PT24H");
+ properties.setNotificationCleanupBatchSize(10);
+ properties.setNotificationCleanupMaxBatches(5);
+ when(mapper.deleteTerminalBefore(any(LocalDateTime.class),
any(Integer.class))).thenReturn(10, 4);
+
+ int deleted = new NotificationOutboxService(mapper,
mock(SettingsRepository.class),
+ mock(AlertSilenceService.class), mock(AlertRepository.class),
mock(OperationAuditService.class),
+ properties).cleanupTerminalDeliveries();
+
+ assertThat(deleted).isEqualTo(14);
+ verify(mapper,
org.mockito.Mockito.times(2)).deleteTerminalBefore(any(LocalDateTime.class),
+ org.mockito.ArgumentMatchers.eq(10));
+ }
+
+ @Test
+ void cleanupShouldBeDisabledWhenRetentionIsNonPositiveTest() {
+ RmqAlertNotificationOutboxMapper mapper =
mock(RmqAlertNotificationOutboxMapper.class);
+ AlertingProperties properties = new AlertingProperties();
+ properties.setNotificationRetention("PT0S");
+
+ int deleted = new NotificationOutboxService(mapper,
mock(SettingsRepository.class),
+ mock(AlertSilenceService.class), mock(AlertRepository.class),
mock(OperationAuditService.class),
+ properties).cleanupTerminalDeliveries();
+
+ assertThat(deleted).isZero();
+ verify(mapper, never()).deleteTerminalBefore(any(LocalDateTime.class),
any(Integer.class));
+ }
+
+ @Test
+ void cleanupShouldIsolateInvalidRetentionConfigurationTest() {
+ RmqAlertNotificationOutboxMapper mapper =
mock(RmqAlertNotificationOutboxMapper.class);
+ AlertingProperties properties = new AlertingProperties();
+ properties.setNotificationRetention("30d");
+
+ int deleted = new NotificationOutboxService(mapper,
mock(SettingsRepository.class),
+ mock(AlertSilenceService.class), mock(AlertRepository.class),
mock(OperationAuditService.class),
+ properties).cleanupTerminalDeliveries();
+
+ assertThat(deleted).isZero();
+ verify(mapper, never()).deleteTerminalBefore(any(LocalDateTime.class),
any(Integer.class));
+ }
+
+ @Test
+ void cleanupShouldStopWhenOneBatchFailsTest() {
+ RmqAlertNotificationOutboxMapper mapper =
mock(RmqAlertNotificationOutboxMapper.class);
+ AlertingProperties properties = new AlertingProperties();
+ properties.setNotificationRetention("PT24H");
+ properties.setNotificationCleanupBatchSize(10);
+ properties.setNotificationCleanupMaxBatches(3);
+ when(mapper.deleteTerminalBefore(any(LocalDateTime.class),
any(Integer.class))).thenReturn(10)
+ .thenThrow(new IllegalStateException("database unavailable"));
+
+ int deleted = new NotificationOutboxService(mapper,
mock(SettingsRepository.class),
+ mock(AlertSilenceService.class), mock(AlertRepository.class),
mock(OperationAuditService.class),
+ properties).cleanupTerminalDeliveries();
+
+ assertThat(deleted).isEqualTo(10);
+ verify(mapper,
org.mockito.Mockito.times(2)).deleteTerminalBefore(any(LocalDateTime.class),
+ org.mockito.ArgumentMatchers.eq(10));
+ }
+
@Test
void schedulesOutboxWorkInUtcRegardlessOfTheJvmDefaultTimeZoneTest() {
TimeZone previous = TimeZone.getDefault();
diff --git a/web/src/api/ops.test.ts b/web/src/api/ops.test.ts
index 5150d401b..82b8e3bee 100644
--- a/web/src/api/ops.test.ts
+++ b/web/src/api/ops.test.ts
@@ -44,6 +44,7 @@ import {
clearAcknowledgedAlerts,
listAlertDeliveries,
listAlertSilences,
+ listAlertSilencesPage,
createAlertSilence,
deleteAlertSilence,
listAuditRecords,
@@ -367,9 +368,19 @@ describe('Ops API - System Alerts & Audit', () => {
createdBy: 'admin',
};
mock.onGet('/alert-silences').reply(200, { code: 200, data: [silence] });
+ mock.onGet('/alert-silences/page').reply((config) => {
+ expect(config.params).toEqual({ page: 2, pageSize: 10 });
+ return [200, { code: 200, data: { items: [silence], total: 21, page: 2,
size: 10 } }];
+ });
mock.onPost('/alert-silences').reply(200, { code: 200, data: silence });
mock.onDelete('/alert-silences/2').reply(200, { code: 200 });
await expect(listAlertSilences()).resolves.toEqual([silence]);
+ await expect(listAlertSilencesPage({ page: 2, pageSize: 10
})).resolves.toEqual({
+ items: [silence],
+ total: 21,
+ page: 2,
+ size: 10,
+ });
await expect(createAlertSilence(silence)).resolves.toEqual(silence);
await expect(deleteAlertSilence(2)).resolves.toBeUndefined();
});
diff --git a/web/src/api/ops.ts b/web/src/api/ops.ts
index 6c889ca02..c0e4d9c7c 100644
--- a/web/src/api/ops.ts
+++ b/web/src/api/ops.ts
@@ -145,6 +145,11 @@ export interface NotificationDeliveryQuery {
pageSize?: number;
}
+export interface AlertSilenceQuery {
+ page?: number;
+ pageSize?: number;
+}
+
export interface AlertSilence {
id: number;
domain?: 'BUSINESS' | 'CLUSTER' | null;
@@ -358,6 +363,13 @@ export async function listAlertSilences() {
return res.data.data;
}
+export async function listAlertSilencesPage(params: AlertSilenceQuery = {}) {
+ const res = await client.get<{ data: PageResult<AlertSilence>
}>('/alert-silences/page', {
+ params,
+ });
+ return res.data.data;
+}
+
export async function createAlertSilence(data: CreateAlertSilence) {
const res = await client.post<{ data: AlertSilence }>('/alert-silences',
data);
return res.data.data;
diff --git a/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
b/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
index 9ae4fe41a..2778c7242 100644
--- a/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
+++ b/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
@@ -19,7 +19,9 @@ import {
listAlertDeliveries,
listRelatedSystemAlerts,
retryAlertDelivery,
+ deleteAlertSilence,
listAlertSilences,
+ listAlertSilencesPage,
listSystemAlertsPage,
} from '../../../services/opsService';
import SystemAlertsPage from '../systemAlerts';
@@ -33,6 +35,7 @@ vi.mock('../../../services/opsService', () => ({
listRelatedSystemAlerts: vi.fn().mockResolvedValue([]),
retryAlertDelivery: vi.fn(),
listAlertSilences: vi.fn(),
+ listAlertSilencesPage: vi.fn(),
createAlertSilence: vi.fn(),
deleteAlertSilence: vi.fn(),
}));
@@ -95,6 +98,7 @@ describe('SystemAlertsPage', () => {
size: 20,
});
vi.mocked(listAlertSilences).mockResolvedValue([]);
+ vi.mocked(listAlertSilencesPage).mockResolvedValue({ items: [], total: 0,
page: 1, size: 10 });
});
it('finishes an export when a later page is empty after the result set
shrinks', async () => {
@@ -444,16 +448,21 @@ describe('SystemAlertsPage', () => {
});
it('shows maintenance windows and creates a scoped silence', async () => {
- vi.mocked(listAlertSilences).mockResolvedValue([
- {
- id: 9,
- domain: 'CLUSTER',
- instanceId: 'local',
- startsAt: '2026-08-10T01:00',
- endsAt: '2026-08-10T02:00',
- createdBy: 'admin',
- },
- ]);
+ vi.mocked(listAlertSilencesPage).mockResolvedValue({
+ items: [
+ {
+ id: 9,
+ domain: 'CLUSTER',
+ instanceId: 'local',
+ startsAt: '2026-08-10T01:00',
+ endsAt: '2026-08-10T02:00',
+ createdBy: 'admin',
+ },
+ ],
+ total: 11,
+ page: 1,
+ size: 10,
+ });
vi.mocked(createAlertSilence).mockResolvedValue({
id: 10,
domain: 'BUSINESS',
@@ -466,6 +475,7 @@ describe('SystemAlertsPage', () => {
await user.click(await screen.findByRole('button', { name: '维护窗口' }));
expect(await screen.findByText(/CLUSTER.*local/)).toBeInTheDocument();
+ expect(listAlertSilencesPage).toHaveBeenCalledWith({ page: 1, pageSize: 10
});
await user.type(screen.getByLabelText('规则 ID'), '42');
await user.type(screen.getByLabelText('标签范围'),
'brokerName=broker-a,topic=orders');
@@ -483,6 +493,70 @@ describe('SystemAlertsPage', () => {
endsAt: new Date('2026-08-11T02:00:00').toISOString(),
}),
);
+ expect(listAlertSilencesPage).toHaveBeenLastCalledWith({ page: 1,
pageSize: 10 });
+ });
+ });
+
+ it('loads maintenance windows by page and backs up after deleting the last
page item', async () => {
+ vi.mocked(listAlertSilencesPage)
+ .mockResolvedValueOnce({
+ items: [
+ {
+ id: 9,
+ domain: 'CLUSTER',
+ instanceId: 'local',
+ startsAt: '2026-08-10T01:00',
+ endsAt: '2026-08-10T02:00',
+ createdBy: 'admin',
+ },
+ ],
+ total: 11,
+ page: 1,
+ size: 10,
+ })
+ .mockResolvedValueOnce({
+ items: [
+ {
+ id: 10,
+ domain: 'BUSINESS',
+ instanceId: 'remote',
+ startsAt: '2026-08-11T01:00',
+ endsAt: '2026-08-11T02:00',
+ createdBy: 'admin',
+ },
+ ],
+ total: 11,
+ page: 2,
+ size: 10,
+ })
+ .mockResolvedValueOnce({
+ items: [
+ {
+ id: 9,
+ domain: 'CLUSTER',
+ instanceId: 'local',
+ startsAt: '2026-08-10T01:00',
+ endsAt: '2026-08-10T02:00',
+ createdBy: 'admin',
+ },
+ ],
+ total: 10,
+ page: 1,
+ size: 10,
+ });
+ vi.mocked(deleteAlertSilence).mockResolvedValue();
+ const user = userEvent.setup();
+ renderPage();
+
+ await user.click(await screen.findByRole('button', { name: '维护窗口' }));
+ await user.click(await screen.findByRole('listitem', { name: '2' }));
+ expect(await screen.findByText(/BUSINESS.*remote/)).toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: /结\s*束/ }));
+
+ await waitFor(() => {
+ expect(deleteAlertSilence).toHaveBeenCalledWith(10);
+ expect(listAlertSilencesPage).toHaveBeenLastCalledWith({ page: 1,
pageSize: 10 });
});
});
});
diff --git a/web/src/pages/ops/systemAlerts.tsx
b/web/src/pages/ops/systemAlerts.tsx
index 513b50d81..506ea2b45 100644
--- a/web/src/pages/ops/systemAlerts.tsx
+++ b/web/src/pages/ops/systemAlerts.tsx
@@ -45,7 +45,7 @@ import {
listSystemAlertsPage,
createAlertSilence,
deleteAlertSilence,
- listAlertSilences,
+ listAlertSilencesPage,
} from '../../services/opsService';
import type {
AlertSilence,
@@ -158,8 +158,11 @@ const SystemAlertsPage = () => {
const [silencesVisible, setSilencesVisible] = useState(false);
const [silences, setSilences] = useState<AlertSilence[]>([]);
const [loadingSilences, setLoadingSilences] = useState(false);
+ const [silencePage, setSilencePage] = useState(1);
+ const [silenceTotal, setSilenceTotal] = useState(0);
const [savingSilence, setSavingSilence] = useState(false);
const [deletingSilenceId, setDeletingSilenceId] = useState<number |
null>(null);
+ const silencePageSize = 10;
const [silenceForm] = Form.useForm();
const currentQuery = () => {
@@ -344,10 +347,13 @@ const SystemAlertsPage = () => {
}
};
- const loadSilences = async () => {
+ const loadSilences = async (nextPage = silencePage) => {
setLoadingSilences(true);
try {
- setSilences(await listAlertSilences());
+ const result = await listAlertSilencesPage({ page: nextPage, pageSize:
silencePageSize });
+ setSilences(result.items);
+ setSilenceTotal(result.total);
+ setSilencePage(result.page);
} catch {
message.error(t('sysAlerts.silenceLoadFailed'));
} finally {
@@ -356,8 +362,9 @@ const SystemAlertsPage = () => {
};
const openSilences = () => {
+ setSilencePage(1);
setSilencesVisible(true);
- void loadSilences();
+ void loadSilences(1);
};
const createSilence = async () => {
@@ -388,7 +395,8 @@ const SystemAlertsPage = () => {
};
await createAlertSilence(request);
silenceForm.resetFields();
- await loadSilences();
+ setSilencePage(1);
+ await loadSilences(1);
message.success(t('sysAlerts.silenceCreated'));
} catch {
message.error(t('sysAlerts.silenceCreateFailed'));
@@ -401,7 +409,9 @@ const SystemAlertsPage = () => {
setDeletingSilenceId(id);
try {
await deleteAlertSilence(id);
- await loadSilences();
+ const nextPage = silences.length === 1 && silencePage > 1 ? silencePage
- 1 : silencePage;
+ setSilencePage(nextPage);
+ await loadSilences(nextPage);
message.success(t('sysAlerts.silenceEnded'));
} catch {
message.error(t('sysAlerts.silenceEndFailed'));
@@ -875,6 +885,17 @@ const SystemAlertsPage = () => {
)}
</Flex>
))}
+ {silenceTotal > silencePageSize && (
+ <Pagination
+ size="small"
+ current={silencePage}
+ pageSize={silencePageSize}
+ total={silenceTotal}
+ showSizeChanger={false}
+ style={{ alignSelf: 'flex-end', marginTop: 8 }}
+ onChange={(nextPage) => void loadSilences(nextPage)}
+ />
+ )}
</Flex>
</Spin>
</Modal>
diff --git a/web/src/services/opsService.ts b/web/src/services/opsService.ts
index 91e23544f..da6ef9f6d 100644
--- a/web/src/services/opsService.ts
+++ b/web/src/services/opsService.ts
@@ -21,6 +21,7 @@ import type {
NotificationDeliveryBulkRetryResult,
NotificationDeliveryQuery,
NotificationDeliveryRecord,
+ AlertSilenceQuery,
AlertSilence,
CreateAlertSilence,
} from '../api/ops';
@@ -437,6 +438,19 @@ export async function listAlertSilences():
Promise<AlertSilence[]> {
return opsApi.listAlertSilences();
}
+export async function listAlertSilencesPage(
+ params: AlertSilenceQuery = {},
+): Promise<PageResult<AlertSilence>> {
+ if (!isMockMode()) return opsApi.listAlertSilencesPage(params);
+ const page = params.page ?? 1;
+ const pageSize = params.pageSize ?? 10;
+ const start = (page - 1) * pageSize;
+ const items = alertSilencesState
+ .slice(start, start + pageSize)
+ .map((silence) => ({ ...silence }));
+ return { items, total: alertSilencesState.length, page, size: pageSize };
+}
+
export async function createAlertSilence(data: CreateAlertSilence):
Promise<AlertSilence> {
if (!isMockMode()) return opsApi.createAlertSilence(data);
const silence = { ...data, id: Date.now(), createdBy: 'admin' } as
AlertSilence;