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 89222fb80 [ISSUE #3068] feat(alert): add recurring alert maintenance 
windows (#3070)
89222fb80 is described below

commit 89222fb800365412e97ed5860eb78aff74daa267
Author: shown <[email protected]>
AuthorDate: Fri Sep 4 14:25:04 2026 +0800

    [ISSUE #3068] feat(alert): add recurring alert maintenance windows (#3070)
---
 .../studio/ops/alert/AlertSchemaMigration.java     |   8 ++
 ...tSilenceVO.java => AlertSilenceRecurrence.java} |  26 +----
 .../studio/ops/alert/AlertSilenceSchedule.java     |  93 +++++++++++++++
 .../studio/ops/alert/AlertSilenceService.java      |  86 ++++++++++++--
 .../rocketmq/studio/ops/alert/AlertSilenceVO.java  |   5 +
 .../studio/ops/alert/CreateAlertSilenceDTO.java    |   5 +
 .../alert/MybatisPlusAlertSilenceRepository.java   |  40 ++++++-
 .../studio/persistence/entity/RmqAlertSilence.java |   4 +
 server/src/main/resources/db/schema.sql            |   7 +-
 .../studio/ops/alert/AlertSchemaMigrationTest.java |  14 ++-
 .../ops/alert/AlertSilenceControllerTest.java      |  40 +++++++
 .../studio/ops/alert/AlertSilenceScheduleTest.java | 127 +++++++++++++++++++++
 .../studio/ops/alert/AlertSilenceServiceTest.java  | 110 ++++++++++++++++++
 .../MybatisPlusAlertSilenceRepositoryTest.java     |  52 ++++++++-
 web/src/api/ops.ts                                 |   4 +
 web/src/i18n/translations.ts                       |  32 ++++++
 .../pages/ops/__tests__/SystemAlertsPage.test.tsx  |  50 ++++++++
 web/src/pages/ops/systemAlerts.tsx                 | 101 +++++++++++++++-
 web/src/utils/timeZone.test.ts                     |  59 ++++++++++
 web/src/utils/timeZone.ts                          | 116 +++++++++++++++++++
 20 files changed, 939 insertions(+), 40 deletions(-)

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 b5391432c..b1d0e066d 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
@@ -65,6 +65,8 @@ public class AlertSchemaMigration implements 
ApplicationRunner {
                     + "gmt_modified DATETIME NOT NULL DEFAULT 
CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
                     + "domain VARCHAR(16), rule_id BIGINT, instance_id 
VARCHAR(128), "
                     + "labels_json TEXT, starts_at DATETIME NOT NULL, ends_at 
DATETIME NOT NULL, reason VARCHAR(512), "
+                    + "recurrence VARCHAR(16) NOT NULL DEFAULT 'ONCE', 
time_zone VARCHAR(64), "
+                    + "recurrence_days_json VARCHAR(64), recurrence_until 
DATETIME, "
                     + "created_by VARCHAR(128) NOT NULL)"),
             new Table("rmq_alert_notification_outbox", "CREATE TABLE 
rmq_alert_notification_outbox ("
                     + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
@@ -98,6 +100,10 @@ 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_silence", "recurrence", "VARCHAR(16) NOT 
NULL DEFAULT 'ONCE'"),
+            new Column("rmq_alert_silence", "time_zone", "VARCHAR(64)"),
+            new Column("rmq_alert_silence", "recurrence_days_json", 
"VARCHAR(64)"),
+            new Column("rmq_alert_silence", "recurrence_until", "DATETIME"),
             new Column("rmq_alert_notification_outbox", "gmt_create",
                     "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"),
             new Column("rmq_alert_notification_outbox", "gmt_modified",
@@ -118,6 +124,8 @@ public class AlertSchemaMigration implements 
ApplicationRunner {
             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_silence", "idx_alert_silence_recurrence",
+                    "recurrence, recurrence_until, starts_at"),
             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"),
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceVO.java 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceRecurrence.java
similarity index 61%
copy from 
server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceVO.java
copy to 
server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceRecurrence.java
index a08185866..1dbee7bc4 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceVO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceRecurrence.java
@@ -16,26 +16,8 @@
  */
 package org.apache.rocketmq.studio.ops.alert;
 
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-import lombok.Data;
-import lombok.NoArgsConstructor;
-
-import java.time.LocalDateTime;
-import java.util.Map;
-
-@Data
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-public class AlertSilenceVO {
-    private Long id;
-    private AlertDomain domain;
-    private Long ruleId;
-    private String instanceId;
-    private Map<String, String> labels;
-    private LocalDateTime startsAt;
-    private LocalDateTime endsAt;
-    private String reason;
-    private String createdBy;
+public enum AlertSilenceRecurrence {
+    ONCE,
+    DAILY,
+    WEEKLY
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceSchedule.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceSchedule.java
new file mode 100644
index 000000000..aa700dcbd
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceSchedule.java
@@ -0,0 +1,93 @@
+/*
+ * 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 java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.util.Set;
+
+/** Resolves one-time and recurring maintenance windows against the UTC alert 
evaluation clock. */
+final class AlertSilenceSchedule {
+    private AlertSilenceSchedule() {
+    }
+
+    static LocalDateTime activeUntil(AlertSilenceVO silence, LocalDateTime 
utcNow) {
+        AlertSilenceRecurrence recurrence = silence.getRecurrence() == null
+                ? AlertSilenceRecurrence.ONCE : silence.getRecurrence();
+        if (recurrence == AlertSilenceRecurrence.ONCE) {
+            return isInside(utcNow, silence.getStartsAt(), 
silence.getEndsAt()) ? silence.getEndsAt() : null;
+        }
+        if (silence.getRecurrenceUntil() == null || 
!utcNow.isBefore(silence.getRecurrenceUntil())) {
+            return null;
+        }
+
+        ZoneId zone = ZoneId.of(silence.getTimeZone());
+        Instant now = utcNow.toInstant(ZoneOffset.UTC);
+        ZonedDateTime localNow = now.atZone(zone);
+        ZonedDateTime seedStart = 
silence.getStartsAt().toInstant(ZoneOffset.UTC).atZone(zone);
+        ZonedDateTime seedEnd = 
silence.getEndsAt().toInstant(ZoneOffset.UTC).atZone(zone);
+        Duration wallDuration = Duration.between(seedStart.toLocalDateTime(), 
seedEnd.toLocalDateTime());
+        int daysToInspect = recurrence == AlertSilenceRecurrence.DAILY ? 1 : 6;
+
+        for (int offset = 0; offset <= daysToInspect; offset++) {
+            LocalDate candidateDate = localNow.toLocalDate().minusDays(offset);
+            if (!runsOn(recurrence, silence.getRecurrenceDays(), 
candidateDate)) {
+                continue;
+            }
+            ZonedDateTime candidateStart = resolve(zone, candidateDate, 
seedStart.toLocalTime());
+            ZonedDateTime candidateEnd = resolveEnd(zone, candidateStart, 
wallDuration);
+            Instant start = candidateStart.toInstant();
+            Instant end = candidateEnd.toInstant();
+            Instant scheduleStart = 
silence.getStartsAt().toInstant(ZoneOffset.UTC);
+            Instant scheduleEnd = 
silence.getRecurrenceUntil().toInstant(ZoneOffset.UTC);
+            if (start.isBefore(scheduleStart) || !start.isBefore(scheduleEnd)) 
{
+                continue;
+            }
+            if (end.isAfter(scheduleEnd)) {
+                end = scheduleEnd;
+            }
+            if (!now.isBefore(start) && now.isBefore(end)) {
+                return LocalDateTime.ofInstant(end, ZoneOffset.UTC);
+            }
+        }
+        return null;
+    }
+
+    private static boolean runsOn(AlertSilenceRecurrence recurrence, 
Set<Integer> recurrenceDays,
+            LocalDate candidateDate) {
+        return recurrence == AlertSilenceRecurrence.DAILY
+                || recurrenceDays != null && 
recurrenceDays.contains(candidateDate.getDayOfWeek().getValue());
+    }
+
+    private static ZonedDateTime resolve(ZoneId zone, LocalDate date, 
LocalTime time) {
+        return ZonedDateTime.of(LocalDateTime.of(date, time), zone);
+    }
+
+    private static ZonedDateTime resolveEnd(ZoneId zone, ZonedDateTime start, 
Duration wallDuration) {
+        return ZonedDateTime.of(start.toLocalDateTime().plus(wallDuration), 
zone);
+    }
+
+    private static boolean isInside(LocalDateTime now, LocalDateTime start, 
LocalDateTime end) {
+        return !now.isBefore(start) && now.isBefore(end);
+    }
+}
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 45a36b226..726eccb9e 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
@@ -23,11 +23,16 @@ import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.springframework.stereotype.Service;
 
+import java.time.DateTimeException;
+import java.time.Duration;
 import java.time.LocalDateTime;
+import java.time.ZoneId;
 import java.time.ZoneOffset;
-import java.util.List;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
 
 @Service
 @RequiredArgsConstructor
@@ -56,16 +61,20 @@ public class AlertSilenceService {
         if (request.getReason() != null && request.getReason().length() > 512) 
{
             throw new BusinessException(400, "Silence reason must not exceed 
512 characters");
         }
+        RecurrenceConfiguration recurrence = validateRecurrence(request);
         AlertSilenceVO silence = 
AlertSilenceVO.builder().domain(request.getDomain())
                 
.ruleId(request.getRuleId()).instanceId(trimToNull(request.getInstanceId()))
                 .labels(normalizeLabels(request.getLabels()))
                 
.startsAt(LocalDateTime.ofInstant(request.getStartsAt().toInstant(), 
ZoneOffset.UTC))
                 
.endsAt(LocalDateTime.ofInstant(request.getEndsAt().toInstant(), 
ZoneOffset.UTC))
+                .recurrence(recurrence.type()).timeZone(recurrence.timeZone())
+                
.recurrenceDays(recurrence.days()).recurrenceUntil(recurrence.until())
                 .reason(trimToNull(request.getReason()))
                 
.createdBy(AuthenticatedUserContext.currentUsernameOrSystem()).build();
         AlertSilenceVO saved = repository.save(silence);
         operationAuditService.record("CREATE_ALERT_SILENCE", "ALERT_SILENCE", 
String.valueOf(saved.getId()),
-                saved.getInstanceId(), "ruleId=" + saved.getRuleId(), 
"SUCCESS", null);
+                saved.getInstanceId(), "ruleId=" + saved.getRuleId() + ", 
recurrence=" + saved.getRecurrence(),
+                "SUCCESS", null);
         return saved;
     }
 
@@ -96,9 +105,11 @@ public class AlertSilenceService {
             LocalDateTime now) {
         AlertDomain domain = rule.getDomain() == null ? AlertDomain.BUSINESS : 
rule.getDomain();
         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);
+                .filter(silence -> matchesScope(silence, rule.getId(), domain, 
instanceId,
+                        labels == null ? Map.of() : labels))
+                .map(silence -> AlertSilenceSchedule.activeUntil(silence, now))
+                .filter(java.util.Objects::nonNull)
+                .max(LocalDateTime::compareTo).orElse(null);
     }
 
     private static void validatePagination(int page, int pageSize) {
@@ -110,15 +121,68 @@ public class AlertSilenceService {
         }
     }
 
-    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())
-                && (silence.getDomain() == null || silence.getDomain() == 
domain)
+    private static boolean matchesScope(AlertSilenceVO silence, Long ruleId, 
AlertDomain domain, String instanceId,
+            Map<String, String> labels) {
+        return (silence.getDomain() == null || silence.getDomain() == domain)
                 && (silence.getRuleId() == null || 
silence.getRuleId().equals(ruleId))
                 && (silence.getInstanceId() == null || 
silence.getInstanceId().equals(instanceId))
                 && (silence.getLabels() == null || 
labels.entrySet().containsAll(silence.getLabels().entrySet()));
     }
 
+    private static RecurrenceConfiguration 
validateRecurrence(CreateAlertSilenceDTO request) {
+        AlertSilenceRecurrence recurrence = request.getRecurrence() == null
+                ? AlertSilenceRecurrence.ONCE : request.getRecurrence();
+        if (recurrence == AlertSilenceRecurrence.ONCE) {
+            return new RecurrenceConfiguration(recurrence, null, Set.of(), 
null);
+        }
+
+        String timeZone = trimToNull(request.getTimeZone());
+        if (timeZone == null) {
+            throw new BusinessException(400, "Time zone is required for 
recurring silences");
+        }
+        ZoneId zone;
+        try {
+            zone = ZoneId.of(timeZone);
+        } catch (DateTimeException error) {
+            throw new BusinessException(400, "Unknown silence time zone: " + 
timeZone);
+        }
+        if (request.getRecurrenceUntil() == null) {
+            throw new BusinessException(400, "Recurrence end time is required 
for recurring silences");
+        }
+        if 
(request.getRecurrenceUntil().toInstant().isBefore(request.getEndsAt().toInstant()))
 {
+            throw new BusinessException(400, "Recurrence end time must not be 
before the first window ends");
+        }
+
+        Duration wallDuration = Duration.between(
+                
request.getStartsAt().toInstant().atZone(zone).toLocalDateTime(),
+                
request.getEndsAt().toInstant().atZone(zone).toLocalDateTime());
+        Duration maximumDuration = recurrence == AlertSilenceRecurrence.DAILY
+                ? Duration.ofDays(1) : Duration.ofDays(7);
+        if (wallDuration.isNegative() || wallDuration.isZero() || 
wallDuration.compareTo(maximumDuration) > 0) {
+            throw new BusinessException(400, recurrence == 
AlertSilenceRecurrence.DAILY
+                    ? "Daily silence windows must not exceed 24 hours"
+                    : "Weekly silence windows must not exceed 7 days");
+        }
+
+        Set<Integer> days = normalizeRecurrenceDays(recurrence, 
request.getRecurrenceDays());
+        return new RecurrenceConfiguration(recurrence, zone.getId(), days,
+                
LocalDateTime.ofInstant(request.getRecurrenceUntil().toInstant(), 
ZoneOffset.UTC));
+    }
+
+    private static Set<Integer> normalizeRecurrenceDays(AlertSilenceRecurrence 
recurrence, Set<Integer> days) {
+        if (recurrence == AlertSilenceRecurrence.DAILY) {
+            return Set.of();
+        }
+        if (days == null || days.isEmpty()) {
+            throw new BusinessException(400, "At least one weekday is required 
for weekly silences");
+        }
+        if (days.stream().anyMatch(day -> day == null || day < 1 || day > 7)) {
+            throw new BusinessException(400, "Silence weekdays must use ISO 
values from 1 to 7");
+        }
+        TreeSet<Integer> normalized = new TreeSet<>(days);
+        return Set.copyOf(normalized);
+    }
+
     private static String trimToNull(String value) {
         return value == null || value.isBlank() ? null : value.trim();
     }
@@ -138,4 +202,8 @@ public class AlertSilenceService {
         });
         return Map.copyOf(normalized);
     }
+
+    private record RecurrenceConfiguration(AlertSilenceRecurrence type, String 
timeZone, Set<Integer> days,
+            LocalDateTime until) {
+    }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceVO.java 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceVO.java
index a08185866..841e719ae 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceVO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceVO.java
@@ -23,6 +23,7 @@ import lombok.NoArgsConstructor;
 
 import java.time.LocalDateTime;
 import java.util.Map;
+import java.util.Set;
 
 @Data
 @Builder
@@ -36,6 +37,10 @@ public class AlertSilenceVO {
     private Map<String, String> labels;
     private LocalDateTime startsAt;
     private LocalDateTime endsAt;
+    private AlertSilenceRecurrence recurrence;
+    private String timeZone;
+    private Set<Integer> recurrenceDays;
+    private LocalDateTime recurrenceUntil;
     private String reason;
     private String createdBy;
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/CreateAlertSilenceDTO.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/CreateAlertSilenceDTO.java
index 21a0dce20..6d23b5623 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/CreateAlertSilenceDTO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/CreateAlertSilenceDTO.java
@@ -21,6 +21,7 @@ import lombok.Data;
 
 import java.time.OffsetDateTime;
 import java.util.Map;
+import java.util.Set;
 
 @Data
 public class CreateAlertSilenceDTO {
@@ -33,5 +34,9 @@ public class CreateAlertSilenceDTO {
     private OffsetDateTime startsAt;
     @NotNull(message = "endsAt is required")
     private OffsetDateTime endsAt;
+    private AlertSilenceRecurrence recurrence;
+    private String timeZone;
+    private Set<Integer> recurrenceDays;
+    private OffsetDateTime recurrenceUntil;
     private String reason;
 }
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 5d2676510..2e2bfbdb5 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
@@ -30,7 +30,9 @@ import org.springframework.stereotype.Repository;
 import java.time.LocalDateTime;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.TreeMap;
+import java.util.TreeSet;
 
 @Repository
 @RequiredArgsConstructor
@@ -65,8 +67,15 @@ public class MybatisPlusAlertSilenceRepository implements 
AlertSilenceRepository
     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(schedule -> schedule
+                        .nested(once -> once
+                                .and(type -> type.isNull("recurrence").or()
+                                        .eq("recurrence", 
AlertSilenceRecurrence.ONCE.name()))
+                                .le("starts_at", now).gt("ends_at", now))
+                        .or(recurring -> recurring
+                                .in("recurrence", 
AlertSilenceRecurrence.DAILY.name(),
+                                        AlertSilenceRecurrence.WEEKLY.name())
+                                .le("starts_at", now).gt("recurrence_until", 
now)))
                 .and(scope -> scope.isNull("domain").or().eq("domain", 
domain.name()));
         if (ruleId == null) {
             query.isNull("rule_id");
@@ -95,6 +104,11 @@ public class MybatisPlusAlertSilenceRepository implements 
AlertSilenceRepository
         entity.setLabelsJson(writeLabels(silence.getLabels()));
         entity.setStartsAt(silence.getStartsAt());
         entity.setEndsAt(silence.getEndsAt());
+        entity.setRecurrence((silence.getRecurrence() == null ? 
AlertSilenceRecurrence.ONCE
+                : silence.getRecurrence()).name());
+        entity.setTimeZone(silence.getTimeZone());
+        entity.setRecurrenceDaysJson(writeDays(silence.getRecurrenceDays()));
+        entity.setRecurrenceUntil(silence.getRecurrenceUntil());
         entity.setReason(silence.getReason());
         entity.setCreatedBy(silence.getCreatedBy());
         return entity;
@@ -106,6 +120,10 @@ public class MybatisPlusAlertSilenceRepository implements 
AlertSilenceRepository
                 .ruleId(entity.getRuleId()).instanceId(entity.getInstanceId())
                 .labels(readLabels(entity.getLabelsJson()))
                 .startsAt(entity.getStartsAt()).endsAt(entity.getEndsAt())
+                .recurrence(entity.getRecurrence() == null ? 
AlertSilenceRecurrence.ONCE
+                        : 
AlertSilenceRecurrence.valueOf(entity.getRecurrence()))
+                
.timeZone(entity.getTimeZone()).recurrenceDays(readDays(entity.getRecurrenceDaysJson()))
+                .recurrenceUntil(entity.getRecurrenceUntil())
                 
.reason(entity.getReason()).createdBy(entity.getCreatedBy()).build();
     }
 
@@ -126,4 +144,22 @@ public class MybatisPlusAlertSilenceRepository implements 
AlertSilenceRepository
             throw new IllegalStateException("Unable to read alert silence 
labels", error);
         }
     }
+
+    private String writeDays(Set<Integer> days) {
+        if (days == null || days.isEmpty()) return null;
+        try {
+            return objectMapper.writeValueAsString(new TreeSet<>(days));
+        } catch (Exception error) {
+            throw new IllegalArgumentException("Unable to serialize alert 
silence weekdays", error);
+        }
+    }
+
+    private Set<Integer> readDays(String daysJson) {
+        if (daysJson == null || daysJson.isBlank()) return Set.of();
+        try {
+            return Set.copyOf(objectMapper.readValue(daysJson, new 
TypeReference<Set<Integer>>() { }));
+        } catch (Exception error) {
+            throw new IllegalStateException("Unable to read alert silence 
weekdays", error);
+        }
+    }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAlertSilence.java
 
b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAlertSilence.java
index 52af4c465..e47488dea 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAlertSilence.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAlertSilence.java
@@ -34,6 +34,10 @@ public class RmqAlertSilence {
     private String labelsJson;
     private LocalDateTime startsAt;
     private LocalDateTime endsAt;
+    private String recurrence;
+    private String timeZone;
+    private String recurrenceDaysJson;
+    private LocalDateTime recurrenceUntil;
     private String reason;
     private String createdBy;
 }
diff --git a/server/src/main/resources/db/schema.sql 
b/server/src/main/resources/db/schema.sql
index 2e62cf379..31f2e0938 100644
--- a/server/src/main/resources/db/schema.sql
+++ b/server/src/main/resources/db/schema.sql
@@ -356,12 +356,17 @@ CREATE TABLE IF NOT EXISTS rmq_alert_silence (
   `labels_json` TEXT NULL,
   `starts_at` DATETIME NOT NULL,
   `ends_at` DATETIME NOT NULL,
+  `recurrence` VARCHAR(16) NOT NULL DEFAULT 'ONCE',
+  `time_zone` VARCHAR(64) NULL,
+  `recurrence_days_json` VARCHAR(64) NULL,
+  `recurrence_until` DATETIME NULL,
   `reason` VARCHAR(512) NULL,
   `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`)
+  INDEX idx_alert_silence_scope (`domain`, `rule_id`, `instance_id`),
+  INDEX idx_alert_silence_recurrence (`recurrence`, `recurrence_until`, 
`starts_at`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
 CREATE TABLE IF NOT EXISTS rmq_alert_notification_outbox (
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 85b994a3b..8427a2d8f 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
@@ -78,9 +78,17 @@ class AlertSchemaMigrationTest {
         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'")) {
+                        + "AND index_name IN ('idx_alert_silence_expiry', 
'idx_alert_silence_recurrence')")) {
             result.next();
-            assertThat(result.getInt(1)).isGreaterThan(0);
+            assertThat(result.getInt(1)).isEqualTo(2);
+        }
+
+        try (Connection connection = dataSource.getConnection(); Statement 
statement = connection.createStatement();
+                ResultSet result = statement.executeQuery("SELECT COUNT(*) 
FROM information_schema.columns "
+                        + "WHERE table_name = 'rmq_alert_silence' AND 
column_name IN "
+                        + "('recurrence', 'time_zone', 'recurrence_days_json', 
'recurrence_until')")) {
+            result.next();
+            assertThat(result.getInt(1)).isEqualTo(4);
         }
     }
 
@@ -126,6 +134,8 @@ class AlertSchemaMigrationTest {
                             "collected_at");
             assertThat(indexColumns(connection, "rmq_metric_snapshot", 
"idx_metric_snapshot_retention"))
                     .containsExactly("collected_at");
+            assertThat(indexColumns(connection, "rmq_alert_silence", 
"idx_alert_silence_recurrence"))
+                    .containsExactly("recurrence", "recurrence_until", 
"starts_at");
         }
     }
 
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
index 86cb197f9..d78334c45 100644
--- 
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
@@ -26,11 +26,15 @@ import org.springframework.test.web.servlet.MockMvc;
 
 import java.time.LocalDateTime;
 import java.util.List;
+import java.util.Set;
 
+import static org.mockito.ArgumentMatchers.any;
 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.request.MockMvcRequestBuilders.post;
+import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
 import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
 import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 
@@ -68,4 +72,40 @@ class AlertSilenceControllerTest {
 
         verify(silenceService).listPage(eq(2), eq(10));
     }
+
+    @Test
+    void createShouldBindAndReturnRecurringScheduleTest() throws Exception {
+        AlertSilenceVO silence = AlertSilenceVO.builder().id(13L)
+                .startsAt(LocalDateTime.of(2026, 9, 7, 1, 0))
+                .endsAt(LocalDateTime.of(2026, 9, 7, 2, 0))
+                
.recurrence(AlertSilenceRecurrence.WEEKLY).timeZone("Asia/Shanghai")
+                .recurrenceDays(Set.of(1, 3, 5))
+                .recurrenceUntil(LocalDateTime.of(2026, 10, 1, 0, 
0)).createdBy("admin").build();
+        
when(silenceService.create(any(CreateAlertSilenceDTO.class))).thenReturn(silence);
+
+        mockMvc.perform(post("/api/alert-silences")
+                        .contentType("application/json")
+                        .content("""
+                                {
+                                  "startsAt": "2026-09-07T09:00:00+08:00",
+                                  "endsAt": "2026-09-07T10:00:00+08:00",
+                                  "recurrence": "WEEKLY",
+                                  "timeZone": "Asia/Shanghai",
+                                  "recurrenceDays": [1, 3, 5],
+                                  "recurrenceUntil": 
"2026-10-01T08:00:00+08:00"
+                                }
+                                """))
+                .andExpect(status().isOk())
+                
.andExpect(content().contentTypeCompatibleWith("application/json"))
+                .andExpect(jsonPath("$.data.id").value(13))
+                .andExpect(jsonPath("$.data.recurrence").value("WEEKLY"))
+                .andExpect(jsonPath("$.data.timeZone").value("Asia/Shanghai"))
+                .andExpect(jsonPath("$.data.recurrenceDays.length()").value(3))
+                
.andExpect(jsonPath("$.data.recurrenceUntil").value("2026-10-01T00:00:00"));
+
+        
verify(silenceService).create(org.mockito.ArgumentMatchers.argThat(request ->
+                request.getRecurrence() == AlertSilenceRecurrence.WEEKLY
+                        && request.getRecurrenceDays().equals(Set.of(1, 3, 5))
+                        && "Asia/Shanghai".equals(request.getTimeZone())));
+    }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceScheduleTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceScheduleTest.java
new file mode 100644
index 000000000..19768f402
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceScheduleTest.java
@@ -0,0 +1,127 @@
+/*
+ * 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.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class AlertSilenceScheduleTest {
+
+    @Test
+    void oneTimeWindowIncludesStartAndExcludesEndTest() {
+        AlertSilenceVO silence = once("2026-09-01T01:00:00", 
"2026-09-01T02:00:00");
+
+        assertThat(AlertSilenceSchedule.activeUntil(silence, 
at("2026-09-01T00:59:59"))).isNull();
+        assertThat(AlertSilenceSchedule.activeUntil(silence, 
at("2026-09-01T01:00:00")))
+                .isEqualTo(at("2026-09-01T02:00:00"));
+        assertThat(AlertSilenceSchedule.activeUntil(silence, 
at("2026-09-01T01:59:59")))
+                .isEqualTo(at("2026-09-01T02:00:00"));
+        assertThat(AlertSilenceSchedule.activeUntil(silence, 
at("2026-09-01T02:00:00"))).isNull();
+    }
+
+    @Test
+    void dailyWindowRepeatsAtTheConfiguredLocalTimeTest() {
+        AlertSilenceVO silence = recurring(AlertSilenceRecurrence.DAILY, 
"Asia/Shanghai", Set.of(),
+                "2026-09-01T01:00:00", "2026-09-01T02:00:00", 
"2026-09-10T00:00:00");
+
+        assertThat(AlertSilenceSchedule.activeUntil(silence, 
at("2026-09-03T01:30:00")))
+                .isEqualTo(at("2026-09-03T02:00:00"));
+        assertThat(AlertSilenceSchedule.activeUntil(silence, 
at("2026-09-03T02:30:00"))).isNull();
+    }
+
+    @Test
+    void dailyWindowCanCrossLocalMidnightTest() {
+        AlertSilenceVO silence = recurring(AlertSilenceRecurrence.DAILY, 
"Asia/Shanghai", Set.of(),
+                "2026-09-01T15:00:00", "2026-09-01T17:00:00", 
"2026-09-10T00:00:00");
+
+        assertThat(AlertSilenceSchedule.activeUntil(silence, 
at("2026-09-03T16:30:00")))
+                .isEqualTo(at("2026-09-03T17:00:00"));
+        assertThat(AlertSilenceSchedule.activeUntil(silence, 
at("2026-09-03T14:59:59"))).isNull();
+    }
+
+    @Test
+    void weeklyWindowRunsOnlyOnSelectedIsoWeekdaysTest() {
+        AlertSilenceVO silence = recurring(AlertSilenceRecurrence.WEEKLY, 
"UTC", Set.of(2, 4),
+                "2026-09-01T10:00:00", "2026-09-01T11:00:00", 
"2026-10-01T00:00:00");
+
+        assertThat(AlertSilenceSchedule.activeUntil(silence, 
at("2026-09-08T10:30:00")))
+                .isEqualTo(at("2026-09-08T11:00:00"));
+        assertThat(AlertSilenceSchedule.activeUntil(silence, 
at("2026-09-10T10:30:00")))
+                .isEqualTo(at("2026-09-10T11:00:00"));
+        assertThat(AlertSilenceSchedule.activeUntil(silence, 
at("2026-09-09T10:30:00"))).isNull();
+    }
+
+    @Test
+    void weeklyWindowCanRemainActiveOnFollowingDayTest() {
+        AlertSilenceVO silence = recurring(AlertSilenceRecurrence.WEEKLY, 
"UTC", Set.of(2),
+                "2026-09-01T22:00:00", "2026-09-03T02:00:00", 
"2026-10-01T00:00:00");
+
+        assertThat(AlertSilenceSchedule.activeUntil(silence, 
at("2026-09-09T01:00:00")))
+                .isEqualTo(at("2026-09-10T02:00:00"));
+        assertThat(AlertSilenceSchedule.activeUntil(silence, 
at("2026-09-10T02:00:00"))).isNull();
+    }
+
+    @Test
+    void recurrenceNeverStartsBeforeSeedWindowTest() {
+        AlertSilenceVO silence = recurring(AlertSilenceRecurrence.DAILY, 
"UTC", Set.of(),
+                "2026-09-10T10:00:00", "2026-09-10T11:00:00", 
"2026-09-20T00:00:00");
+
+        assertThat(AlertSilenceSchedule.activeUntil(silence, 
at("2026-09-09T10:30:00"))).isNull();
+        assertThat(AlertSilenceSchedule.activeUntil(silence, 
at("2026-09-10T10:30:00")))
+                .isEqualTo(at("2026-09-10T11:00:00"));
+    }
+
+    @Test
+    void recurrenceEndCapsTheLastOccurrenceTest() {
+        AlertSilenceVO silence = recurring(AlertSilenceRecurrence.DAILY, 
"UTC", Set.of(),
+                "2026-09-01T10:00:00", "2026-09-01T12:00:00", 
"2026-09-03T11:00:00");
+
+        assertThat(AlertSilenceSchedule.activeUntil(silence, 
at("2026-09-03T10:30:00")))
+                .isEqualTo(at("2026-09-03T11:00:00"));
+        assertThat(AlertSilenceSchedule.activeUntil(silence, 
at("2026-09-03T11:00:00"))).isNull();
+    }
+
+    @Test
+    void dailyWindowKeepsWallClockTimesAcrossSpringDstTest() {
+        AlertSilenceVO silence = recurring(AlertSilenceRecurrence.DAILY, 
"America/New_York", Set.of(),
+                "2026-03-07T06:30:00", "2026-03-07T08:30:00", 
"2026-03-12T00:00:00");
+
+        assertThat(AlertSilenceSchedule.activeUntil(silence, 
at("2026-03-08T07:15:00")))
+                .isEqualTo(at("2026-03-08T07:30:00"));
+        assertThat(AlertSilenceSchedule.activeUntil(silence, 
at("2026-03-09T06:45:00")))
+                .isEqualTo(at("2026-03-09T07:30:00"));
+    }
+
+    private static AlertSilenceVO once(String start, String end) {
+        return AlertSilenceVO.builder().startsAt(at(start)).endsAt(at(end))
+                
.recurrence(AlertSilenceRecurrence.ONCE).recurrenceDays(Set.of()).build();
+    }
+
+    private static AlertSilenceVO recurring(AlertSilenceRecurrence recurrence, 
String timeZone, Set<Integer> days,
+            String start, String end, String recurrenceUntil) {
+        return 
AlertSilenceVO.builder().startsAt(at(start)).endsAt(at(end)).recurrence(recurrence)
+                
.timeZone(timeZone).recurrenceDays(days).recurrenceUntil(at(recurrenceUntil)).build();
+    }
+
+    private static LocalDateTime at(String value) {
+        return LocalDateTime.parse(value);
+    }
+}
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 29f622ff4..05c2676fb 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
@@ -29,6 +29,7 @@ import java.time.LocalDateTime;
 import java.time.ZoneOffset;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -161,4 +162,113 @@ class AlertSilenceServiceTest {
         
org.mockito.Mockito.verify(repository).findActiveCandidates(AlertDomain.CLUSTER,
 9L, "local", now);
         org.mockito.Mockito.verify(repository, 
org.mockito.Mockito.never()).findAll();
     }
+
+    @Test
+    void createsBoundedWeeklySilenceWithNormalizedScheduleTest() {
+        AlertSilenceService service = new AlertSilenceService(repository, 
operationAuditService);
+        CreateAlertSilenceDTO request = 
recurringRequest(AlertSilenceRecurrence.WEEKLY);
+        request.setTimeZone(" Asia/Shanghai ");
+        request.setRecurrenceDays(Set.of(5, 1, 3));
+        when(repository.save(any())).thenAnswer(invocation -> 
invocation.getArgument(0));
+
+        AlertSilenceVO created = service.create(request);
+
+        
assertThat(created.getRecurrence()).isEqualTo(AlertSilenceRecurrence.WEEKLY);
+        assertThat(created.getTimeZone()).isEqualTo("Asia/Shanghai");
+        assertThat(created.getRecurrenceDays()).containsExactlyInAnyOrder(1, 
3, 5);
+        
assertThat(created.getRecurrenceUntil()).isEqualTo(LocalDateTime.of(2026, 9, 
30, 0, 0));
+    }
+
+    @Test
+    void defaultsLegacyRequestsToOneTimeSilenceTest() {
+        AlertSilenceService service = new AlertSilenceService(repository, 
operationAuditService);
+        CreateAlertSilenceDTO request = new CreateAlertSilenceDTO();
+        
request.setStartsAt(java.time.OffsetDateTime.parse("2026-09-01T10:00:00Z"));
+        
request.setEndsAt(java.time.OffsetDateTime.parse("2026-09-01T11:00:00Z"));
+        request.setTimeZone("Asia/Shanghai");
+        request.setRecurrenceDays(Set.of(1));
+        when(repository.save(any())).thenAnswer(invocation -> 
invocation.getArgument(0));
+
+        AlertSilenceVO created = service.create(request);
+
+        
assertThat(created.getRecurrence()).isEqualTo(AlertSilenceRecurrence.ONCE);
+        assertThat(created.getTimeZone()).isNull();
+        assertThat(created.getRecurrenceDays()).isEmpty();
+        assertThat(created.getRecurrenceUntil()).isNull();
+    }
+
+    @Test
+    void rejectsRecurringSilenceWithoutValidTimeZoneTest() {
+        AlertSilenceService service = new AlertSilenceService(repository, 
operationAuditService);
+        CreateAlertSilenceDTO missing = 
recurringRequest(AlertSilenceRecurrence.DAILY);
+        missing.setTimeZone(null);
+        CreateAlertSilenceDTO unknown = 
recurringRequest(AlertSilenceRecurrence.DAILY);
+        unknown.setTimeZone("Mars/Olympus");
+
+        assertThatThrownBy(() -> service.create(missing))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("Time zone is required for recurring silences");
+        assertThatThrownBy(() -> service.create(unknown))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("Unknown silence time zone: Mars/Olympus");
+    }
+
+    @Test
+    void rejectsUnboundedOrPrematureRecurrenceEndTest() {
+        AlertSilenceService service = new AlertSilenceService(repository, 
operationAuditService);
+        CreateAlertSilenceDTO missing = 
recurringRequest(AlertSilenceRecurrence.DAILY);
+        missing.setTimeZone("UTC");
+        missing.setRecurrenceUntil(null);
+        CreateAlertSilenceDTO premature = 
recurringRequest(AlertSilenceRecurrence.DAILY);
+        
premature.setRecurrenceUntil(java.time.OffsetDateTime.parse("2026-09-01T10:30:00Z"));
+
+        assertThatThrownBy(() -> service.create(missing))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("Recurrence end time is required for recurring 
silences");
+        assertThatThrownBy(() -> service.create(premature))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("Recurrence end time must not be before the first 
window ends");
+    }
+
+    @Test
+    void rejectsWeeklySilenceWithoutIsoWeekdaysTest() {
+        AlertSilenceService service = new AlertSilenceService(repository, 
operationAuditService);
+        CreateAlertSilenceDTO empty = 
recurringRequest(AlertSilenceRecurrence.WEEKLY);
+        CreateAlertSilenceDTO invalid = 
recurringRequest(AlertSilenceRecurrence.WEEKLY);
+        invalid.setRecurrenceDays(Set.of(0, 8));
+
+        assertThatThrownBy(() -> service.create(empty))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("At least one weekday is required for weekly 
silences");
+        assertThatThrownBy(() -> service.create(invalid))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("Silence weekdays must use ISO values from 1 to 
7");
+    }
+
+    @Test
+    void rejectsWindowsLongerThanTheirRecurrencePeriodTest() {
+        AlertSilenceService service = new AlertSilenceService(repository, 
operationAuditService);
+        CreateAlertSilenceDTO daily = 
recurringRequest(AlertSilenceRecurrence.DAILY);
+        daily.setEndsAt(daily.getStartsAt().plusHours(25));
+        CreateAlertSilenceDTO weekly = 
recurringRequest(AlertSilenceRecurrence.WEEKLY);
+        weekly.setRecurrenceDays(Set.of(1));
+        weekly.setEndsAt(weekly.getStartsAt().plusDays(8));
+
+        assertThatThrownBy(() -> service.create(daily))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("Daily silence windows must not exceed 24 hours");
+        assertThatThrownBy(() -> service.create(weekly))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("Weekly silence windows must not exceed 7 days");
+    }
+
+    private static CreateAlertSilenceDTO 
recurringRequest(AlertSilenceRecurrence recurrence) {
+        CreateAlertSilenceDTO request = new CreateAlertSilenceDTO();
+        
request.setStartsAt(java.time.OffsetDateTime.parse("2026-09-01T10:00:00Z"));
+        
request.setEndsAt(java.time.OffsetDateTime.parse("2026-09-01T11:00:00Z"));
+        request.setRecurrence(recurrence);
+        request.setTimeZone("UTC");
+        
request.setRecurrenceUntil(java.time.OffsetDateTime.parse("2026-09-30T00:00:00Z"));
+        return request;
+    }
 }
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
index 96861057a..aef0d56e5 100644
--- 
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
@@ -32,6 +32,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
 
 import java.time.LocalDateTime;
 import java.util.List;
+import java.util.Set;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.ArgumentMatchers.any;
@@ -98,7 +99,8 @@ class MybatisPlusAlertSilenceRepositoryTest {
         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("starts_at", "ends_at", "recurrence", 
"recurrence_until", "domain IS NULL",
+                        "rule_id IS NULL", "instance_id IS NULL")
                 .contains("ORDER BY ends_at DESC,id DESC");
         assertThat(query.getParamNameValuePairs())
                 .containsValue(now)
@@ -106,4 +108,52 @@ class MybatisPlusAlertSilenceRepositoryTest {
                 .containsValue(5L)
                 .containsValue("local");
     }
+
+    @Test
+    void saveShouldPersistRecurringScheduleFieldsTest() {
+        MybatisPlusAlertSilenceRepository repository = new 
MybatisPlusAlertSilenceRepository(
+                mapper, new ObjectMapper());
+        AlertSilenceVO silence = AlertSilenceVO.builder()
+                .domain(AlertDomain.BUSINESS).startsAt(LocalDateTime.of(2026, 
9, 1, 10, 0))
+                .endsAt(LocalDateTime.of(2026, 9, 1, 11, 
0)).recurrence(AlertSilenceRecurrence.WEEKLY)
+                .timeZone("Asia/Shanghai").recurrenceDays(Set.of(5, 1))
+                .recurrenceUntil(LocalDateTime.of(2026, 10, 1, 0, 
0)).createdBy("admin").build();
+        when(mapper.insert(any(RmqAlertSilence.class))).thenAnswer(invocation 
-> {
+            RmqAlertSilence entity = invocation.getArgument(0);
+            entity.setId(31L);
+            return 1;
+        });
+
+        AlertSilenceVO saved = repository.save(silence);
+
+        ArgumentCaptor<RmqAlertSilence> captor = 
ArgumentCaptor.forClass(RmqAlertSilence.class);
+        verify(mapper).insert(captor.capture());
+        assertThat(saved.getId()).isEqualTo(31L);
+        assertThat(captor.getValue().getRecurrence()).isEqualTo("WEEKLY");
+        assertThat(captor.getValue().getTimeZone()).isEqualTo("Asia/Shanghai");
+        
assertThat(captor.getValue().getRecurrenceDaysJson()).isEqualTo("[1,5]");
+        
assertThat(captor.getValue().getRecurrenceUntil()).isEqualTo(LocalDateTime.of(2026,
 10, 1, 0, 0));
+    }
+
+    @Test
+    void findAllShouldRestoreRecurringScheduleAndLegacyDefaultsTest() {
+        MybatisPlusAlertSilenceRepository repository = new 
MybatisPlusAlertSilenceRepository(
+                mapper, new ObjectMapper());
+        RmqAlertSilence recurring = new RmqAlertSilence();
+        recurring.setId(31L);
+        recurring.setRecurrence("WEEKLY");
+        recurring.setTimeZone("UTC");
+        recurring.setRecurrenceDaysJson("[1,3,5]");
+        recurring.setRecurrenceUntil(LocalDateTime.of(2026, 10, 1, 0, 0));
+        RmqAlertSilence legacy = new RmqAlertSilence();
+        legacy.setId(30L);
+        when(mapper.selectList(any())).thenReturn(List.of(recurring, legacy));
+
+        List<AlertSilenceVO> restored = repository.findAll();
+
+        
assertThat(restored.get(0).getRecurrence()).isEqualTo(AlertSilenceRecurrence.WEEKLY);
+        
assertThat(restored.get(0).getRecurrenceDays()).containsExactlyInAnyOrder(1, 3, 
5);
+        
assertThat(restored.get(1).getRecurrence()).isEqualTo(AlertSilenceRecurrence.ONCE);
+        assertThat(restored.get(1).getRecurrenceDays()).isEmpty();
+    }
 }
diff --git a/web/src/api/ops.ts b/web/src/api/ops.ts
index c0e4d9c7c..cf05f8498 100644
--- a/web/src/api/ops.ts
+++ b/web/src/api/ops.ts
@@ -158,6 +158,10 @@ export interface AlertSilence {
   labels?: Record<string, string>;
   startsAt: string;
   endsAt: string;
+  recurrence?: 'ONCE' | 'DAILY' | 'WEEKLY';
+  timeZone?: string | null;
+  recurrenceDays?: number[];
+  recurrenceUntil?: string | null;
   reason?: string | null;
   createdBy: string;
 }
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index a8873d13f..802092b92 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -719,6 +719,38 @@ const translations: Record<string, Record<Lang, string>> = 
{
   'sysAlerts.startTimeRequired': { zh: '请选择开始时间', en: 'Select a start time' },
   'sysAlerts.endTime': { zh: '结束时间', en: 'End time' },
   'sysAlerts.endTimeRequired': { zh: '请选择结束时间', en: 'Select an end time' },
+  'sysAlerts.recurrence': { zh: '重复方式', en: 'Recurrence' },
+  'sysAlerts.recurrenceOnce': { zh: '仅一次', en: 'One time' },
+  'sysAlerts.recurrenceDaily': { zh: '每天', en: 'Daily' },
+  'sysAlerts.recurrenceWeekly': { zh: '每周', en: 'Weekly' },
+  'sysAlerts.timeZone': { zh: '时区', en: 'Time zone' },
+  'sysAlerts.timeZoneRequired': { zh: '请输入时区', en: 'Enter a time zone' },
+  'sysAlerts.timeZoneHelp': {
+    zh: '使用 IANA 时区,例如 Asia/Shanghai',
+    en: 'Use an IANA time zone, for example Asia/Shanghai',
+  },
+  'sysAlerts.recurrenceDays': { zh: '重复日期', en: 'Repeat on' },
+  'sysAlerts.recurrenceDaysRequired': {
+    zh: '请至少选择一个星期',
+    en: 'Select at least one weekday',
+  },
+  'sysAlerts.recurrenceUntil': { zh: '重复至', en: 'Repeat until' },
+  'sysAlerts.recurrenceUntilRequired': {
+    zh: '请选择重复结束时间',
+    en: 'Select when recurrence ends',
+  },
+  'sysAlerts.recurrenceUntilHelp': {
+    zh: '到达该时间后不再静默通知',
+    en: 'Notifications resume after this time',
+  },
+  'sysAlerts.repeatsUntil': { zh: '重复至 {time}', en: 'repeats until {time}' },
+  'sysAlerts.monday': { zh: '周一', en: 'Monday' },
+  'sysAlerts.tuesday': { zh: '周二', en: 'Tuesday' },
+  'sysAlerts.wednesday': { zh: '周三', en: 'Wednesday' },
+  'sysAlerts.thursday': { zh: '周四', en: 'Thursday' },
+  'sysAlerts.friday': { zh: '周五', en: 'Friday' },
+  'sysAlerts.saturday': { zh: '周六', en: 'Saturday' },
+  'sysAlerts.sunday': { zh: '周日', en: 'Sunday' },
   'sysAlerts.reason': { zh: '原因', en: 'Reason' },
   'sysAlerts.labelScope': { zh: '标签范围', en: 'Label scope' },
   'sysAlerts.labelScopeHelp': {
diff --git a/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx 
b/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
index 2778c7242..51f3f4eef 100644
--- a/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
+++ b/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
@@ -497,6 +497,56 @@ describe('SystemAlertsPage', () => {
     });
   });
 
+  it('creates a bounded weekly maintenance schedule in an IANA time zone', 
async () => {
+    vi.mocked(listAlertSilencesPage).mockResolvedValue({
+      items: [],
+      total: 0,
+      page: 1,
+      size: 10,
+    });
+    vi.mocked(createAlertSilence).mockResolvedValue({
+      id: 12,
+      startsAt: '2026-09-07T01:00:00Z',
+      endsAt: '2026-09-07T02:00:00Z',
+      recurrence: 'WEEKLY',
+      timeZone: 'Asia/Shanghai',
+      recurrenceDays: [1, 3, 5],
+      recurrenceUntil: '2026-10-01T00:00:00Z',
+      createdBy: 'admin',
+    });
+    const user = userEvent.setup();
+    renderPage();
+
+    await user.click(await screen.findByRole('button', { name: '维护窗口' }));
+    const recurrenceSelect = screen.getByLabelText('重复方式');
+    fireEvent.mouseDown(recurrenceSelect.parentElement!);
+    await user.click(await screen.findByText('每周'));
+
+    await user.clear(screen.getByLabelText('时区'));
+    await user.type(screen.getByLabelText('时区'), 'Asia/Shanghai');
+    const weekdaySelect = screen.getByLabelText('重复日期');
+    fireEvent.mouseDown(weekdaySelect.parentElement!);
+    await user.click(await screen.findByText('周一'));
+
+    fireEvent.change(screen.getByLabelText('开始时间'), { target: { value: 
'2026-09-07T09:00' } });
+    fireEvent.change(screen.getByLabelText('结束时间'), { target: { value: 
'2026-09-07T10:00' } });
+    fireEvent.change(screen.getByLabelText('重复至'), { target: { value: 
'2026-10-01T08:00' } });
+    await user.click(screen.getByRole('button', { name: /创\s*建/ }));
+
+    await waitFor(() => {
+      expect(createAlertSilence).toHaveBeenCalledWith(
+        expect.objectContaining({
+          recurrence: 'WEEKLY',
+          timeZone: 'Asia/Shanghai',
+          recurrenceDays: [1],
+          startsAt: '2026-09-07T01:00:00.000Z',
+          endsAt: '2026-09-07T02:00:00.000Z',
+          recurrenceUntil: '2026-10-01T00:00:00.000Z',
+        }),
+      );
+    });
+  });
+
   it('loads maintenance windows by page and backs up after deleting the last 
page item', async () => {
     vi.mocked(listAlertSilencesPage)
       .mockResolvedValueOnce({
diff --git a/web/src/pages/ops/systemAlerts.tsx 
b/web/src/pages/ops/systemAlerts.tsx
index 506ea2b45..dbf3ce179 100644
--- a/web/src/pages/ops/systemAlerts.tsx
+++ b/web/src/pages/ops/systemAlerts.tsx
@@ -57,11 +57,22 @@ import type {
 } from '../../api/ops';
 import { formatUtcDateTime, formatNumber } from '../../utils/format';
 import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
+import { zonedLocalDateTimeToUtc } from '../../utils/timeZone';
 
 const { Text } = Typography;
 
 const ALERT_EXPORT_PAGE_SIZE = 100;
 const ALERT_EXPORT_MAX_PAGES = 10_000;
+const DEFAULT_TIME_ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone || 
'UTC';
+const WEEKDAYS = [
+  { value: 1, key: 'sysAlerts.monday' },
+  { value: 2, key: 'sysAlerts.tuesday' },
+  { value: 3, key: 'sysAlerts.wednesday' },
+  { value: 4, key: 'sysAlerts.thursday' },
+  { value: 5, key: 'sysAlerts.friday' },
+  { value: 6, key: 'sysAlerts.saturday' },
+  { value: 7, key: 'sysAlerts.sunday' },
+] as const;
 
 const normalizeAlertLevel = (level?: string | null) => (level ?? 
'').toLowerCase();
 const formatAlertTransition = (
@@ -164,6 +175,7 @@ const SystemAlertsPage = () => {
   const [deletingSilenceId, setDeletingSilenceId] = useState<number | 
null>(null);
   const silencePageSize = 10;
   const [silenceForm] = Form.useForm();
+  const silenceRecurrence = Form.useWatch('recurrence', silenceForm) ?? 'ONCE';
 
   const currentQuery = () => {
     const labelSeparator = labelFilter.indexOf('=');
@@ -376,6 +388,10 @@ const SystemAlertsPage = () => {
       endsAt: string;
       reason?: string;
       labelsText?: string;
+      recurrence?: 'ONCE' | 'DAILY' | 'WEEKLY';
+      timeZone?: string;
+      recurrenceDays?: number[];
+      recurrenceUntil?: string;
     };
     try {
       values = await silenceForm.validateFields();
@@ -384,14 +400,26 @@ const SystemAlertsPage = () => {
     }
     setSavingSilence(true);
     try {
+      const recurrence = values.recurrence ?? 'ONCE';
+      const convertTime = (value: string) =>
+        recurrence === 'ONCE'
+          ? localDateTimeToUtc(value)
+          : zonedLocalDateTimeToUtc(value, values.timeZone!);
       const request: CreateAlertSilence = {
         instanceId: values.instanceId,
-        startsAt: localDateTimeToUtc(values.startsAt),
-        endsAt: localDateTimeToUtc(values.endsAt),
+        startsAt: convertTime(values.startsAt),
+        endsAt: convertTime(values.endsAt),
         reason: values.reason,
         ruleId: values.ruleId ? Number(values.ruleId) : undefined,
         domain: values.domain || undefined,
         labels: parseSilenceLabels(values.labelsText, 
t('sysAlerts.labelsFormatInvalid')),
+        recurrence,
+        timeZone: recurrence !== 'ONCE' ? values.timeZone : undefined,
+        recurrenceDays: recurrence === 'WEEKLY' ? values.recurrenceDays : 
undefined,
+        recurrenceUntil:
+          recurrence !== 'ONCE' && values.recurrenceUntil
+            ? convertTime(values.recurrenceUntil)
+            : undefined,
       };
       await createAlertSilence(request);
       silenceForm.resetFields();
@@ -804,7 +832,11 @@ const SystemAlertsPage = () => {
         width={680}
       >
         {canManageSilences && (
-          <Form form={silenceForm} layout="vertical" initialValues={{ domain: 
'BUSINESS' }}>
+          <Form
+            form={silenceForm}
+            layout="vertical"
+            initialValues={{ domain: 'BUSINESS', recurrence: 'ONCE', timeZone: 
DEFAULT_TIME_ZONE }}
+          >
             <Flex gap={8}>
               <Form.Item name="domain" label={t('sysAlerts.domain')} style={{ 
flex: 1 }}>
                 <Select
@@ -826,6 +858,57 @@ const SystemAlertsPage = () => {
                 <Input />
               </Form.Item>
             </Flex>
+            <Flex gap={8} align="start">
+              <Form.Item name="recurrence" label={t('sysAlerts.recurrence')} 
style={{ flex: 1 }}>
+                <Select
+                  options={[
+                    { value: 'ONCE', label: t('sysAlerts.recurrenceOnce') },
+                    { value: 'DAILY', label: t('sysAlerts.recurrenceDaily') },
+                    { value: 'WEEKLY', label: t('sysAlerts.recurrenceWeekly') 
},
+                  ]}
+                />
+              </Form.Item>
+              {silenceRecurrence !== 'ONCE' && (
+                <Form.Item
+                  name="timeZone"
+                  label={t('sysAlerts.timeZone')}
+                  style={{ flex: 1 }}
+                  rules={[{ required: true, message: 
t('sysAlerts.timeZoneRequired') }]}
+                  extra={t('sysAlerts.timeZoneHelp')}
+                >
+                  <Input placeholder="Asia/Shanghai" />
+                </Form.Item>
+              )}
+            </Flex>
+            {silenceRecurrence !== 'ONCE' && (
+              <Flex gap={8} align="start">
+                {silenceRecurrence === 'WEEKLY' && (
+                  <Form.Item
+                    name="recurrenceDays"
+                    label={t('sysAlerts.recurrenceDays')}
+                    style={{ flex: 1 }}
+                    rules={[{ required: true, message: 
t('sysAlerts.recurrenceDaysRequired') }]}
+                  >
+                    <Select
+                      mode="multiple"
+                      options={WEEKDAYS.map((day) => ({
+                        value: day.value,
+                        label: t(day.key),
+                      }))}
+                    />
+                  </Form.Item>
+                )}
+                <Form.Item
+                  name="recurrenceUntil"
+                  label={t('sysAlerts.recurrenceUntil')}
+                  style={{ flex: 1 }}
+                  rules={[{ required: true, message: 
t('sysAlerts.recurrenceUntilRequired') }]}
+                  extra={t('sysAlerts.recurrenceUntilHelp')}
+                >
+                  <Input type="datetime-local" />
+                </Form.Item>
+              </Flex>
+            )}
             <Flex gap={8}>
               <Form.Item
                 name="startsAt"
@@ -864,6 +947,13 @@ const SystemAlertsPage = () => {
             {silences.map((silence) => (
               <Flex key={silence.id} justify="space-between" align="center" 
gap={8}>
                 <Text>
+                  {silence.recurrence && silence.recurrence !== 'ONCE' && (
+                    <Tag color="blue">
+                      {silence.recurrence === 'DAILY'
+                        ? t('sysAlerts.recurrenceDaily')
+                        : t('sysAlerts.recurrenceWeekly')}
+                    </Tag>
+                  )}
                   {silence.domain ?? t('common.all')} ·{' '}
                   {silence.instanceId ?? t('sysAlerts.allInstances')} · 
{silence.startsAt} -{' '}
                   {silence.endsAt}
@@ -872,6 +962,11 @@ const SystemAlertsPage = () => {
                         .map(([key, value]) => `${key}=${value}`)
                         .join(', ')}`
                     : ''}
+                  {silence.recurrence && silence.recurrence !== 'ONCE'
+                    ? ` · ${silence.timeZone} · ${t('sysAlerts.repeatsUntil', {
+                        time: silence.recurrenceUntil ?? '',
+                      })}`
+                    : ''}
                 </Text>
                 {canManageSilences && (
                   <Button
diff --git a/web/src/utils/timeZone.test.ts b/web/src/utils/timeZone.test.ts
new file mode 100644
index 000000000..cedcffc4d
--- /dev/null
+++ b/web/src/utils/timeZone.test.ts
@@ -0,0 +1,59 @@
+/*
+ * 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.
+ */
+
+import { describe, expect, it } from 'vitest';
+import { zonedLocalDateTimeToUtc } from './timeZone';
+
+describe('zonedLocalDateTimeToUtc', () => {
+  it('converts a positive fixed offset independently of the browser zone', () 
=> {
+    expect(zonedLocalDateTimeToUtc('2026-09-07T09:00', 'Asia/Shanghai')).toBe(
+      '2026-09-07T01:00:00.000Z',
+    );
+  });
+
+  it('converts a negative winter offset', () => {
+    expect(zonedLocalDateTimeToUtc('2026-01-15T09:30:45', 
'America/New_York')).toBe(
+      '2026-01-15T14:30:45.000Z',
+    );
+  });
+
+  it('uses daylight-saving offset after the spring transition', () => {
+    expect(zonedLocalDateTimeToUtc('2026-03-08T03:30', 
'America/New_York')).toBe(
+      '2026-03-08T07:30:00.000Z',
+    );
+  });
+
+  it('rejects a wall-clock time skipped by daylight saving', () => {
+    expect(() => zonedLocalDateTimeToUtc('2026-03-08T02:30', 
'America/New_York')).toThrow(
+      'Local date time does not exist in America/New_York',
+    );
+  });
+
+  it('supports UTC and second precision', () => {
+    expect(zonedLocalDateTimeToUtc('2026-09-07T09:00:59', 
'UTC')).toBe('2026-09-07T09:00:59.000Z');
+  });
+
+  it('rejects impossible calendar values before conversion', () => {
+    expect(() => zonedLocalDateTimeToUtc('2026-02-30T09:00', 'UTC')).toThrow(
+      'Invalid local date time',
+    );
+  });
+
+  it('rejects unknown IANA time zones', () => {
+    expect(() => zonedLocalDateTimeToUtc('2026-09-07T09:00', 
'Mars/Olympus')).toThrow();
+  });
+});
diff --git a/web/src/utils/timeZone.ts b/web/src/utils/timeZone.ts
new file mode 100644
index 000000000..468de0eac
--- /dev/null
+++ b/web/src/utils/timeZone.ts
@@ -0,0 +1,116 @@
+/*
+ * 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.
+ */
+
+interface LocalDateTimeParts {
+  year: number;
+  month: number;
+  day: number;
+  hour: number;
+  minute: number;
+  second: number;
+}
+
+const LOCAL_DATE_TIME = 
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/;
+
+const parseLocalDateTime = (value: string): LocalDateTimeParts => {
+  const match = LOCAL_DATE_TIME.exec(value);
+  if (!match) throw new Error(`Invalid local date time: ${value}`);
+  const [, year, month, day, hour, minute, second = '0'] = match;
+  const parts = {
+    year: Number(year),
+    month: Number(month),
+    day: Number(day),
+    hour: Number(hour),
+    minute: Number(minute),
+    second: Number(second),
+  };
+  const normalized = new Date(
+    Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, 
parts.second),
+  );
+  if (
+    normalized.getUTCFullYear() !== parts.year ||
+    normalized.getUTCMonth() + 1 !== parts.month ||
+    normalized.getUTCDate() !== parts.day ||
+    normalized.getUTCHours() !== parts.hour ||
+    normalized.getUTCMinutes() !== parts.minute ||
+    normalized.getUTCSeconds() !== parts.second
+  ) {
+    throw new Error(`Invalid local date time: ${value}`);
+  }
+  return parts;
+};
+
+const partsAsUtcMillis = (parts: LocalDateTimeParts) =>
+  Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, 
parts.second);
+
+const formatterFor = (timeZone: string) =>
+  new Intl.DateTimeFormat('en-CA', {
+    timeZone,
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit',
+    second: '2-digit',
+    hourCycle: 'h23',
+  });
+
+const formatParts = (formatter: Intl.DateTimeFormat, timestamp: number): 
LocalDateTimeParts => {
+  const parts = Object.fromEntries(
+    formatter
+      .formatToParts(new Date(timestamp))
+      .filter((part) => part.type !== 'literal')
+      .map((part) => [part.type, Number(part.value)]),
+  );
+  return {
+    year: parts.year,
+    month: parts.month,
+    day: parts.day,
+    hour: parts.hour,
+    minute: parts.minute,
+    second: parts.second,
+  };
+};
+
+const sameParts = (left: LocalDateTimeParts, right: LocalDateTimeParts) =>
+  left.year === right.year &&
+  left.month === right.month &&
+  left.day === right.day &&
+  left.hour === right.hour &&
+  left.minute === right.minute &&
+  left.second === right.second;
+
+/** Converts a wall-clock date time in an IANA time zone to a UTC ISO 
timestamp. */
+export const zonedLocalDateTimeToUtc = (value: string, timeZone: string): 
string => {
+  const desired = parseLocalDateTime(value);
+  const formatter = formatterFor(timeZone);
+  const desiredMillis = partsAsUtcMillis(desired);
+  let candidate = desiredMillis;
+
+  // Offset changes are discontinuous around DST, so converge using the 
wall-clock delta.
+  for (let attempt = 0; attempt < 4; attempt += 1) {
+    const observed = formatParts(formatter, candidate);
+    const delta = desiredMillis - partsAsUtcMillis(observed);
+    if (delta === 0) return new Date(candidate).toISOString();
+    candidate += delta;
+  }
+
+  if (!sameParts(formatParts(formatter, candidate), desired)) {
+    throw new Error(`Local date time does not exist in ${timeZone}: ${value}`);
+  }
+  return new Date(candidate).toISOString();
+};

Reply via email to