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 ec3abcf1b feat(alert): filter notification deliveries by time and text 
(#5034)
ec3abcf1b is described below

commit ec3abcf1b47a2789e089ff30e480ecc4d2011d7b
Author: zmuxuny <[email protected]>
AuthorDate: Thu Oct 1 02:27:40 2026 -0700

    feat(alert): filter notification deliveries by time and text (#5034)
    
    * feat(alert): filter notification deliveries by time and text
    
    * test(alert): isolate delivery filters and assert UTC bounds
---
 docs/studio-native-alerting-design.md              |  2 +
 .../ops/alert/NotificationOutboxService.java       | 15 ++--
 .../studio/ops/alert/SystemAlertController.java    |  6 +-
 .../mapper/RmqAlertNotificationOutboxMapper.java   | 15 +++-
 .../NotificationOutboxMapperIntegrationTest.java   | 76 +++++++++++++++++++-
 .../ops/alert/NotificationOutboxServiceTest.java   | 31 ++++++--
 .../ops/alert/SystemAlertControllerTest.java       | 24 ++++++-
 web/src/api/ops.ts                                 |  3 +
 web/src/i18n/translations.ts                       |  4 ++
 .../__tests__/NotificationDeliveriesPage.test.tsx  | 82 ++++++++++++++++++++++
 web/src/pages/ops/notificationDeliveries.tsx       | 46 +++++++++++-
 11 files changed, 286 insertions(+), 18 deletions(-)

diff --git a/docs/studio-native-alerting-design.md 
b/docs/studio-native-alerting-design.md
index 344714231..7caf50d4f 100644
--- a/docs/studio-native-alerting-design.md
+++ b/docs/studio-native-alerting-design.md
@@ -275,6 +275,8 @@ Retries use bounded exponential backoff. Channel 
configuration is encrypted at r
 
 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`.
 
+The Notification Deliveries page can narrow the feed by channel, status, 
instance, alert title or delivery error, and delivery creation time. Its time 
picker uses the operator's local time and sends UTC bounds to `GET 
/api/system-alerts/deliveries/page`; `from` and `to` are inclusive. Text search 
is case-insensitive and treats `%` and `_` as literal characters. The same 
filters determine both the rows and the pagination total.
+
 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/ops/alert/NotificationOutboxService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxService.java
index 4de59f2a7..c46fdb112 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
@@ -209,18 +209,25 @@ public class NotificationOutboxService {
     }
 
     public PageResult<NotificationDeliveryPageVO> listDeliveries(String 
channel, String status, String instanceId,
-            int page, int pageSize) {
+            String search, LocalDateTime from, LocalDateTime to, int page, int 
pageSize) {
+        if (from != null && to != null && from.isAfter(to)) {
+            throw new 
org.apache.rocketmq.studio.common.exception.BusinessException(400,
+                    "Delivery start time must not be after end time");
+        }
         int safePage = Math.max(1, page);
         int safePageSize = Math.min(100, Math.max(1, pageSize));
         String normalizedChannel = normalizeFilter(channel);
         String normalizedStatus = normalizeStatus(status);
         String normalizedInstanceId = normalizeTrim(instanceId);
-        long total = mapper.countPage(normalizedChannel, normalizedStatus, 
normalizedInstanceId);
+        String normalizedSearch = normalizeTrim(search);
+        long total = mapper.countPage(normalizedChannel, normalizedStatus, 
normalizedInstanceId,
+                normalizedSearch, from, to);
         if (total == 0) {
             return PageResult.empty(safePage, safePageSize);
         }
-        return PageResult.of(mapper.findPage(normalizedChannel, 
normalizedStatus, normalizedInstanceId, safePageSize,
-                (long) (safePage - 1) * safePageSize), total, safePage, 
safePageSize);
+        return PageResult.of(mapper.findPage(normalizedChannel, 
normalizedStatus, normalizedInstanceId,
+                normalizedSearch, from, to, safePageSize, (long) (safePage - 
1) * safePageSize),
+                total, safePage, safePageSize);
     }
 
     public void retryFailedDelivery(Long deliveryId) {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertController.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertController.java
index 3e5db8f73..48386dbbc 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertController.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertController.java
@@ -82,9 +82,13 @@ public class SystemAlertController {
             @RequestParam(required = false) String channel,
             @RequestParam(required = false) String status,
             @RequestParam(required = false) String instanceId,
+            @RequestParam(required = false) String search,
+            @RequestParam(required = false) LocalDateTime from,
+            @RequestParam(required = false) LocalDateTime to,
             @RequestParam(defaultValue = "1") int page,
             @RequestParam(defaultValue = "20") int pageSize) {
-        return Result.ok(notificationOutboxService.listDeliveries(channel, 
status, instanceId, page, pageSize));
+        return Result.ok(notificationOutboxService.listDeliveries(channel, 
status, instanceId,
+                search, from, to, page, pageSize));
     }
 
     @PostMapping("/deliveries/{deliveryId}/retry")
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 0642a6d0c..6243da06c 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
@@ -61,10 +61,16 @@ public interface RmqAlertNotificationOutboxMapper extends 
BaseMapper<RmqAlertNot
             + "<if test='channel != null and channel != \"\"'> AND o.channel = 
#{channel}</if>"
             + "<if test='status != null and status != \"\"'> AND o.status = 
#{status}</if>"
             + "<if test='instanceId != null and instanceId != \"\"'> AND 
a.instance_id = #{instanceId}</if>"
+            + "<if test='search != null'> AND (LOCATE(LOWER(#{search}), 
LOWER(a.title)) > 0 "
+            + "OR LOCATE(LOWER(#{search}), LOWER(o.last_error)) > 0)</if>"
+            + "<if test='from != null'> AND o.gmt_create &gt;= #{from}</if>"
+            + "<if test='to != null'> AND o.gmt_create &lt;= #{to}</if>"
             + "</where> ORDER BY o.id DESC LIMIT #{limit} OFFSET #{offset}"
             + "</script>")
     List<NotificationDeliveryPageVO> findPage(@Param("channel") String 
channel, @Param("status") String status,
-            @Param("instanceId") String instanceId, @Param("limit") int limit, 
@Param("offset") long offset);
+            @Param("instanceId") String instanceId, @Param("search") String 
search,
+            @Param("from") LocalDateTime from, @Param("to") LocalDateTime to,
+            @Param("limit") int limit, @Param("offset") long offset);
 
     @Select("<script>"
             + "SELECT COUNT(*) FROM rmq_alert_notification_outbox o "
@@ -73,8 +79,13 @@ public interface RmqAlertNotificationOutboxMapper extends 
BaseMapper<RmqAlertNot
             + "<if test='channel != null and channel != \"\"'> AND o.channel = 
#{channel}</if>"
             + "<if test='status != null and status != \"\"'> AND o.status = 
#{status}</if>"
             + "<if test='instanceId != null and instanceId != \"\"'> AND 
a.instance_id = #{instanceId}</if>"
+            + "<if test='search != null'> AND (LOCATE(LOWER(#{search}), 
LOWER(a.title)) > 0 "
+            + "OR LOCATE(LOWER(#{search}), LOWER(o.last_error)) > 0)</if>"
+            + "<if test='from != null'> AND o.gmt_create &gt;= #{from}</if>"
+            + "<if test='to != null'> AND o.gmt_create &lt;= #{to}</if>"
             + "</where>"
             + "</script>")
     long countPage(@Param("channel") String channel, @Param("status") String 
status,
-            @Param("instanceId") String instanceId);
+            @Param("instanceId") String instanceId, @Param("search") String 
search,
+            @Param("from") LocalDateTime from, @Param("to") LocalDateTime to);
 }
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
index 1eaed8c7b..776ff92bf 100644
--- 
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
@@ -18,13 +18,19 @@ 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.entity.RmqSystemAlert;
 import 
org.apache.rocketmq.studio.persistence.mapper.RmqAlertNotificationOutboxMapper;
+import org.apache.rocketmq.studio.persistence.mapper.RmqSystemAlertMapper;
 import org.junit.jupiter.api.Test;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.jdbc.core.JdbcTemplate;
 
 import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
 import java.util.List;
+import java.util.UUID;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -34,10 +40,76 @@ import static org.assertj.core.api.Assertions.assertThat;
 })
 class NotificationOutboxMapperIntegrationTest {
     private static final long ALERT_ID_BASE = 2748000L;
+    private static final DateTimeFormatter MYSQL_DATE_TIME = 
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
 
     @Autowired
     private RmqAlertNotificationOutboxMapper mapper;
 
+    @Autowired
+    private RmqSystemAlertMapper alertMapper;
+
+    @Autowired
+    private JdbcTemplate jdbcTemplate;
+
+    @Test
+    void deliveryPageFiltersSearchAndCreationTimeWithConsistentCountTest() {
+        List<Long> alertIds = new ArrayList<>();
+        String instanceId = "delivery-filter-test-" + UUID.randomUUID();
+        LocalDateTime start = LocalDateTime.of(2026, 9, 1, 14, 0);
+        LocalDateTime end = start.plusHours(1);
+        try {
+            alertIds.add(insertAlert("Broker DISK % warning", instanceId));
+            alertIds.add(insertAlert("Broker unavailable", instanceId));
+            alertIds.add(insertAlert("Another broker unavailable", 
instanceId));
+            Long titleMatch = insertDeliveryAt(alertIds.get(0), "email", null, 
start);
+            Long errorMatch = insertDeliveryAt(alertIds.get(1), "email", 
"Webhook timeout_ABC", end);
+            insertDeliveryAt(alertIds.get(2), "email", "Webhook after the 
incident", end.plusSeconds(1));
+
+            assertThat(mapper.countPage("email", null, instanceId, null, null, 
null)).isEqualTo(3);
+            assertThat(mapper.countPage("email", null, instanceId, "broker", 
null, null)).isEqualTo(3);
+            assertThat(mapper.countPage("email", null, instanceId, null, 
start, end)).isEqualTo(2);
+            assertThat(mapper.countPage("email", null, instanceId, "broker", 
start, end)).isEqualTo(2);
+            assertThat(mapper.findPage("email", null, instanceId, "broker", 
start, end, 1, 0))
+                    
.extracting(NotificationDeliveryPageVO::getId).containsExactly(errorMatch);
+            assertThat(mapper.findPage("email", null, instanceId, "broker", 
start, end, 1, 1))
+                    
.extracting(NotificationDeliveryPageVO::getId).containsExactly(titleMatch);
+
+            assertThat(mapper.countPage(null, null, instanceId, "webhook", 
start, end)).isEqualTo(1);
+            assertThat(mapper.findPage(null, null, instanceId, "WEBHOOK", 
start, end, 10, 0))
+                    
.extracting(NotificationDeliveryPageVO::getId).containsExactly(errorMatch);
+            assertThat(mapper.countPage(null, null, instanceId, "%", null, 
null)).isEqualTo(1);
+            assertThat(mapper.countPage(null, null, instanceId, "_ABC", null, 
null)).isEqualTo(1);
+            assertThat(mapper.countPage(null, null, instanceId, "webhook", 
end.plusSeconds(1), null)).isEqualTo(1);
+        } finally {
+            cleanup(alertIds);
+            if (!alertIds.isEmpty()) {
+                alertMapper.delete(new QueryWrapper<RmqSystemAlert>().in("id", 
alertIds));
+            }
+        }
+    }
+
+    private Long insertAlert(String title, String instanceId) {
+        RmqSystemAlert alert = new RmqSystemAlert();
+        alert.setTitle(title);
+        alert.setInstanceId(instanceId);
+        alertMapper.insert(alert);
+        return alert.getId();
+    }
+
+    private Long insertDeliveryAt(Long alertId, String channel, String error, 
LocalDateTime createdAt) {
+        RmqAlertNotificationOutbox row = new RmqAlertNotificationOutbox();
+        row.setAlertId(alertId);
+        row.setChannel(channel);
+        row.setStatus(NotificationOutboxStatus.FAILED.name());
+        row.setAttemptCount(1);
+        row.setNextAttemptAt(createdAt);
+        row.setLastError(error);
+        mapper.insert(row);
+        jdbcTemplate.update("UPDATE rmq_alert_notification_outbox SET 
gmt_create = ? WHERE id = ?",
+                MYSQL_DATE_TIME.format(createdAt), row.getId());
+        return row.getId();
+    }
+
     @Test
     void 
deleteTerminalBeforeShouldDeleteOnlyExpiredDeliveredAndFailedRowsTest() {
         LocalDateTime now = LocalDateTime.now();
@@ -99,6 +171,8 @@ class NotificationOutboxMapperIntegrationTest {
     }
 
     private void cleanup(List<Long> alertIds) {
-        mapper.delete(new 
QueryWrapper<RmqAlertNotificationOutbox>().in("alert_id", alertIds));
+        if (!alertIds.isEmpty()) {
+            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 e0f120bcd..80feb2864 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
@@ -48,6 +48,7 @@ import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.springframework.test.web.client.ExpectedCount.once;
@@ -488,12 +489,16 @@ class NotificationOutboxServiceTest {
         RmqAlertNotificationOutboxMapper mapper = 
mock(RmqAlertNotificationOutboxMapper.class);
         NotificationDeliveryPageVO delivery = 
NotificationDeliveryPageVO.builder().id(8L).alertId(9L)
                 
.channel("dingtalk").status(NotificationOutboxStatus.DELIVERED).attemptCount(0).build();
-        when(mapper.countPage("dingtalk", "DELIVERED", 
"Local")).thenReturn(1L);
-        when(mapper.findPage("dingtalk", "DELIVERED", "Local", 20, 
0)).thenReturn(List.of(delivery));
+        LocalDateTime from = LocalDateTime.of(2026, 9, 1, 14, 0);
+        LocalDateTime to = from.plusHours(1);
+        when(mapper.countPage("dingtalk", "DELIVERED", "Local", "webhook", 
from, to)).thenReturn(1L);
+        when(mapper.findPage("dingtalk", "DELIVERED", "Local", "webhook", 
from, to, 20, 0))
+                .thenReturn(List.of(delivery));
 
         PageResult<NotificationDeliveryPageVO> result = new 
NotificationOutboxService(mapper,
                 mock(SettingsRepository.class), 
mock(AlertSilenceService.class), mock(AlertRepository.class),
-                mock(OperationAuditService.class)).listDeliveries(" DingTalk 
", "delivered", "Local", 1, 20);
+                mock(OperationAuditService.class)).listDeliveries(" DingTalk 
", "delivered", "Local",
+                "  webhook  ", from, to, 1, 20);
 
         assertThat(result.getTotal()).isEqualTo(1);
         assertThat(result.getItems()).containsExactly(delivery);
@@ -505,18 +510,32 @@ class NotificationOutboxServiceTest {
         try {
             Locale.setDefault(Locale.forLanguageTag("tr-TR"));
             RmqAlertNotificationOutboxMapper mapper = 
mock(RmqAlertNotificationOutboxMapper.class);
-            when(mapper.countPage("dingtalk", "PENDING", 
"Local")).thenReturn(0L);
+            when(mapper.countPage("dingtalk", "PENDING", "Local", null, null, 
null)).thenReturn(0L);
 
             new NotificationOutboxService(mapper, 
mock(SettingsRepository.class), mock(AlertSilenceService.class),
                     mock(AlertRepository.class), 
mock(OperationAuditService.class))
-                    .listDeliveries(" DINGTALK ", "pending", "Local", 1, 20);
+                    .listDeliveries(" DINGTALK ", "pending", "Local", null, 
null, null, 1, 20);
 
-            verify(mapper).countPage("dingtalk", "PENDING", "Local");
+            verify(mapper).countPage("dingtalk", "PENDING", "Local", null, 
null, null);
         } finally {
             Locale.setDefault(previous);
         }
     }
 
+    @Test
+    void rejectsInvertedDeliveryTimeRangeBeforeQueryingTest() {
+        RmqAlertNotificationOutboxMapper mapper = 
mock(RmqAlertNotificationOutboxMapper.class);
+        LocalDateTime from = LocalDateTime.of(2026, 9, 1, 15, 0);
+
+        org.assertj.core.api.Assertions.assertThatThrownBy(() -> new 
NotificationOutboxService(mapper,
+                mock(SettingsRepository.class), 
mock(AlertSilenceService.class), mock(AlertRepository.class),
+                mock(OperationAuditService.class)).listDeliveries(null, null, 
null, null,
+                from, from.minusHours(1), 1, 20))
+                
.isInstanceOf(org.apache.rocketmq.studio.common.exception.BusinessException.class)
+                .hasMessage("Delivery start time must not be after end time");
+        verifyNoInteractions(mapper);
+    }
+
     @Test
     void retriesOnlyFailedDeliveryAndResetsItsDispatchStateTest() {
         RmqAlertNotificationOutboxMapper mapper = 
mock(RmqAlertNotificationOutboxMapper.class);
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/SystemAlertControllerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/SystemAlertControllerTest.java
index f8f48ba1a..b8c8db1ad 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/SystemAlertControllerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/SystemAlertControllerTest.java
@@ -120,15 +120,20 @@ class SystemAlertControllerTest extends 
WebMvcAuthTestSupport {
 
     @Test
     void listDeliveriesPageShouldForwardFiltersAndPagingTest() throws 
Exception {
+        LocalDateTime from = LocalDateTime.of(2026, 8, 23, 14, 0);
+        LocalDateTime to = from.plusHours(1);
         NotificationDeliveryPageVO delivery = 
NotificationDeliveryPageVO.builder().id(8L).alertId(9L)
                 
.channel("dingtalk").status(NotificationOutboxStatus.DELIVERED).attemptCount(0)
                 .alertTitle("Disk usage high").instanceId("local")
                 .messageContent("[info] Disk usage high").build();
-        when(notificationOutboxService.listDeliveries("dingtalk", "DELIVERED", 
"local", 2, 10))
+        when(notificationOutboxService.listDeliveries("dingtalk", "DELIVERED", 
"local",
+                "disk", from, to, 2, 10))
                 .thenReturn(PageResult.of(List.of(delivery), 11, 2, 10));
 
         
mockMvc.perform(get("/api/system-alerts/deliveries/page").param("channel", 
"dingtalk")
                         .param("status", "DELIVERED").param("instanceId", 
"local")
+                        .param("search", "disk").param("from", 
"2026-08-23T14:00")
+                        .param("to", "2026-08-23T15:00")
                         .param("page", "2").param("pageSize", "10"))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.total").value(11))
@@ -136,7 +141,22 @@ class SystemAlertControllerTest extends 
WebMvcAuthTestSupport {
                 .andExpect(jsonPath("$.data.items[0].alertTitle").value("Disk 
usage high"))
                 
.andExpect(jsonPath("$.data.items[0].messageContent").value("[info] Disk usage 
high"));
 
-        verify(notificationOutboxService).listDeliveries("dingtalk", 
"DELIVERED", "local", 2, 10);
+        verify(notificationOutboxService).listDeliveries("dingtalk", 
"DELIVERED", "local",
+                "disk", from, to, 2, 10);
+    }
+
+    @Test
+    void listDeliveriesPageShouldReportInvertedTimeRangeAsBadRequestTest() 
throws Exception {
+        LocalDateTime from = LocalDateTime.of(2026, 8, 23, 15, 0);
+        LocalDateTime to = from.minusHours(1);
+        when(notificationOutboxService.listDeliveries(null, null, null, null, 
from, to, 1, 20))
+                .thenThrow(new 
org.apache.rocketmq.studio.common.exception.BusinessException(400,
+                        "Delivery start time must not be after end time"));
+
+        mockMvc.perform(get("/api/system-alerts/deliveries/page")
+                        .param("from", "2026-08-23T15:00").param("to", 
"2026-08-23T14:00"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.message").value("Delivery start time 
must not be after end time"));
     }
 
     @Test
diff --git a/web/src/api/ops.ts b/web/src/api/ops.ts
index a8cd5eb73..df6205a2a 100644
--- a/web/src/api/ops.ts
+++ b/web/src/api/ops.ts
@@ -141,6 +141,9 @@ export interface NotificationDeliveryQuery {
   channel?: string;
   status?: NotificationDelivery['status'];
   instanceId?: string;
+  search?: string;
+  from?: string;
+  to?: string;
   page?: number;
   pageSize?: number;
 }
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index b65365d2e..bcc0edb39 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -1193,6 +1193,10 @@ const translations: Record<string, Record<Lang, string>> 
= {
   'deliveries.allChannels': { zh: '全部通道', en: 'All channels' },
   'deliveries.allStatuses': { zh: '全部状态', en: 'All statuses' },
   'deliveries.allInstances': { zh: '全部实例', en: 'All instances' },
+  'deliveries.search': { zh: '搜索告警标题或失败原因', en: 'Search alert title or error' 
},
+  'deliveries.timeRange': { zh: '投递时间范围', en: 'Delivery time range' },
+  'deliveries.startTime': { zh: '投递开始时间', en: 'Delivery start time' },
+  'deliveries.endTime': { zh: '投递结束时间', en: 'Delivery end time' },
   'deliveries.loadFailed': {
     zh: '告警投递记录加载失败,请稍后重试',
     en: 'Failed to load alert deliveries. Please try again later.',
diff --git a/web/src/pages/ops/__tests__/NotificationDeliveriesPage.test.tsx 
b/web/src/pages/ops/__tests__/NotificationDeliveriesPage.test.tsx
index 2dab3893c..adff33d95 100644
--- a/web/src/pages/ops/__tests__/NotificationDeliveriesPage.test.tsx
+++ b/web/src/pages/ops/__tests__/NotificationDeliveriesPage.test.tsx
@@ -195,4 +195,86 @@ describe('NotificationDeliveriesPage', () => {
       expect(screen.queryByRole('button', { name: /^重\s*试$/ 
})).not.toBeInTheDocument(),
     );
   });
+
+  it('searches alert titles and errors and returns to page one when the filter 
changes', async () => {
+    vi.mocked(listAlertDeliveriesPage).mockImplementation(async (query) => ({
+      items: [
+        {
+          id: 7,
+          alertId: 3,
+          alertTitle: 'Broker disk usage',
+          channel: 'dingtalk',
+          status: 'FAILED',
+          attemptCount: 5,
+          createdAt: '2026-08-23T10:00:00',
+          lastError: 'Webhook rejected the request',
+        },
+      ],
+      total: 25,
+      page: query?.page ?? 1,
+      size: 20,
+    }));
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    render(
+      <App>
+        <LangProvider>
+          <NotificationDeliveriesPage />
+        </LangProvider>
+      </App>,
+    );
+
+    await screen.findByText('Broker disk usage');
+    const secondPage = document.querySelector('.ant-pagination-item-2') as 
HTMLElement;
+    await user.click(secondPage);
+    await waitFor(() =>
+      
expect(listAlertDeliveriesPage).toHaveBeenLastCalledWith(expect.objectContaining({
 page: 2 })),
+    );
+
+    await user.type(screen.getByPlaceholderText('搜索告警标题或失败原因'), 
'WebHook{Enter}');
+    await waitFor(() =>
+      expect(listAlertDeliveriesPage).toHaveBeenLastCalledWith(
+        expect.objectContaining({ search: 'WebHook', page: 1 }),
+      ),
+    );
+
+    await user.clear(screen.getByPlaceholderText('搜索告警标题或失败原因'));
+    await waitFor(() =>
+      expect(listAlertDeliveriesPage).toHaveBeenLastCalledWith(
+        expect.objectContaining({ search: undefined, page: 1 }),
+      ),
+    );
+  });
+
+  it('forwards the selected local delivery time range as UTC bounds', async () 
=> {
+    vi.stubEnv('TZ', 'Asia/Shanghai');
+    try {
+      const user = userEvent.setup({ pointerEventsCheck: 0 });
+      render(
+        <App>
+          <LangProvider>
+            <NotificationDeliveriesPage />
+          </LangProvider>
+        </App>,
+      );
+
+      await screen.findByText('Broker disk usage');
+      const inputs = screen.getAllByLabelText('投递时间范围');
+      await user.type(inputs[0], '2026-09-01 14:00:00');
+      await user.keyboard('{Enter}');
+      await user.type(inputs[1], '2026-09-01 15:00:00');
+      await user.keyboard('{Enter}');
+
+      await waitFor(() =>
+        expect(listAlertDeliveriesPage).toHaveBeenLastCalledWith(
+          expect.objectContaining({
+            from: '2026-09-01T06:00:00.000',
+            to: '2026-09-01T07:00:00.000',
+            page: 1,
+          }),
+        ),
+      );
+    } finally {
+      vi.unstubAllEnvs();
+    }
+  });
 });
diff --git a/web/src/pages/ops/notificationDeliveries.tsx 
b/web/src/pages/ops/notificationDeliveries.tsx
index 31150cd40..7bc3e1d07 100644
--- a/web/src/pages/ops/notificationDeliveries.tsx
+++ b/web/src/pages/ops/notificationDeliveries.tsx
@@ -8,9 +8,11 @@ import { useEffect, useRef, useState } from 'react';
 import {
   Button,
   Card,
+  DatePicker,
   Descriptions,
   Drawer,
   Flex,
+  Input,
   Select,
   Table,
   Tag,
@@ -52,6 +54,9 @@ const NotificationDeliveriesPage = () => {
   const [channel, setChannel] = useState<string>();
   const [status, setStatus] = useState<NotificationDeliveryRecord['status']>();
   const [instanceId, setInstanceId] = useState<string>();
+  const [searchDraft, setSearchDraft] = useState('');
+  const [search, setSearch] = useState<string>();
+  const [timeRange, setTimeRange] = useState<{ from?: string; to?: string 
}>({});
   const [instanceLoadFailed, setInstanceLoadFailed] = useState(false);
   const [instanceReloadNonce, setInstanceReloadNonce] = useState(0);
   const [selectedDelivery, setSelectedDelivery] = 
useState<NotificationDeliveryRecord>();
@@ -136,7 +141,7 @@ const NotificationDeliveriesPage = () => {
 
   useEffect(() => {
     let cancelled = false;
-    void listAlertDeliveriesPage({ channel, status, instanceId, page, pageSize 
})
+    void listAlertDeliveriesPage({ channel, status, instanceId, search, 
...timeRange, page, pageSize })
       .then((result) => {
         if (cancelled) return;
         setItems(result.items);
@@ -151,7 +156,7 @@ const NotificationDeliveriesPage = () => {
     return () => {
       cancelled = true;
     };
-  }, [channel, status, instanceId, page, pageSize, refreshNonce, t]);
+  }, [channel, status, instanceId, search, timeRange, page, pageSize, 
refreshNonce, t]);
 
   const resetPage = (change: () => void) => {
     setLoading(true);
@@ -159,6 +164,11 @@ const NotificationDeliveriesPage = () => {
     setPage(1);
   };
 
+  const applySearch = (value: string) => {
+    const normalized = value.trim() || undefined;
+    if (normalized !== search) resetPage(() => setSearch(normalized));
+  };
+
   const columns: ColumnsType<NotificationDeliveryRecord> = [
     {
       title: t('deliveries.alert'),
@@ -285,6 +295,38 @@ const NotificationDeliveriesPage = () => {
               }))}
               onChange={(value) => resetPage(() => setInstanceId(value))}
             />
+            <Input.Search
+              aria-label={t('deliveries.search')}
+              placeholder={t('deliveries.search')}
+              allowClear
+              value={searchDraft}
+              style={{ width: 280, flex: '1 1 280px' }}
+              onChange={(event) => {
+                setSearchDraft(event.target.value);
+                if (!event.target.value) applySearch('');
+              }}
+              onBlur={() => applySearch(searchDraft)}
+              onSearch={applySearch}
+            />
+            <DatePicker.RangePicker
+              aria-label={t('deliveries.timeRange')}
+              showTime
+              format="YYYY-MM-DD HH:mm:ss"
+              placeholder={[t('deliveries.startTime'), 
t('deliveries.endTime')]}
+              style={{ width: 380, flex: '1 1 380px' }}
+              onChange={(dates) =>
+                resetPage(() =>
+                  setTimeRange(
+                    dates?.[0] && dates[1]
+                      ? {
+                          from: dates[0].toDate().toISOString().replace('Z', 
''),
+                          to: dates[1].toDate().toISOString().replace('Z', ''),
+                        }
+                      : {},
+                  ),
+                )
+              }
+            />
             {instanceLoadFailed && (
               <Tooltip title={t('deliveries.instancesLoadFailed')}>
                 <Button

Reply via email to