From 4c28c4d9052e5f47e8285f7f869216b020cae2e8 Mon Sep 17 00:00:00 2001 From: Bondar Bogdan <36962546+bogdan-bondar@users.noreply.github.com> Date: Wed, 4 Apr 2018 10:01:55 +0200 Subject: [PATCH] Fix optimize ui maintenance window (#668) * Optimize maintenance window UI Signed-off-by: Melanie Retter * Refactor Signed-off-by: Melanie Retter * Add new downloaded status to UI. Signed-off-by: kaizimmerm * Changed Accordion to Tabsheet for better visualization of action types and maintanance window. Signed-off-by: Markus Block * Refined UI for maintenance window, refactoring Added ENTER shortcut for save button in dialog windows Signed-off-by: Bogdan Bondar * Extended validation for maintenance window, refactored the maintenance window helper class Added text change listeners for the schedule and duration text fields in order to activate "save all" button Added client Locale identification for cron expression translation Moved maintenance window validation from TargetWithActionType constructor to saveAll method of assignment tab Signed-off-by: Bogdan Bondar * Added SupressWarnings annotation for exception handling cases Signed-off-by: Bogdan Bondar * Fixed Sonar issue: added private constructor to Maintenance schedule helper class Signed-off-by: Bogdan Bondar * Sonar Issue: make utility class Maintenance Schedule final Signed-off-by: Bogdan Bondar * Added Maintenance Window validation to Distribution Set and Target Management API Signed-off-by: Bogdan Bondar * Added unit tests for MaintenanceScheduleHelper class Signed-off-by: Bogdan Bondar * Added the license header to MaintenanceScheduleHelperTest class Signed-off-by: Bogdan Bondar * Small changes after PR review Signed-off-by: Bogdan Bondar * Added Id for Maintenance Window layout for UI Tests Signed-off-by: Bogdan Bondar --- .../resource/MgmtDistributionSetResource.java | 11 +- .../rest/resource/MgmtTargetResource.java | 18 +- .../repository/MaintenanceScheduleHelper.java | 159 ++++++---- .../InvalidMaintenanceScheduleException.java | 39 +++ .../model/TargetWithActionType.java | 4 - .../MaintenanceScheduleHelperTest.java | 96 ++++++ .../jpa/JpaControllerManagement.java | 6 +- .../repository/jpa/model/JpaAction.java | 38 +-- .../src/main/resources/application.properties | 1 + hawkbit-ui/pom.xml | 4 + .../org/eclipse/hawkbit/ui/UiProperties.java | 13 + .../hawkbit/ui/common/CommonDialogWindow.java | 2 + .../ui/common/builder/ComboBoxBuilder.java | 30 +- .../confirmwindow/layout/ConfirmationTab.java | 3 - .../hawkbit/ui/management/DeploymentView.java | 2 +- .../footer/DeleteActionsLayout.java | 26 +- .../footer/MaintenanceWindowLayout.java | 290 +++++++++++------- ...> ManagementConfirmationWindowLayout.java} | 118 +++++-- .../ui/utils/UIComponentIdProvider.java | 30 ++ .../hawkbit/customstyles/popup-window.scss | 25 +- .../src/main/resources/messages.properties | 10 +- 21 files changed, 632 insertions(+), 293 deletions(-) create mode 100644 hawkbit-repository/hawkbit-repository-api/src/test/java/org/eclipse/hawkbit/repository/MaintenanceScheduleHelperTest.java rename hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/footer/{ManangementConfirmationWindowLayout.java => ManagementConfirmationWindowLayout.java} (83%) diff --git a/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtDistributionSetResource.java b/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtDistributionSetResource.java index 2d963814f..f8e1329de 100644 --- a/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtDistributionSetResource.java +++ b/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtDistributionSetResource.java @@ -29,6 +29,7 @@ import org.eclipse.hawkbit.mgmt.rest.api.MgmtRestConstants; import org.eclipse.hawkbit.repository.DeploymentManagement; import org.eclipse.hawkbit.repository.DistributionSetManagement; import org.eclipse.hawkbit.repository.EntityFactory; +import org.eclipse.hawkbit.repository.MaintenanceScheduleHelper; import org.eclipse.hawkbit.repository.OffsetBasedPageRequest; import org.eclipse.hawkbit.repository.SoftwareModuleManagement; import org.eclipse.hawkbit.repository.SystemManagement; @@ -256,10 +257,14 @@ public class MgmtDistributionSetResource implements MgmtDistributionSetRestApi { t.getForcetime()); } + final String cronSchedule = t.getMaintenanceWindow().getMaintenanceSchedule(); + final String duration = t.getMaintenanceWindow().getMaintenanceWindowDuration(); + final String timezone = t.getMaintenanceWindow().getMaintenanceWindowTimeZone(); + + MaintenanceScheduleHelper.validateMaintenanceSchedule(cronSchedule, duration, timezone); + return new TargetWithActionType(t.getId(), MgmtRestModelMapper.convertActionType(t.getType()), - t.getForcetime(), t.getMaintenanceWindow().getMaintenanceSchedule(), - t.getMaintenanceWindow().getMaintenanceWindowDuration(), - t.getMaintenanceWindow().getMaintenanceWindowTimeZone()); + t.getForcetime(), cronSchedule, duration, timezone); }).collect(Collectors.toList())); return ResponseEntity.ok(MgmtDistributionSetMapper.toResponse(assignDistributionSet)); diff --git a/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResource.java b/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResource.java index 78d70d2eb..6cc44270c 100644 --- a/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResource.java +++ b/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResource.java @@ -31,6 +31,7 @@ import org.eclipse.hawkbit.mgmt.rest.api.MgmtRestConstants; import org.eclipse.hawkbit.mgmt.rest.api.MgmtTargetRestApi; import org.eclipse.hawkbit.repository.DeploymentManagement; import org.eclipse.hawkbit.repository.EntityFactory; +import org.eclipse.hawkbit.repository.MaintenanceScheduleHelper; import org.eclipse.hawkbit.repository.OffsetBasedPageRequest; import org.eclipse.hawkbit.repository.TargetManagement; import org.eclipse.hawkbit.repository.exception.EntityNotFoundException; @@ -283,12 +284,17 @@ public class MgmtTargetResource implements MgmtTargetRestApi { MgmtRestModelMapper.convertActionType(dsId.getType()), dsId.getForcetime()))))); } - return ResponseEntity.ok(MgmtDistributionSetMapper.toResponse(this.deploymentManagement.assignDistributionSet( - dsId.getId(), - Arrays.asList(new TargetWithActionType(controllerId, - MgmtRestModelMapper.convertActionType(dsId.getType()), dsId.getForcetime(), - maintenanceWindow.getMaintenanceSchedule(), maintenanceWindow.getMaintenanceWindowDuration(), - maintenanceWindow.getMaintenanceWindowTimeZone()))))); + final String cronSchedule = maintenanceWindow.getMaintenanceSchedule(); + final String duration = maintenanceWindow.getMaintenanceWindowDuration(); + final String timezone = maintenanceWindow.getMaintenanceWindowTimeZone(); + + MaintenanceScheduleHelper.validateMaintenanceSchedule(cronSchedule, duration, timezone); + + return ResponseEntity + .ok(MgmtDistributionSetMapper.toResponse(this.deploymentManagement.assignDistributionSet(dsId.getId(), + Arrays.asList(new TargetWithActionType(controllerId, + MgmtRestModelMapper.convertActionType(dsId.getType()), dsId.getForcetime(), + cronSchedule, duration, timezone))))); } diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/MaintenanceScheduleHelper.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/MaintenanceScheduleHelper.java index 7ce2992dd..92975ccbf 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/MaintenanceScheduleHelper.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/MaintenanceScheduleHelper.java @@ -8,8 +8,6 @@ */ package org.eclipse.hawkbit.repository; -import static com.cronutils.model.CronType.QUARTZ; - import java.time.Duration; import java.time.LocalTime; import java.time.ZoneOffset; @@ -21,7 +19,7 @@ import org.eclipse.hawkbit.repository.exception.InvalidMaintenanceScheduleExcept import org.springframework.util.StringUtils; import com.cronutils.model.Cron; -import com.cronutils.model.definition.CronDefinition; +import com.cronutils.model.CronType; import com.cronutils.model.definition.CronDefinitionBuilder; import com.cronutils.model.time.ExecutionTime; import com.cronutils.parser.CronParser; @@ -32,62 +30,67 @@ import com.cronutils.parser.CronParser; * also provides a helper method for conversion of duration specified in * HH:mm:ss format to ISO format. */ -public class MaintenanceScheduleHelper { +public final class MaintenanceScheduleHelper { - private final ExecutionTime scheduleExecutor; + private static final CronParser cronParser = new CronParser( + CronDefinitionBuilder.instanceDefinitionFor(CronType.QUARTZ)); - /** - * Constructor that accepts a cron expression, duration and time zone and - * instantiates the cron parser and scheduler executor. - * - * @param cronSchedule - * is the cron expression to be used for scheduling the - * maintenance window. Expression has 6 mandatory fields and 1 - * last optional field: "second minute hour dayofmonth month - * weekday year" - */ - public MaintenanceScheduleHelper(final String cronSchedule) { - final CronDefinition cronDefinition = CronDefinitionBuilder.instanceDefinitionFor(QUARTZ); - final CronParser parser = new CronParser(cronDefinition); - final Cron quartzCron = parser.parse(cronSchedule); - this.scheduleExecutor = ExecutionTime.forCron(quartzCron); + private MaintenanceScheduleHelper() { + throw new IllegalStateException("Utility class"); } /** - * Method calculates the next available maintenance window within the - * schedule but after a given time. + * Calculate the next available maintenance window. * - * @param after - * is the {@link ZonedDateTime} after which the window is - * required + * @param cronSchedule + * is a cron expression with 6 mandatory fields and 1 last + * optional field: "second minute hour dayofmonth month weekday + * year". + * @param duration + * in HH:mm:ss format specifying the duration of a maintenance + * window, for example 00:30:00 for 30 minutes. + * @param timezone + * is the time zone specified as +/-hh:mm offset from UTC. For + * example +02:00 for CET summer time and +00:00 for UTC. The + * start time of a maintenance window calculated based on the + * cron expression is relative to this time zone. * * @return {@link Optional} of the next available window. In - * case there is none, returns empty value. + * case there is none, or there are maintenance window validation + * errors, returns empty value. + * */ - // Exception squid:S1166 - lib throws exception as well if no value found + // Exception squid:S1166 - if there are validation error(format of cron + // expression or duration is wrong), we simply return empty value @SuppressWarnings("squid:S1166") - public Optional nextExecution(final ZonedDateTime after) { + public static Optional getNextMaintenanceWindow(final String cronSchedule, final String duration, + final String timezone) { try { - final ZonedDateTime next = this.scheduleExecutor.nextExecution(after); - return Optional.ofNullable(next); - } catch (final IllegalArgumentException ignored) { + final ExecutionTime scheduleExecutor = ExecutionTime.forCron(getCronFromExpression(cronSchedule)); + final ZonedDateTime now = ZonedDateTime.now(ZoneOffset.of(timezone)); + final ZonedDateTime after = now.minus(convertToISODuration(duration)); + final ZonedDateTime next = scheduleExecutor.nextExecution(after); + return Optional.of(next); + } catch (final RuntimeException ignored) { return Optional.empty(); } } /** - * Method checks if there are any more valid maintenance windows after a - * given time. + * Parse the given cron expression with quartz parser. * - * @param after - * is the {@link ZonedDateTime} after which the windows are - * checked + * @param cronSchedule + * is a cron expression with 6 mandatory fields and 1 last + * optional field: "second minute hour dayofmonth month weekday + * year". * - * @return true if there is at least one valid schedule remaining, else - * false. + * @return {@link Cron} object, that corresponds to the expression. + * + * @throws IllegalArgumentException + * if the cron expression doesn't have a valid format. */ - private boolean hasValidScheduleAfter(final ZonedDateTime after) { - return nextExecution(after).isPresent(); + public static Cron getCronFromExpression(final String cronSchedule) { + return cronParser.parse(cronSchedule); } /** @@ -100,39 +103,30 @@ public class MaintenanceScheduleHelper { * @param cronSchedule * is a cron expression with 6 mandatory fields and 1 last * optional field: "second minute hour dayofmonth month weekday - * year" + * year". * @param duration * in HH:mm:ss format specifying the duration of a maintenance - * window, for example 00:30:00 for 30 minutes + * window, for example 00:30:00 for 30 minutes. * @param timezone * is the time zone specified as +/-hh:mm offset from UTC. For * example +02:00 for CET summer time and +00:00 for UTC. The * start time of a maintenance window calculated based on the - * cron expression is relative to this time zone + * cron expression is relative to this time zone. * * @throws InvalidMaintenanceScheduleException * if the defined schedule fails the validity criteria. */ public static void validateMaintenanceSchedule(final String cronSchedule, final String duration, final String timezone) { - // check if schedule, duration and timezone are all not empty. if (allNotEmpty(cronSchedule, duration, timezone)) { - final ZonedDateTime now; - try { - now = ZonedDateTime.now(ZoneOffset.of(timezone)); - Duration.parse(convertToISODuration(duration)); - } catch (final RuntimeException validationFailed) { - throw new InvalidMaintenanceScheduleException("No valid maintenance window provided", validationFailed); - } - - final MaintenanceScheduleHelper scheduleHelper = new MaintenanceScheduleHelper(cronSchedule); + validateCronSchedule(cronSchedule); + validateDuration(duration); // check if there is a window currently active or available in // future. - if (!scheduleHelper.hasValidScheduleAfter(now.minus(Duration.parse(convertToISODuration(duration))))) { + if (!getNextMaintenanceWindow(cronSchedule, duration, timezone).isPresent()) { throw new InvalidMaintenanceScheduleException( "No valid maintenance window available after current time"); } - } else if (atLeastOneNotEmpty(cronSchedule, duration, timezone)) { throw new InvalidMaintenanceScheduleException( "All of schedule, duration and timezone should either be null or non empty."); @@ -154,14 +148,61 @@ public class MaintenanceScheduleHelper { * @param timeInterval * in "HH:mm:ss" string format. This format is popularly used but * can be confused with time of the day, hence conversion to ISO - * specified format for time duration is required + * specified format for time duration is required. * - * @return the time interval or duration in ISO format + * @return {@link Duration} in ISO format. * * @throws DateTimeParseException * if the text cannot be converted to ISO format. */ - public static String convertToISODuration(final String timeInterval) { - return Duration.between(LocalTime.MIN, LocalTime.parse(timeInterval)).toString(); + public static Duration convertToISODuration(final String timeInterval) { + return Duration.between(LocalTime.MIN, convertDurationToLocalTime(timeInterval)); + } + + private static LocalTime convertDurationToLocalTime(final String timeInterval) { + return LocalTime.parse(StringUtils.trimWhitespace(timeInterval)); + } + + /** + * Validates the format of the maintenance window duration + * + * @param duration + * in "HH:mm:ss" string format. This format is popularly used but + * can be confused with time of the day, hence conversion to ISO + * specified format for time duration is required. + * + * @throws InvalidMaintenanceScheduleException + * if the duration doesn't have a valid format to be converted + * to ISO. + */ + public static void validateDuration(final String duration) { + try { + if (StringUtils.hasText(duration)) { + convertDurationToLocalTime(duration); + } + } catch (final DateTimeParseException e) { + throw new InvalidMaintenanceScheduleException("Provided duration is not valid", e, e.getErrorIndex()); + } + } + + /** + * Validates the format of the maintenance window cron expression + * + * @param cronSchedule + * is a cron expression with 6 mandatory fields and 1 last + * optional field: "second minute hour dayofmonth month weekday + * year". + * + * @throws InvalidMaintenanceScheduleException + * if the cron expression doesn't have a valid quartz format. + */ + public static void validateCronSchedule(final String cronSchedule) { + try { + if (StringUtils.hasText(cronSchedule)) { + getCronFromExpression(cronSchedule); + } + } catch (final IllegalArgumentException e) { + throw new InvalidMaintenanceScheduleException(e.getMessage(), e); + } } } diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/exception/InvalidMaintenanceScheduleException.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/exception/InvalidMaintenanceScheduleException.java index bb09b9af5..7aa68bac4 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/exception/InvalidMaintenanceScheduleException.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/exception/InvalidMaintenanceScheduleException.java @@ -20,6 +20,8 @@ import org.eclipse.hawkbit.exception.SpServerError; public class InvalidMaintenanceScheduleException extends AbstractServerRtException { private static final long serialVersionUID = 1L; + private final int durationErrorIndex; + /** * Constructor for {@link InvalidMaintenanceScheduleException}. * @@ -27,7 +29,20 @@ public class InvalidMaintenanceScheduleException extends AbstractServerRtExcepti * the message for this exception. */ public InvalidMaintenanceScheduleException(final String message) { + this(message, -1); + } + + /** + * Constructor for {@link InvalidMaintenanceScheduleException}. + * + * @param message + * the message for this exception. + * @param errorIndex + * the error index of maintenance duration. + */ + public InvalidMaintenanceScheduleException(final String message, final int errorIndex) { super(message, SpServerError.SP_MAINTENANCE_SCHEDULE_INVALID); + this.durationErrorIndex = errorIndex; } /** @@ -39,6 +54,30 @@ public class InvalidMaintenanceScheduleException extends AbstractServerRtExcepti * the cause for this exception. */ public InvalidMaintenanceScheduleException(final String message, final Throwable cause) { + this(message, cause, -1); + } + + /** + * Constructor for {@link InvalidMaintenanceScheduleException}. + * + * @param message + * the message for this exception + * @param cause + * the cause for this exception. + * @param errorIndex + * the error index of maintenance duration. + */ + public InvalidMaintenanceScheduleException(final String message, final Throwable cause, final int errorIndex) { super(message, SpServerError.SP_MAINTENANCE_SCHEDULE_INVALID, cause); + this.durationErrorIndex = errorIndex; + } + + /** + * Get the error index position for maintenance window duration. + * + * @return error index. + */ + public int getDurationErrorIndex() { + return durationErrorIndex; } } diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/TargetWithActionType.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/TargetWithActionType.java index 9178461c7..858c694af 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/TargetWithActionType.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/TargetWithActionType.java @@ -8,7 +8,6 @@ */ package org.eclipse.hawkbit.repository.model; -import org.eclipse.hawkbit.repository.MaintenanceScheduleHelper; import org.eclipse.hawkbit.repository.exception.InvalidMaintenanceScheduleException; import org.eclipse.hawkbit.repository.model.Action.ActionType; @@ -69,9 +68,6 @@ public class TargetWithActionType { this.maintenanceSchedule = maintenanceSchedule; this.maintenanceWindowDuration = maintenanceWindowDuration; this.maintenanceWindowTimeZone = maintenanceWindowTimeZone; - - MaintenanceScheduleHelper.validateMaintenanceSchedule(maintenanceSchedule, maintenanceWindowDuration, - maintenanceWindowTimeZone); } public ActionType getActionType() { diff --git a/hawkbit-repository/hawkbit-repository-api/src/test/java/org/eclipse/hawkbit/repository/MaintenanceScheduleHelperTest.java b/hawkbit-repository/hawkbit-repository-api/src/test/java/org/eclipse/hawkbit/repository/MaintenanceScheduleHelperTest.java new file mode 100644 index 000000000..b22836540 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-api/src/test/java/org/eclipse/hawkbit/repository/MaintenanceScheduleHelperTest.java @@ -0,0 +1,96 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.time.ZonedDateTime; + +import org.eclipse.hawkbit.repository.exception.InvalidMaintenanceScheduleException; +import org.junit.Test; + +import com.cronutils.model.Cron; + +import ru.yandex.qatools.allure.annotations.Description; +import ru.yandex.qatools.allure.annotations.Features; +import ru.yandex.qatools.allure.annotations.Stories; + +@Features("Unit Tests - Repository") +@Stories("Maintenance Schedule Utility") +public class MaintenanceScheduleHelperTest { + + @Test + @Description("Verifies that the Cron object is returned for valid cron expression") + public void getCronFromExpressionValid() { + final String validCron = "0 0 0 ? * 6"; // at 00:00 every Saturday + assertThat(MaintenanceScheduleHelper.getCronFromExpression(validCron)).isNotNull().isInstanceOf(Cron.class); + } + + @Test + @Description("Verifies that the Duration object is returned for valid duration format (hh:mm or hh:mm:ss)") + public void convertToISODurationValid() { + final String duration = "00:10"; + assertThat(MaintenanceScheduleHelper.convertToISODuration(duration)).isNotNull().isInstanceOf(Duration.class); + } + + @Test + @Description("Verifies that the InvalidMaintenanceScheduleException is thrown for invalid duration format") + public void validateDurationInvalid() { + final String duration = "10"; + assertThatThrownBy(() -> MaintenanceScheduleHelper.validateDuration(duration)) + .isInstanceOf(InvalidMaintenanceScheduleException.class).hasMessage("Provided duration is not valid") + .extracting("durationErrorIndex").containsExactly(2); + } + + @Test + @Description("Verifies that the InvalidMaintenanceScheduleException is thrown for invalid cron expression") + public void validateCronScheduleInvalid() { + final String invalidCron = "0 0 0 * * 6"; + assertThatThrownBy(() -> MaintenanceScheduleHelper.validateCronSchedule(invalidCron)) + .isInstanceOf(InvalidMaintenanceScheduleException.class) + .hasMessageContaining("Both, a day-of-week AND a day-of-month parameter, are not supported"); + } + + @Test + @Description("Verifies that there is a maintenance window available for correct schedule, duration and timezone") + public void getNextMaintenanceWindowValid() { + final ZonedDateTime currentTime = ZonedDateTime.now(); + final String cronSchedule = String.format("0 %d %d %d %d ? %d", currentTime.getMinute(), currentTime.getHour(), + currentTime.getDayOfMonth(), currentTime.getMonthValue(), currentTime.getYear()); + final String duration = "00:10"; + final String timezone = ZonedDateTime.now().getOffset().getId().replace("Z", "+00:00"); + assertThat(MaintenanceScheduleHelper.getNextMaintenanceWindow(cronSchedule, duration, timezone)).isPresent(); + } + + @Test + @Description("Verifies the maintenance schedule when only one required field is present") + public void validateMaintenanceScheduleAtLeastOneNotEmpty() { + final String duration = "00:10"; + assertThatThrownBy(() -> MaintenanceScheduleHelper.validateMaintenanceSchedule(null, duration, null)) + .isInstanceOf(InvalidMaintenanceScheduleException.class) + .hasMessage("All of schedule, duration and timezone should either be null or non empty."); + } + + @Test + @Description("Verifies that there is no valid maintenance window available, scheduled before current time") + public void validateMaintenanceScheduleBeforeCurrentTime() { + ZonedDateTime currentTime = ZonedDateTime.now(); + currentTime = currentTime.plusMinutes(-30); + final String cronSchedule = String.format("0 %d %d %d %d ? %d", currentTime.getMinute(), currentTime.getHour(), + currentTime.getDayOfMonth(), currentTime.getMonthValue(), currentTime.getYear()); + final String duration = "00:10"; + final String timezone = ZonedDateTime.now().getOffset().getId().replace("Z", "+00:00"); + assertThatThrownBy( + () -> MaintenanceScheduleHelper.validateMaintenanceSchedule(cronSchedule, duration, timezone)) + .isInstanceOf(InvalidMaintenanceScheduleException.class) + .hasMessage("No valid maintenance window available after current time"); + } +} diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaControllerManagement.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaControllerManagement.java index 1f97097e2..486807759 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaControllerManagement.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaControllerManagement.java @@ -252,12 +252,10 @@ public class JpaControllerManagement implements ControllerManagement { */ EventTimer(final String defaultEventInterval, final String minimumEventInterval, final TemporalUnit timeUnit) { this.defaultEventInterval = defaultEventInterval; - this.defaultEventIntervalDuration = Duration - .parse(MaintenanceScheduleHelper.convertToISODuration(defaultEventInterval)); + this.defaultEventIntervalDuration = MaintenanceScheduleHelper.convertToISODuration(defaultEventInterval); this.minimumEventInterval = minimumEventInterval; - this.minimumEventIntervalDuration = Duration - .parse(MaintenanceScheduleHelper.convertToISODuration(minimumEventInterval)); + this.minimumEventIntervalDuration = MaintenanceScheduleHelper.convertToISODuration(minimumEventInterval); this.timeUnit = timeUnit; } diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/model/JpaAction.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/model/JpaAction.java index fab0893e4..dcfa915d0 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/model/JpaAction.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/model/JpaAction.java @@ -8,7 +8,6 @@ */ package org.eclipse.hawkbit.repository.jpa.model; -import java.time.Duration; import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.util.Collections; @@ -127,11 +126,6 @@ public class JpaAction extends AbstractJpaTenantAwareBaseEntity implements Actio @Column(name = "maintenance_time_zone", updatable = false, length = Action.MAINTENANCE_WINDOW_TIMEZONE_LENGTH) private String maintenanceWindowTimeZone; - /** - * A transient (non serialized) maintenance schedule helper. - */ - private transient MaintenanceScheduleHelper scheduleHelper = null; - @Override public DistributionSet getDistributionSet() { return distributionSet; @@ -271,28 +265,6 @@ public class JpaAction extends AbstractJpaTenantAwareBaseEntity implements Actio this.maintenanceWindowTimeZone = maintenanceWindowTimeZone; } - /** - * Get the transient schedule helper. Instantiate one if not already done - * after deserialization. - * - * @return the {@link MaintenanceScheduleHelper} object. - */ - private MaintenanceScheduleHelper getScheduler() { - if (this.scheduleHelper == null) { - this.scheduleHelper = new MaintenanceScheduleHelper(maintenanceSchedule); - } - return this.scheduleHelper; - } - - /** - * Returns the duration of each maintenance window in ISO 8601 format. - * - * @return the {@link Duration} of each maintenance window. - */ - private Duration getMaintenanceWindowDuration() { - return Duration.parse(MaintenanceScheduleHelper.convertToISODuration(this.maintenanceWindowDuration)); - } - /** * Returns the start time of next available maintenance window for the * {@link Action} as {@link ZonedDateTime}. If a maintenance window is @@ -301,8 +273,8 @@ public class JpaAction extends AbstractJpaTenantAwareBaseEntity implements Actio * @return the start time as {@link Optional}. */ public Optional getMaintenanceWindowStartTime() { - final ZonedDateTime now = ZonedDateTime.now(ZoneOffset.of(maintenanceWindowTimeZone)); - return getScheduler().nextExecution(now.minus(getMaintenanceWindowDuration())); + return MaintenanceScheduleHelper.getNextMaintenanceWindow(maintenanceSchedule, maintenanceWindowDuration, + maintenanceWindowTimeZone); } /** @@ -313,7 +285,8 @@ public class JpaAction extends AbstractJpaTenantAwareBaseEntity implements Actio * @return the end time of window as {@link Optional}. */ private Optional getMaintenanceWindowEndTime() { - return getMaintenanceWindowStartTime().map(start -> start.plus(getMaintenanceWindowDuration())); + return getMaintenanceWindowStartTime() + .map(start -> start.plus(MaintenanceScheduleHelper.convertToISODuration(maintenanceWindowDuration))); } @Override @@ -323,8 +296,7 @@ public class JpaAction extends AbstractJpaTenantAwareBaseEntity implements Actio @Override public boolean isMaintenanceScheduleLapsed() { - final ZonedDateTime now = ZonedDateTime.now(ZoneOffset.of(maintenanceWindowTimeZone)); - return !getScheduler().nextExecution(now.minus(getMaintenanceWindowDuration())).isPresent(); + return !getMaintenanceWindowStartTime().isPresent(); } @Override diff --git a/hawkbit-runtime/hawkbit-update-server/src/main/resources/application.properties b/hawkbit-runtime/hawkbit-update-server/src/main/resources/application.properties index 306a72b07..3c1e67c0e 100644 --- a/hawkbit-runtime/hawkbit-update-server/src/main/resources/application.properties +++ b/hawkbit-runtime/hawkbit-update-server/src/main/resources/application.properties @@ -49,3 +49,4 @@ hawkbit.server.ui.links.documentation.rollout-view=https://www.eclipse.org/hawkb hawkbit.server.ui.links.documentation.system-configuration-view=https://www.eclipse.org/hawkbit/documentation/interfaces/management-ui.html hawkbit.server.ui.links.documentation.targetfilter-view=https://www.eclipse.org/hawkbit/documentation/interfaces/management-ui.html hawkbit.server.ui.links.documentation.upload-view=https://www.eclipse.org/hawkbit/documentation/interfaces/management-ui.html +hawkbit.server.ui.links.documentation.maintenance-window-view=http://www.quartz-scheduler.org/documentation/quartz-2.x/tutorials/crontrigger.html diff --git a/hawkbit-ui/pom.xml b/hawkbit-ui/pom.xml index 7b6812a46..84a7490d3 100644 --- a/hawkbit-ui/pom.xml +++ b/hawkbit-ui/pom.xml @@ -234,6 +234,10 @@ org.apache.commons commons-lang3 + + com.cronutils + cron-utils + diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/UiProperties.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/UiProperties.java index 559614871..97e19dc4f 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/UiProperties.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/UiProperties.java @@ -122,6 +122,11 @@ public class UiProperties implements Serializable { */ private String uploadView = ""; + /** + * Link to documentation of maintenance window view. + */ + private String maintenanceWindowView = ""; + /** * Link to documentation of system configuration view. */ @@ -174,6 +179,10 @@ public class UiProperties implements Serializable { return uploadView; } + public String getMaintenanceWindowView() { + return maintenanceWindowView; + } + public void setDeploymentView(final String deploymentView) { this.deploymentView = deploymentView; } @@ -206,6 +215,10 @@ public class UiProperties implements Serializable { this.uploadView = uploadView; } + public void setMaintenanceWindowView(final String maintenanceWindowView) { + this.maintenanceWindowView = maintenanceWindowView; + } + } private final Documentation documentation = new Documentation(); diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/common/CommonDialogWindow.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/common/CommonDialogWindow.java index 55b56c694..fad5a0a82 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/common/CommonDialogWindow.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/common/CommonDialogWindow.java @@ -41,6 +41,7 @@ import com.vaadin.data.validator.NullValidator; import com.vaadin.event.FieldEvents.TextChangeEvent; import com.vaadin.event.FieldEvents.TextChangeListener; import com.vaadin.event.FieldEvents.TextChangeNotifier; +import com.vaadin.event.ShortcutAction.KeyCode; import com.vaadin.server.FontAwesome; import com.vaadin.ui.AbstractComponent; import com.vaadin.ui.AbstractField; @@ -466,6 +467,7 @@ public class CommonDialogWindow extends Window { saveButton.addStyleName("default-color"); addCloseListenerForSaveButton(); saveButton.setEnabled(false); + saveButton.setClickShortcut(KeyCode.ENTER); buttonsLayout.addComponent(saveButton); buttonsLayout.setComponentAlignment(saveButton, Alignment.MIDDLE_RIGHT); buttonsLayout.setExpandRatio(saveButton, 1.0F); diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/common/builder/ComboBoxBuilder.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/common/builder/ComboBoxBuilder.java index a96c64c82..cc4c0134f 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/common/builder/ComboBoxBuilder.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/common/builder/ComboBoxBuilder.java @@ -26,6 +26,8 @@ public class ComboBoxBuilder { private String prompt; + private String caption; + public ComboBoxBuilder setValueChangeListener(final Property.ValueChangeListener valueChangeListener) { this.valueChangeListener = valueChangeListener; return this; @@ -36,27 +38,35 @@ public class ComboBoxBuilder { return this; } - public ComboBoxBuilder setPrompt(String prompt) { + public ComboBoxBuilder setPrompt(final String prompt) { this.prompt = prompt; return this; } + public ComboBoxBuilder setCaption(final String caption) { + this.caption = caption; + return this; + } + /** * @return a new ComboBox */ public ComboBox buildCombBox() { - final ComboBox targetFilter = SPUIComponentProvider.getComboBox(null, "", null, ValoTheme.COMBOBOX_SMALL, false, - "", prompt); - targetFilter.setImmediate(true); - targetFilter.setPageLength(7); - targetFilter.setItemCaptionPropertyId(SPUILabelDefinitions.VAR_NAME); - targetFilter.setSizeUndefined(); + final ComboBox comboBox = SPUIComponentProvider.getComboBox(null, "", null, ValoTheme.COMBOBOX_SMALL, false, "", + prompt); + comboBox.setImmediate(true); + comboBox.setPageLength(7); + comboBox.setItemCaptionPropertyId(SPUILabelDefinitions.VAR_NAME); + comboBox.setSizeUndefined(); + if (caption != null) { + comboBox.setCaption(caption); + } if (id != null) { - targetFilter.setId(id); + comboBox.setId(id); } if (valueChangeListener != null) { - targetFilter.addValueChangeListener(valueChangeListener); + comboBox.addValueChangeListener(valueChangeListener); } - return targetFilter; + return comboBox; } } diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/common/confirmwindow/layout/ConfirmationTab.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/common/confirmwindow/layout/ConfirmationTab.java index ea869a52c..293cd7c97 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/common/confirmwindow/layout/ConfirmationTab.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/common/confirmwindow/layout/ConfirmationTab.java @@ -24,9 +24,6 @@ import com.vaadin.ui.themes.ValoTheme; /** * Confirmation tab of confirmation window. - * - * - * */ public class ConfirmationTab extends VerticalLayout { diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/DeploymentView.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/DeploymentView.java index 042a10219..b48ae3556 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/DeploymentView.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/DeploymentView.java @@ -159,7 +159,7 @@ public class DeploymentView extends AbstractNotificationView implements BrowserW uiExecutor); this.deleteAndActionsLayout = new DeleteActionsLayout(i18n, permChecker, eventBus, uiNotification, targetTagManagement, distributionSetTagManagement, managementViewClientCriterion, managementUIState, - targetManagement, targetTable, deploymentManagement, distributionSetManagement); + targetManagement, targetTable, deploymentManagement, distributionSetManagement, uiproperties); actionHistoryLayout.registerDetails(((ActionStatusGrid) actionStatusLayout.getGrid()).getDetailsSupport()); actionStatusLayout diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/footer/DeleteActionsLayout.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/footer/DeleteActionsLayout.java index 2c0b6723a..c65cef749 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/footer/DeleteActionsLayout.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/footer/DeleteActionsLayout.java @@ -24,6 +24,7 @@ import org.eclipse.hawkbit.repository.TargetTagManagement; import org.eclipse.hawkbit.repository.model.DistributionSet; import org.eclipse.hawkbit.repository.model.Target; import org.eclipse.hawkbit.ui.SpPermissionChecker; +import org.eclipse.hawkbit.ui.UiProperties; import org.eclipse.hawkbit.ui.common.entity.DistributionSetIdName; import org.eclipse.hawkbit.ui.common.entity.TargetIdName; import org.eclipse.hawkbit.ui.common.footer.AbstractDeleteActionsLayout; @@ -72,7 +73,7 @@ public class DeleteActionsLayout extends AbstractDeleteActionsLayout { private final ManagementUIState managementUIState; - private final ManangementConfirmationWindowLayout manangementConfirmationWindowLayout; + private final ManagementConfirmationWindowLayout managementConfirmationWindowLayout; private final CountMessageLabel countMessageLabel; @@ -86,14 +87,15 @@ public class DeleteActionsLayout extends AbstractDeleteActionsLayout { final ManagementViewClientCriterion managementViewClientCriterion, final ManagementUIState managementUIState, final TargetManagement targetManagement, final TargetTable targetTable, final DeploymentManagement deploymentManagement, - final DistributionSetManagement distributionSetManagement) { + final DistributionSetManagement distributionSetManagement, final UiProperties uiProperties) { super(i18n, permChecker, eventBus, notification); this.distributionSetTagManagement = distributionSetTagManagement; this.targetTagManagement = targetTagManagement; this.managementViewClientCriterion = managementViewClientCriterion; this.managementUIState = managementUIState; - this.manangementConfirmationWindowLayout = new ManangementConfirmationWindowLayout(i18n, eventBus, - managementUIState, targetManagement, deploymentManagement, distributionSetManagement); + this.managementConfirmationWindowLayout = new ManagementConfirmationWindowLayout(i18n, eventBus, + managementUIState, targetManagement, deploymentManagement, distributionSetManagement, uiProperties, + notification); this.countMessageLabel = new CountMessageLabel(eventBus, targetManagement, i18n, managementUIState, targetTable); this.targetManagement = targetManagement; @@ -114,7 +116,7 @@ public class DeleteActionsLayout extends AbstractDeleteActionsLayout { UI.getCurrent().access(() -> { if (!hasUnsavedActions()) { closeUnsavedActionsWindow(); - final String message = manangementConfirmationWindowLayout.getConsolidatedMessage(); + final String message = managementConfirmationWindowLayout.getConsolidatedMessage(); if (message != null && message.length() > 0) { notification.displaySuccess(message); } @@ -215,7 +217,7 @@ public class DeleteActionsLayout extends AbstractDeleteActionsLayout { @Override protected void unsavedActionsWindowClosed() { - final String message = manangementConfirmationWindowLayout.getConsolidatedMessage(); + final String message = managementConfirmationWindowLayout.getConsolidatedMessage(); if (message != null && message.length() > 0) { notification.displaySuccess(message); } @@ -223,8 +225,8 @@ public class DeleteActionsLayout extends AbstractDeleteActionsLayout { @Override protected Component getUnsavedActionsWindowContent() { - manangementConfirmationWindowLayout.initialize(); - return manangementConfirmationWindowLayout; + managementConfirmationWindowLayout.initialize(); + return managementConfirmationWindowLayout; } @Override @@ -248,7 +250,7 @@ public class DeleteActionsLayout extends AbstractDeleteActionsLayout { final String tagName = HawkbitCommonUtil.removePrefix(source.getId(), SPUIDefinitions.DISTRIBUTION_TAG_ID_PREFIXS); if (managementUIState.getDistributionTableFilters().getDistSetTags().contains(tagName)) { - notification.displayValidationError(i18n.getMessage("message.tag.delete", new Object[] { tagName })); + notification.displayValidationError(i18n.getMessage("message.tag.delete", tagName)); } else { distributionSetTagManagement.delete(tagName); @@ -258,14 +260,14 @@ public class DeleteActionsLayout extends AbstractDeleteActionsLayout { new DistributionSetTagTableEvent(BaseEntityEventType.REMOVE_ENTITY, Arrays.asList(id))); } - notification.displaySuccess(i18n.getMessage("message.delete.success", new Object[] { tagName })); + notification.displaySuccess(i18n.getMessage("message.delete.success", tagName)); } } private void deleteTargetTag(final Component source) { final String tagName = HawkbitCommonUtil.removePrefix(source.getId(), SPUIDefinitions.TARGET_TAG_ID_PREFIXS); if (managementUIState.getTargetTableFilters().getClickedTargetTags().contains(tagName)) { - notification.displayValidationError(i18n.getMessage("message.tag.delete", new Object[] { tagName })); + notification.displayValidationError(i18n.getMessage("message.tag.delete", tagName)); } else { targetTagManagement.delete(tagName); @@ -274,7 +276,7 @@ public class DeleteActionsLayout extends AbstractDeleteActionsLayout { eventBus.publish(this, new TargetTagTableEvent(BaseEntityEventType.REMOVE_ENTITY, Arrays.asList(id))); } - notification.displaySuccess(i18n.getMessage("message.delete.success", new Object[] { tagName })); + notification.displaySuccess(i18n.getMessage("message.delete.success", tagName)); } } diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/footer/MaintenanceWindowLayout.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/footer/MaintenanceWindowLayout.java index 79e780b6f..d4029b2bd 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/footer/MaintenanceWindowLayout.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/footer/MaintenanceWindowLayout.java @@ -11,22 +11,26 @@ package org.eclipse.hawkbit.ui.management.footer; import java.time.ZoneId; import java.time.ZonedDateTime; -import java.time.format.DateTimeParseException; import java.util.List; +import java.util.Locale; import java.util.stream.Collectors; import org.eclipse.hawkbit.repository.MaintenanceScheduleHelper; +import org.eclipse.hawkbit.repository.exception.InvalidMaintenanceScheduleException; +import org.eclipse.hawkbit.ui.common.builder.LabelBuilder; +import org.eclipse.hawkbit.ui.common.builder.TextFieldBuilder; import org.eclipse.hawkbit.ui.utils.SPDateTimeUtil; +import org.eclipse.hawkbit.ui.utils.UIComponentIdProvider; import org.eclipse.hawkbit.ui.utils.VaadinMessageSource; -import org.springframework.util.StringUtils; -import com.vaadin.data.Property.ValueChangeEvent; -import com.vaadin.data.Property.ValueChangeListener; +import com.cronutils.descriptor.CronDescriptor; import com.vaadin.data.Validator; -import com.vaadin.ui.CheckBox; +import com.vaadin.event.FieldEvents.TextChangeEvent; +import com.vaadin.event.FieldEvents.TextChangeListener; +import com.vaadin.server.Page; import com.vaadin.ui.ComboBox; import com.vaadin.ui.HorizontalLayout; -import com.vaadin.ui.Notification; +import com.vaadin.ui.Label; import com.vaadin.ui.TextField; import com.vaadin.ui.VerticalLayout; import com.vaadin.ui.themes.ValoTheme; @@ -36,15 +40,16 @@ import com.vaadin.ui.themes.ValoTheme; * maintenance schedule while assigning distribution set(s) to the target(s). */ public class MaintenanceWindowLayout extends VerticalLayout { - - private static final long serialVersionUID = 722511089585562455L; + private static final long serialVersionUID = 1L; private final VaadinMessageSource i18n; - private CheckBox maintenanceWindowSelection; + private static final String CRON_VALIDATION_ERROR = "message.maintenancewindow.schedule.validation.error"; + private TextField schedule; private TextField duration; private ComboBox timeZone; + private Label scheduleTranslator; /** * Constructor for the control to specify the maintenance schedule. @@ -55,123 +60,137 @@ public class MaintenanceWindowLayout extends VerticalLayout { */ public MaintenanceWindowLayout(final VaadinMessageSource i18n) { - HorizontalLayout optionContainer; - HorizontalLayout controlContainer; - this.i18n = i18n; - optionContainer = new HorizontalLayout(); - controlContainer = new HorizontalLayout(); - addComponent(optionContainer); - addComponent(controlContainer); - - createMaintenanceWindowOption(); createMaintenanceScheduleControl(); createMaintenanceDurationControl(); createMaintenanceTimeZoneControl(); + createMaintenanceScheduleTranslatorControl(); - optionContainer.addComponent(maintenanceWindowSelection); + final HorizontalLayout controlContainer = new HorizontalLayout(); controlContainer.addComponent(schedule); controlContainer.addComponent(duration); controlContainer.addComponent(timeZone); + addComponent(controlContainer); + + addComponent(scheduleTranslator); - addValueChangeListener(); - maintenanceWindowSelection.setValue(false); setStyleName("dist-window-maintenance-window-layout"); - } - - /** - * Validates if the maintenance schedule is a valid cron expression. - */ - private static class CronValidation implements Validator { - private static final long serialVersionUID = 1L; - - @Override - public void validate(final Object value) throws InvalidValueException { - try { - final String expr = (String) value; - if (!expr.isEmpty()) { - MaintenanceScheduleHelper.validateMaintenanceSchedule((String) value, "00:00:00", - getClientTimeZone()); - } - } catch (final IllegalArgumentException e) { - Notification.show(e.getMessage()); - throw e; - } - } - } - - /** - * Validates if the duration is specified in expected format. - */ - private static class DurationValidator implements Validator { - private static final long serialVersionUID = 1L; - - @Override - public void validate(final Object value) { - try { - final String expr = (String) value; - if (!StringUtils.isEmpty(expr)) { - MaintenanceScheduleHelper.convertToISODuration((String) value); - } - } catch (final DateTimeParseException e) { - Notification.show(e.getMessage()); - throw e; - } - } - } - - /** - * Create check box to enable or disable maintenance window. - */ - private void createMaintenanceWindowOption() { - maintenanceWindowSelection = new CheckBox(i18n.getMessage("caption.maintenancewindow.enable")); - maintenanceWindowSelection.addStyleName(ValoTheme.CHECKBOX_SMALL); + setId(UIComponentIdProvider.MAINTENANCE_WINDOW_LAYOUT_ID); } /** * Text field to specify the schedule. */ private void createMaintenanceScheduleControl() { - schedule = new TextField(); - schedule.setCaption(i18n.getMessage("caption.maintenancewindow.schedule")); - schedule.addValidator(new CronValidation()); - schedule.setEnabled(false); - schedule.addStyleName(ValoTheme.TEXTFIELD_SMALL); + schedule = new TextFieldBuilder().id(UIComponentIdProvider.MAINTENANCE_WINDOW_SCHEDULE_ID) + .caption(i18n.getMessage("caption.maintenancewindow.schedule")).immediate(true) + .validator(new CronValidator()).prompt("0 0 3 ? * 6").required(true).buildTextComponent(); + schedule.addTextChangeListener(new CronTranslationListener()); + } + + /** + * Validates if the maintenance schedule is a valid cron expression. + */ + private class CronValidator implements Validator { + private static final long serialVersionUID = 1L; + + // Exception squid:S1166 - Vaadin validation class, + // InvalidValueException, + // doesn't have the constructor to pass throwable, but shows the + // validation + // errors to the user + @SuppressWarnings("squid:S1166") + @Override + public void validate(final Object value) { + try { + MaintenanceScheduleHelper.validateCronSchedule((String) value); + } catch (final InvalidMaintenanceScheduleException e) { + throw new InvalidValueException(i18n.getMessage(CRON_VALIDATION_ERROR) + ": " + e.getMessage()); + } + } + } + + /** + * Used for cron expression translation. + */ + private class CronTranslationListener implements TextChangeListener { + private static final long serialVersionUID = 1L; + + private final transient CronDescriptor cronDescriptor; + + public CronTranslationListener() { + cronDescriptor = CronDescriptor.instance(getClientsLocale()); + } + + @Override + public void textChange(final TextChangeEvent event) { + scheduleTranslator.setValue(translateCron(event.getText())); + } + + // Exception squid:S1166 - when the format of the cron expression is not + // valid, the hint is shown to provide the valid one + @SuppressWarnings("squid:S1166") + private String translateCron(final String cronExpression) { + try { + return cronDescriptor.describe(MaintenanceScheduleHelper.getCronFromExpression(cronExpression)); + } catch (final IllegalArgumentException ex) { + return i18n.getMessage(CRON_VALIDATION_ERROR); + } + } + + private Locale getClientsLocale() { + return Page.getCurrent().getWebBrowser().getLocale(); + } } /** * Text field to specify the duration. */ private void createMaintenanceDurationControl() { - duration = new TextField(); - duration.setCaption(i18n.getMessage("caption.maintenancewindow.duration")); - duration.addValidator(new DurationValidator()); - duration.setEnabled(false); - schedule.addStyleName(ValoTheme.TEXTFIELD_SMALL); + duration = new TextFieldBuilder().id(UIComponentIdProvider.MAINTENANCE_WINDOW_DURATION_ID) + .caption(i18n.getMessage("caption.maintenancewindow.duration")).immediate(true) + .validator(new DurationValidator()).prompt("hh:mm:ss").required(true).buildTextComponent(); + } + + /** + * Validates if the duration is specified in expected format. + */ + private class DurationValidator implements Validator { + private static final long serialVersionUID = 1L; + + // Exception squid:S1166 - Vaadin validation class, + // InvalidValueException, + // doesn't have the constructor to pass throwable, but shows the + // validation + // errors to the user + @SuppressWarnings("squid:S1166") + @Override + public void validate(final Object value) { + try { + MaintenanceScheduleHelper.validateDuration((String) value); + } catch (final InvalidMaintenanceScheduleException e) { + throw new InvalidValueException(i18n.getMessage("message.maintenancewindow.duration.validation.error", + e.getDurationErrorIndex())); + } + } } /** * Combo box to pick the time zone offset. */ private void createMaintenanceTimeZoneControl() { + // ComboBoxBuilder cannot be used here, because Builder do + // 'comboBox.setItemCaptionPropertyId(SPUILabelDefinitions.VAR_NAME);' + // which interferes our code: 'timeZone.addItems(getAllTimeZones());' timeZone = new ComboBox(); + timeZone.setId(UIComponentIdProvider.MAINTENANCE_WINDOW_TIME_ZONE_ID); timeZone.setCaption(i18n.getMessage("caption.maintenancewindow.timezone")); - timeZone.addItems(getAllTimeZones()); - timeZone.setTextInputAllowed(false); timeZone.setValue(getClientTimeZone()); - - timeZone.setEnabled(false); timeZone.addStyleName(ValoTheme.COMBOBOX_SMALL); - } - - /** - * Get time zone of the browser client to be used as default. - */ - private static String getClientTimeZone() { - return ZonedDateTime.now(ZoneId.of(SPDateTimeUtil.getBrowserTimeZone().getID())).getOffset().getId() - .replaceAll("Z", "+00:00"); + timeZone.setTextInputAllowed(false); + timeZone.setNullSelectionAllowed(false); } /** @@ -186,36 +205,20 @@ public class MaintenanceWindowLayout extends VerticalLayout { } /** - * Create a listener to enable and disable maintenance schedule controls. + * Get time zone of the browser client to be used as default. */ - private void addValueChangeListener() { - maintenanceWindowSelection.addValueChangeListener(new ValueChangeListener() { - private static final long serialVersionUID = 1L; - - @Override - public void valueChange(final ValueChangeEvent event) { - schedule.setEnabled(maintenanceWindowSelection.getValue()); - schedule.setRequired(maintenanceWindowSelection.getValue()); - schedule.setValue(""); - - duration.setEnabled(maintenanceWindowSelection.getValue()); - duration.setRequired(maintenanceWindowSelection.getValue()); - duration.setValue(""); - - timeZone.setEnabled(maintenanceWindowSelection.getValue()); - timeZone.setRequired(maintenanceWindowSelection.getValue()); - timeZone.setValue(getClientTimeZone()); - } - }); + private static String getClientTimeZone() { + return ZonedDateTime.now(ZoneId.of(SPDateTimeUtil.getBrowserTimeZone().getID())).getOffset().getId() + .replaceAll("Z", "+00:00"); } /** - * Get whether the maintenance schedule option is enabled or not. - * - * @return boolean. + * Label to translate the cron schedule to human readable format. */ - public boolean isMaintenanceWindowEnabled() { - return maintenanceWindowSelection.getValue(); + private void createMaintenanceScheduleTranslatorControl() { + scheduleTranslator = new LabelBuilder().id(UIComponentIdProvider.MAINTENANCE_WINDOW_SCHEDULE_TRANSLATOR_ID) + .name(i18n.getMessage(CRON_VALIDATION_ERROR)).buildLabel(); + scheduleTranslator.addStyleName(ValoTheme.LABEL_TINY); } /** @@ -237,7 +240,7 @@ public class MaintenanceWindowLayout extends VerticalLayout { } /** - * Get the cron expression for maintenance window timezone. + * Get the timezone for maintenance window. * * @return {@link String}. */ @@ -245,4 +248,55 @@ public class MaintenanceWindowLayout extends VerticalLayout { public String getMaintenanceTimeZone() { return timeZone.getValue().toString(); } + + /** + * Set all the controls to their default values. + */ + public void clearAllControls() { + schedule.setValue(""); + duration.setValue(""); + timeZone.setValue(getClientTimeZone()); + } + + /** + * Method, used for validity check, when schedule text is changed. + * + * @param event + * (@link TextChangeEvent} the event object after schedule text + * change. + * @return validity of maintenance window controls. + */ + public boolean onScheduleChange(final TextChangeEvent event) { + schedule.setValue(event.getText()); + return isScheduleAndDurationValid(); + } + + /** + * Method, used for validity check, when duration text is changed. + * + * @param event + * (@link TextChangeEvent} the event object after duration text + * change. + * @return validity of maintenance window controls. + */ + public boolean onDurationChange(final TextChangeEvent event) { + duration.setValue(event.getText()); + return isScheduleAndDurationValid(); + } + + private boolean isScheduleAndDurationValid() { + if (schedule.isEmpty() || duration.isEmpty()) { + return false; + } + + return schedule.isValid() && duration.isValid(); + } + + public TextField getScheduleControl() { + return schedule; + } + + public TextField getDurationControl() { + return duration; + } } diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/footer/ManangementConfirmationWindowLayout.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/footer/ManagementConfirmationWindowLayout.java similarity index 83% rename from hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/footer/ManangementConfirmationWindowLayout.java rename to hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/footer/ManagementConfirmationWindowLayout.java index 8dfbbcffc..68b6471b6 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/footer/ManangementConfirmationWindowLayout.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/footer/ManagementConfirmationWindowLayout.java @@ -21,16 +21,20 @@ import java.util.stream.Collectors; import org.eclipse.hawkbit.repository.DeploymentManagement; import org.eclipse.hawkbit.repository.DistributionSetManagement; +import org.eclipse.hawkbit.repository.MaintenanceScheduleHelper; import org.eclipse.hawkbit.repository.TargetManagement; -import org.eclipse.hawkbit.repository.model.TargetWithActionType; +import org.eclipse.hawkbit.repository.exception.InvalidMaintenanceScheduleException; import org.eclipse.hawkbit.repository.model.Action.ActionType; import org.eclipse.hawkbit.repository.model.DistributionSetAssignmentResult; import org.eclipse.hawkbit.repository.model.RepositoryModelConstants; +import org.eclipse.hawkbit.repository.model.TargetWithActionType; +import org.eclipse.hawkbit.ui.UiProperties; import org.eclipse.hawkbit.ui.common.confirmwindow.layout.AbstractConfirmationWindowLayout; import org.eclipse.hawkbit.ui.common.confirmwindow.layout.ConfirmationTab; import org.eclipse.hawkbit.ui.common.entity.DistributionSetIdName; import org.eclipse.hawkbit.ui.common.entity.TargetIdName; import org.eclipse.hawkbit.ui.common.table.BaseEntityEventType; +import org.eclipse.hawkbit.ui.components.SPUIComponentProvider; import org.eclipse.hawkbit.ui.management.event.DistributionTableEvent; import org.eclipse.hawkbit.ui.management.event.PinUnpinEvent; import org.eclipse.hawkbit.ui.management.event.SaveActionWindowEvent; @@ -41,6 +45,7 @@ import org.eclipse.hawkbit.ui.utils.HawkbitCommonUtil; import org.eclipse.hawkbit.ui.utils.SPUIDefinitions; import org.eclipse.hawkbit.ui.utils.SPUILabelDefinitions; import org.eclipse.hawkbit.ui.utils.UIComponentIdProvider; +import org.eclipse.hawkbit.ui.utils.UINotification; import org.eclipse.hawkbit.ui.utils.VaadinMessageSource; import org.vaadin.spring.events.EventBus.UIEventBus; @@ -49,13 +54,17 @@ import com.vaadin.data.Item; import com.vaadin.data.util.IndexedContainer; import com.vaadin.server.FontAwesome; import com.vaadin.ui.Button.ClickListener; +import com.vaadin.ui.CheckBox; +import com.vaadin.ui.HorizontalLayout; +import com.vaadin.ui.Link; import com.vaadin.ui.Table.Align; +import com.vaadin.ui.themes.ValoTheme; /** * Confirmation window for target/distributionSet delete and assignment * operations on the Deployment View. */ -public class ManangementConfirmationWindowLayout extends AbstractConfirmationWindowLayout { +public class ManagementConfirmationWindowLayout extends AbstractConfirmationWindowLayout { private static final long serialVersionUID = 1L; @@ -69,6 +78,10 @@ public class ManangementConfirmationWindowLayout extends AbstractConfirmationWin private static final String TARGET_ID = "TargetId"; + private final UiProperties uiProperties; + + private final UINotification uiNotification; + private final ManagementUIState managementUIState; private final transient TargetManagement targetManagement; @@ -83,11 +96,13 @@ public class ManangementConfirmationWindowLayout extends AbstractConfirmationWin private ConfirmationTab assignmentTab; - public ManangementConfirmationWindowLayout(final VaadinMessageSource i18n, final UIEventBus eventBus, + public ManagementConfirmationWindowLayout(final VaadinMessageSource i18n, final UIEventBus eventBus, final ManagementUIState managementUIState, final TargetManagement targetManagement, - final DeploymentManagement deploymentManagement, - final DistributionSetManagement distributionSetManagement) { + final DeploymentManagement deploymentManagement, final DistributionSetManagement distributionSetManagement, + final UiProperties uiProperties, final UINotification uiNotification) { super(i18n, eventBus); + this.uiProperties = uiProperties; + this.uiNotification = uiNotification; this.managementUIState = managementUIState; this.targetManagement = targetManagement; this.deploymentManagement = deploymentManagement; @@ -142,12 +157,61 @@ public class ManangementConfirmationWindowLayout extends AbstractConfirmationWin actionTypeOptionGroupLayout.selectDefaultOption(); assignmentTab.addComponent(actionTypeOptionGroupLayout, 1); - assignmentTab.addComponent(maintenanceWindowLayout, 1); + assignmentTab.addComponent(enableMaintenanceWindowLayout(), 2); + initMaintenanceWindow(); + assignmentTab.addComponent(maintenanceWindowLayout, 3); + return assignmentTab; } + private HorizontalLayout enableMaintenanceWindowLayout() { + final HorizontalLayout layout = new HorizontalLayout(); + layout.addComponent(enableMaintenanceWindowControl()); + layout.addComponent(maintenanceWindowHelpLinkControl()); + + return layout; + } + + private CheckBox enableMaintenanceWindowControl() { + final CheckBox enableMaintenanceWindow = new CheckBox(i18n.getMessage("caption.maintenancewindow.enabled")); + enableMaintenanceWindow.setId(UIComponentIdProvider.MAINTENANCE_WINDOW_ENABLED_ID); + enableMaintenanceWindow.addStyleName(ValoTheme.CHECKBOX_SMALL); + enableMaintenanceWindow.addStyleName("dist-window-maintenance-window-enable"); + enableMaintenanceWindow.addValueChangeListener(event -> { + final Boolean isMaintenanceWindowEnabled = enableMaintenanceWindow.getValue(); + maintenanceWindowLayout.setVisible(isMaintenanceWindowEnabled); + maintenanceWindowLayout.setEnabled(isMaintenanceWindowEnabled); + enableSaveButton(!isMaintenanceWindowEnabled); + maintenanceWindowLayout.clearAllControls(); + }); + + return enableMaintenanceWindow; + } + + private void enableSaveButton(final boolean enabled) { + assignmentTab.getConfirmAll().setEnabled(enabled); + } + + private Link maintenanceWindowHelpLinkControl() { + final String maintenanceWindowHelpUrl = uiProperties.getLinks().getDocumentation().getMaintenanceWindowView(); + return SPUIComponentProvider.getHelpLink(maintenanceWindowHelpUrl); + } + + private void initMaintenanceWindow() { + maintenanceWindowLayout.setVisible(false); + maintenanceWindowLayout.setEnabled(false); + maintenanceWindowLayout.getScheduleControl() + .addTextChangeListener(event -> enableSaveButton(maintenanceWindowLayout.onScheduleChange(event))); + maintenanceWindowLayout.getDurationControl() + .addTextChangeListener(event -> enableSaveButton(maintenanceWindowLayout.onDurationChange(event))); + } + private void saveAllAssignments(final ConfirmationTab tab) { + if (!isMaintenanceWindowValid()) { + return; + } + final Set itemIds = managementUIState.getAssignedList().keySet(); Long distId; List targetIdSetList; @@ -159,15 +223,6 @@ public class ManangementConfirmationWindowLayout extends AbstractConfirmationWin ? actionTypeOptionGroupLayout.getForcedTimeDateField().getValue().getTime() : RepositoryModelConstants.NO_FORCE_TIME; - final String maintenanceSchedule = maintenanceWindowLayout.isMaintenanceWindowEnabled() - ? maintenanceWindowLayout.getMaintenanceSchedule() : null; - - final String maintenanceDuration = maintenanceWindowLayout.isMaintenanceWindowEnabled() - ? maintenanceWindowLayout.getMaintenanceDuration() : null; - - final String maintenanceTimeZone = maintenanceWindowLayout.isMaintenanceWindowEnabled() - ? maintenanceWindowLayout.getMaintenanceTimeZone() : null; - final Map> saveAssignedList = Maps.newHashMapWithExpectedSize(itemIds.size()); int successAssignmentCount = 0; @@ -185,13 +240,18 @@ public class ManangementConfirmationWindowLayout extends AbstractConfirmationWin saveAssignedList.put(distId, targetIdSetList); } + final String maintenanceSchedule = maintenanceWindowLayout.getMaintenanceSchedule(); + final String maintenanceDuration = maintenanceWindowLayout.getMaintenanceDuration(); + final String maintenanceTimeZone = maintenanceWindowLayout.getMaintenanceTimeZone(); + for (final Map.Entry> mapEntry : saveAssignedList.entrySet()) { tempIdList = saveAssignedList.get(mapEntry.getKey()); final DistributionSetAssignmentResult distributionSetAssignmentResult = deploymentManagement .assignDistributionSet(mapEntry.getKey(), - tempIdList.stream() - .map(t -> new TargetWithActionType(t.getControllerId(), actionType, forcedTimeStamp, - maintenanceSchedule, maintenanceDuration, maintenanceTimeZone)) + tempIdList.stream().map(t -> maintenanceWindowLayout.isEnabled() + ? new TargetWithActionType(t.getControllerId(), actionType, forcedTimeStamp, + maintenanceSchedule, maintenanceDuration, maintenanceTimeZone) + : new TargetWithActionType(t.getControllerId(), actionType, forcedTimeStamp)) .collect(Collectors.toList())); if (distributionSetAssignmentResult.getAssigned() > 0) { @@ -210,6 +270,24 @@ public class ManangementConfirmationWindowLayout extends AbstractConfirmationWin eventBus.publish(this, SaveActionWindowEvent.SAVED_ASSIGNMENTS); } + // Exception squid:S1166 - the user is notified when the maintenance + // schedule validation fails + @SuppressWarnings("squid:S1166") + private boolean isMaintenanceWindowValid() { + if (maintenanceWindowLayout.isEnabled()) { + try { + MaintenanceScheduleHelper.validateMaintenanceSchedule(maintenanceWindowLayout.getMaintenanceSchedule(), + maintenanceWindowLayout.getMaintenanceDuration(), + maintenanceWindowLayout.getMaintenanceTimeZone()); + } catch (final InvalidMaintenanceScheduleException e) { + uiNotification.displayValidationError(e.getMessage()); + return false; + } + } + + return true; + } + private void addMessage(final int successAssignmentCount, final int duplicateAssignmentCount) { if (successAssignmentCount > 0) { addToConsolitatedMsg(getAssigmentSuccessMessage(successAssignmentCount)); @@ -239,12 +317,12 @@ public class ManangementConfirmationWindowLayout extends AbstractConfirmationWin private String getAssigmentSuccessMessage(final int assignedCount) { return FontAwesome.TASKS.getHtml() + SPUILabelDefinitions.HTML_SPACE - + i18n.getMessage("message.target.assignment", new Object[] { assignedCount }); + + i18n.getMessage("message.target.assignment", assignedCount); } private String getDuplicateAssignmentMessage(final int alreadyAssignedCount) { return FontAwesome.TASKS.getHtml() + SPUILabelDefinitions.HTML_SPACE - + i18n.getMessage("message.target.alreadyAssigned", new Object[] { alreadyAssignedCount }); + + i18n.getMessage("message.target.alreadyAssigned", alreadyAssignedCount); } private void discardAllAssignments(final ConfirmationTab tab) { diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/utils/UIComponentIdProvider.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/utils/UIComponentIdProvider.java index 2715735b2..4fdc6d795 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/utils/UIComponentIdProvider.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/utils/UIComponentIdProvider.java @@ -294,6 +294,36 @@ public final class UIComponentIdProvider { */ public static final String ACTION_BUTTON_LAYOUT = "ActionButtonLayout"; + /** + * Id for maintenance window layout + */ + public static final String MAINTENANCE_WINDOW_LAYOUT_ID = "maintenance.window.layout"; + + /** + * Id for maintenance window - enabled checkbox + */ + public static final String MAINTENANCE_WINDOW_ENABLED_ID = "maintenance.window.enabled"; + + /** + * Id for maintenance window - field schedule + */ + public static final String MAINTENANCE_WINDOW_SCHEDULE_ID = "maintenance.window.schedule"; + + /** + * Id for maintenance window - field duration + */ + public static final String MAINTENANCE_WINDOW_DURATION_ID = "maintenance.window.duration"; + + /** + * Id for maintenance window - field time zone + */ + public static final String MAINTENANCE_WINDOW_TIME_ZONE_ID = "maintenance.window.time.zone"; + + /** + * Id for maintenance window - label schedule translator + */ + public static final String MAINTENANCE_WINDOW_SCHEDULE_TRANSLATOR_ID = "maintenance.window.schedule.translator"; + /** * Delete button wrapper id. */ diff --git a/hawkbit-ui/src/main/resources/VAADIN/themes/hawkbit/customstyles/popup-window.scss b/hawkbit-ui/src/main/resources/VAADIN/themes/hawkbit/customstyles/popup-window.scss index 48f4b2717..cb936685b 100644 --- a/hawkbit-ui/src/main/resources/VAADIN/themes/hawkbit/customstyles/popup-window.scss +++ b/hawkbit-ui/src/main/resources/VAADIN/themes/hawkbit/customstyles/popup-window.scss @@ -83,39 +83,36 @@ @include sp-button-icon-only-href; } + .v-slot-dist-window-maintenance-window-enable { + padding-left: 9px; + padding-top: 5px; + padding-right: 5px; + font-weight: 400; + } + .dist-window-maintenance-window-layout { vertical-align: middle; horizontal-align: middle; + padding-left: 14px; .v-slot { vertical-align: middle; - padding-left: 8px; - padding-right: 5px; + padding-right: 20px; } .v-caption { vertical-align: middle; margin-top: 0; - font-size: 12px; - } - - .v-checkbox { - font-size: 12px; - font-weight: 400; } .v-filterselect { - font-size: 12px; font-weight: 400; width: 160px; - height: 24px; } .v-textfield { - font-size: 12px; font-weight: 400; width: 160px; - height: 24px; } } @@ -142,10 +139,6 @@ .v-caption { vertical-align: middle; - margin-top: 0; - font-size: 12px; - padding-right: 0; - padding-left: 0; } .v-radiobutton { diff --git a/hawkbit-ui/src/main/resources/messages.properties b/hawkbit-ui/src/main/resources/messages.properties index ffaab0ae2..8d45a8efc 100644 --- a/hawkbit-ui/src/main/resources/messages.properties +++ b/hawkbit-ui/src/main/resources/messages.properties @@ -139,10 +139,10 @@ caption.metadata.delete.action.confirmbox = Confirm Metadata Delete Action caption.confirm.assign.consequences = Auto assign consequences caption.auto.assignment.ds = Auto assignment -caption.maintenancewindow.enable = Maintenance Schedule -caption.maintenancewindow.schedule = Schedule (Cron Expression) -caption.maintenancewindow.duration = Duration (hh:mm:ss) -caption.maintenancewindow.timezone = Time Zone +caption.maintenancewindow.enabled = Use maintenance window +caption.maintenancewindow.schedule = Schedule +caption.maintenancewindow.duration = Duration +caption.maintenancewindow.timezone = Time Zone (from GMT) # Labels prefix with - label label.dist.details.type = Type : @@ -367,6 +367,8 @@ message.bulk.upload.tag.assignment.failed = Tag {0} assignment failed as tag no message.bulk.upload.tag.assignments.failed= Few tag assignments failed as tags no longer exists message.confirm.assign.consequences.none = This auto assignment will not have any effect on the currently available targets. In future added targets might match the filter and will receive the selected distribution set automatically. message.confirm.assign.consequences.text = When you confirm this auto assignment, {0} targets which match the filter will immediately get assigned with the selected distribution set. +message.maintenancewindow.schedule.validation.error = Please enter a valid Cron expression +message.maintenancewindow.duration.validation.error = Please enter the duration in the format hh:mm:ss, error is at {0} position # action info action.target.table.selectall = Select all (Ctrl+A)