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)