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();
+};