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;

Reply via email to