Remove 'Simple' from Simple UI (#2809)

Signed-off-by: Avgustin Marinov <Avgustin.Marinov@bosch.com>
This commit is contained in:
Avgustin Marinov
2025-11-17 09:03:27 +02:00
committed by GitHub
parent c5ea265e0f
commit bcf62f39e7
47 changed files with 134 additions and 127 deletions

View File

@@ -0,0 +1,7 @@
a.nocolor:link {
color: inherit;
}
a.nocolor:visited {
color: inherit;
}

View File

@@ -0,0 +1,3 @@
{
"lumoImports": ["badge"]
}

View File

@@ -0,0 +1,101 @@
/**
* Copyright (c) 2023 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.ui;
import java.util.function.Supplier;
import feign.FeignException;
import lombok.Getter;
import org.eclipse.hawkbit.mgmt.rest.api.MgmtActionRestApi;
import org.eclipse.hawkbit.mgmt.rest.api.MgmtDistributionSetRestApi;
import org.eclipse.hawkbit.mgmt.rest.api.MgmtDistributionSetTagRestApi;
import org.eclipse.hawkbit.mgmt.rest.api.MgmtDistributionSetTypeRestApi;
import org.eclipse.hawkbit.mgmt.rest.api.MgmtRolloutRestApi;
import org.eclipse.hawkbit.mgmt.rest.api.MgmtSoftwareModuleRestApi;
import org.eclipse.hawkbit.mgmt.rest.api.MgmtSoftwareModuleTypeRestApi;
import org.eclipse.hawkbit.mgmt.rest.api.MgmtTargetFilterQueryRestApi;
import org.eclipse.hawkbit.mgmt.rest.api.MgmtTargetRestApi;
import org.eclipse.hawkbit.mgmt.rest.api.MgmtTargetTagRestApi;
import org.eclipse.hawkbit.mgmt.rest.api.MgmtTargetTypeRestApi;
import org.eclipse.hawkbit.mgmt.rest.api.MgmtTenantManagementRestApi;
import org.eclipse.hawkbit.sdk.HawkbitClient;
import org.eclipse.hawkbit.sdk.Tenant;
import org.springframework.http.ResponseEntity;
@Getter
public class HawkbitMgmtClient {
private final Tenant tenant;
private final HawkbitClient hawkbitClient;
private final MgmtSoftwareModuleRestApi softwareModuleRestApi;
private final MgmtSoftwareModuleTypeRestApi softwareModuleTypeRestApi;
private final MgmtDistributionSetRestApi distributionSetRestApi;
private final MgmtDistributionSetTypeRestApi distributionSetTypeRestApi;
private final MgmtDistributionSetTagRestApi distributionSetTagRestApi;
private final MgmtTargetRestApi targetRestApi;
private final MgmtTargetTypeRestApi targetTypeRestApi;
private final MgmtTargetTagRestApi targetTagRestApi;
private final MgmtTargetFilterQueryRestApi targetFilterQueryRestApi;
private final MgmtRolloutRestApi rolloutRestApi;
private final MgmtTenantManagementRestApi tenantManagementRestApi;
private final MgmtActionRestApi actionRestApi;
public HawkbitMgmtClient(final Tenant tenant, final HawkbitClient hawkbitClient) {
this.tenant = tenant;
this.hawkbitClient = hawkbitClient;
softwareModuleRestApi = service(MgmtSoftwareModuleRestApi.class);
softwareModuleTypeRestApi = service(MgmtSoftwareModuleTypeRestApi.class);
distributionSetRestApi = service(MgmtDistributionSetRestApi.class);
distributionSetTypeRestApi = service(MgmtDistributionSetTypeRestApi.class);
distributionSetTagRestApi = service(MgmtDistributionSetTagRestApi.class);
targetRestApi = service(MgmtTargetRestApi.class);
targetTypeRestApi = service(MgmtTargetTypeRestApi.class);
targetTagRestApi = service(MgmtTargetTagRestApi.class);
targetFilterQueryRestApi = service(MgmtTargetFilterQueryRestApi.class);
rolloutRestApi = service(MgmtRolloutRestApi.class);
tenantManagementRestApi = service(MgmtTenantManagementRestApi.class);
actionRestApi = service(MgmtActionRestApi.class);
}
public boolean hasSoftwareModulesRead() {
return hasRead(() -> softwareModuleRestApi.getSoftwareModule(-1L));
}
public boolean hasRolloutRead() {
return hasRead(() -> rolloutRestApi.getRollout(-1L));
}
public boolean hasDistributionSetRead() {
return hasRead(() -> distributionSetRestApi.getDistributionSet(-1L));
}
public boolean hasTargetRead() {
return hasRead(() -> targetRestApi.getTarget("_#ETE$ER"));
}
public boolean hasConfigRead() {
return hasRead(() -> tenantManagementRestApi.getTenantConfigurationValue("_#ETE$ER"));
}
private boolean hasRead(final Supplier<ResponseEntity<?>> doCall) {
try {
final int statusCode = doCall.get().getStatusCode().value();
return statusCode != 401 && statusCode != 403;
} catch (final FeignException e) {
return !(e instanceof FeignException.Unauthorized) && !(e instanceof FeignException.Forbidden);
}
}
private <T> T service(final Class<T> serviceType) {
return hawkbitClient.mgmtService(serviceType, tenant);
}
}

View File

@@ -0,0 +1,141 @@
/**
* Copyright (c) 2023 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.ui;
import com.vaadin.flow.component.page.AppShellConfigurator;
import com.vaadin.flow.server.PWA;
import com.vaadin.flow.theme.Theme;
import feign.Contract;
import feign.RequestInterceptor;
import feign.codec.Decoder;
import feign.codec.Encoder;
import feign.codec.ErrorDecoder;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.sdk.HawkbitClient;
import org.eclipse.hawkbit.sdk.HawkbitServer;
import org.eclipse.hawkbit.sdk.Tenant;
import org.eclipse.hawkbit.ui.view.util.Utils;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cloud.openfeign.FeignClientsConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.Base64;
import java.util.Collections;
import java.util.Objects;
import static feign.Util.ISO_8859_1;
@Slf4j
@Theme("hawkbit")
@PWA(name = "hawkBit UI", shortName = "hawkBit UI")
@EnableCaching
@EnableScheduling
@SpringBootApplication
@Import(FeignClientsConfiguration.class)
public class HawkbitUiApp implements AppShellConfigurator {
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final RequestInterceptor AUTHORIZATION = requestTemplate -> {
final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication.getPrincipal() instanceof OidcUser oidcUser) {
requestTemplate.header(AUTHORIZATION_HEADER, "Bearer " + oidcUser.getIdToken().getTokenValue());
} else {
requestTemplate.header(
AUTHORIZATION_HEADER, "Basic " + Base64.getEncoder().encodeToString(
(Objects.requireNonNull(authentication.getPrincipal(), "User is null!") + ":" + Objects.requireNonNull(
authentication.getCredentials(), "Password is not available!")).getBytes(ISO_8859_1))
);
}
};
private static final ErrorDecoder DEFAULT_ERROR_DECODER = new ErrorDecoder.Default();
private static final ErrorDecoder ERROR_DECODER = (methodKey, response) -> {
final Exception e = DEFAULT_ERROR_DECODER.decode(methodKey, response);
Utils.errorNotification(e);
return e;
};
public static void main(String[] args) {
SpringApplication.run(HawkbitUiApp.class, args);
}
@Bean
HawkbitClient hawkbitClient(
final HawkbitServer hawkBitServer,
final Encoder encoder,
final Decoder decoder,
final Contract contract
) {
return new HawkbitClient(
hawkBitServer, encoder, decoder, contract,
ERROR_DECODER,
(tenant, controller) -> controller == null
? AUTHORIZATION
: HawkbitClient.DEFAULT_REQUEST_INTERCEPTOR_FN.apply(tenant, controller)
);
}
@Bean
HawkbitMgmtClient hawkbitMgmtClient(final Tenant tenant, final HawkbitClient hawkbitClient) {
return new HawkbitMgmtClient(tenant, hawkbitClient);
}
// accepts all user / pass, just delegating them to the feign client
@Bean
AuthenticationManager authenticationManager(final HawkbitMgmtClient hawkbitClient, final HawkbitServer server) {
return authentication -> {
final String username = authentication.getName();
final String password = authentication.getCredentials().toString();
// make simple check in order not to be logged in as not real user.
if (!isAuthenticated(username, password, server.getMgmtUrl())) {
throw new BadCredentialsException("Incorrect username or password!");
}
return new UsernamePasswordAuthenticationToken(username, password, Collections.emptyList()) {
@Override
public void eraseCredentials() {
// don't erase credentials because they will be used
// to authenticate to the hawkBit update server / mgmt server
}
};
};
}
public static boolean isAuthenticated(String username, String password, String mgmtUrl) {
try {
final URL url = new URL(mgmtUrl + "/rest/v1/rollouts");
final HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
final String auth = username + ":" + password;
final String encodedAuth = Base64.getEncoder().encodeToString(auth.getBytes());
conn.setRequestProperty(AUTHORIZATION_HEADER, "Basic " + encodedAuth);
return conn.getResponseCode() != 401;
} catch (final Exception ex) {
log.error("Failed to authenticate user {} .Reason : ", username, ex);
return false;
}
}
}

View File

@@ -0,0 +1,165 @@
/**
* Copyright (c) 2023 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.ui;
import java.util.List;
import java.util.Optional;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.Unit;
import com.vaadin.flow.component.applayout.AppLayout;
import com.vaadin.flow.component.applayout.DrawerToggle;
import com.vaadin.flow.component.avatar.Avatar;
import com.vaadin.flow.component.contextmenu.MenuItem;
import com.vaadin.flow.component.html.Anchor;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.Footer;
import com.vaadin.flow.component.html.H1;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.html.Header;
import com.vaadin.flow.component.html.Image;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.menubar.MenuBar;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.Scroller;
import com.vaadin.flow.component.sidenav.SideNav;
import com.vaadin.flow.component.sidenav.SideNavItem;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.server.auth.AccessAnnotationChecker;
import com.vaadin.flow.theme.lumo.LumoUtility;
import org.eclipse.hawkbit.ui.security.AuthenticatedUser;
import org.eclipse.hawkbit.ui.view.AboutView;
import org.eclipse.hawkbit.ui.view.ConfigView;
import org.eclipse.hawkbit.ui.view.DistributionSetView;
import org.eclipse.hawkbit.ui.view.RolloutView;
import org.eclipse.hawkbit.ui.view.SoftwareModuleView;
import org.eclipse.hawkbit.ui.view.TargetView;
/**
* The main view is a top-level placeholder for other views.
*/
public class MainLayout extends AppLayout {
static final List<Class<? extends Component>> DEFAULT_VIEW_PRIORITY = List.of(TargetView.class, DistributionSetView.class,
SoftwareModuleView.class, RolloutView.class);
private final transient AuthenticatedUser authenticatedUser;
private final AccessAnnotationChecker accessChecker;
private H2 viewTitle;
private transient Optional<Class<? extends Component>> defaultView;
public MainLayout(final AuthenticatedUser authenticatedUser, final AccessAnnotationChecker accessChecker) {
this.authenticatedUser = authenticatedUser;
this.accessChecker = accessChecker;
setPrimarySection(Section.DRAWER);
addDrawerContent();
setDrawerOpened(true);
addHeaderContent();
}
@Override
protected void afterNavigation() {
super.afterNavigation();
viewTitle.setText(
Optional.ofNullable(getContent())
.map(c -> c.getClass().getAnnotation(PageTitle.class))
.map(PageTitle::value)
.orElse(""));
if (UI.getCurrent().getActiveViewLocation().getPath().isEmpty()) {
defaultView.ifPresent(c -> UI.getCurrent().navigate(c));
}
}
private void addHeaderContent() {
final DrawerToggle toggle = new DrawerToggle();
toggle.setAriaLabel("Menu toggle");
viewTitle = new H2();
viewTitle.addClassNames(LumoUtility.FontSize.LARGE, LumoUtility.Margin.NONE);
addToNavbar(false, toggle, viewTitle);
}
private void addDrawerContent() {
final H1 appName = new H1("hawkBit UI");
final HorizontalLayout layout = new HorizontalLayout();
layout.setPadding(true);
layout.setJustifyContentMode(FlexComponent.JustifyContentMode.CENTER);
final Image icon = new Image("images/header_icon.png", "hawkBit icon");
icon.setMaxHeight(24, Unit.PIXELS);
icon.setMaxWidth(24, Unit.PIXELS);
appName.addClassNames(LumoUtility.AlignItems.BASELINE, LumoUtility.FontSize.LARGE, LumoUtility.Margin.NONE);
layout.add(icon, appName);
final Header header = new Header(layout);
final Scroller scroller = new Scroller(createNavigation());
addToDrawer(header, scroller, createFooter());
}
private SideNav createNavigation() {
final SideNav nav = new SideNav();
if (accessChecker.hasAccess(TargetView.class)) {
nav.addItem(new SideNavItem("Targets", TargetView.class, VaadinIcon.FILTER.create()));
}
if (accessChecker.hasAccess(RolloutView.class)) {
nav.addItem(new SideNavItem("Rollouts", RolloutView.class, VaadinIcon.COGS.create()));
}
if (accessChecker.hasAccess(DistributionSetView.class)) {
nav.addItem(new SideNavItem("Distribution Sets", DistributionSetView.class, VaadinIcon.FILE_TREE.create()));
}
if (accessChecker.hasAccess(SoftwareModuleView.class)) {
nav.addItem(new SideNavItem("Software Modules", SoftwareModuleView.class, VaadinIcon.FILE.create()));
}
if (accessChecker.hasAccess(ConfigView.class)) {
nav.addItem(new SideNavItem("Config", ConfigView.class, VaadinIcon.COG.create()));
}
if (accessChecker.hasAccess(AboutView.class)) {
nav.addItem(new SideNavItem("About", AboutView.class, VaadinIcon.INFO_CIRCLE.create()));
}
defaultView = DEFAULT_VIEW_PRIORITY.stream().filter(accessChecker::hasAccess).findFirst();
return nav;
}
private Footer createFooter() {
final Footer layout = new Footer();
final Optional<String> maybeUser = authenticatedUser.getName();
if (maybeUser.isPresent()) {
final String user = maybeUser.get();
final Avatar avatar = new Avatar(user);
final MenuBar userMenu = new MenuBar();
userMenu.setThemeName("tertiary-inline contrast");
final MenuItem userName = userMenu.addItem("");
final Div div = new Div();
div.add(avatar);
div.add(user);
div.add(new Icon("lumo", "dropdown"));
div.getElement().getStyle().set("display", "flex");
div.getElement().getStyle().set("align-items", "center");
div.getElement().getStyle().set("gap", "var(--lumo-space-s)");
userName.add(div);
userName.getSubMenu().addItem("Sign out", e -> authenticatedUser.logout());
layout.add(userMenu);
} else {
final Anchor loginLink = new Anchor("login", "Sign in");
layout.add(loginLink);
}
return layout;
}
}

View File

@@ -0,0 +1,24 @@
/**
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.ui;
import java.util.Arrays;
import java.util.Locale;
import com.vaadin.flow.i18n.DefaultI18NProvider;
import org.springframework.stereotype.Component;
@Component
public class SimpleI18NProvider extends DefaultI18NProvider {
SimpleI18NProvider() {
super(Arrays.stream(Locale.getAvailableLocales()).toList());
}
}

View File

@@ -0,0 +1,28 @@
/**
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.ui;
import com.vaadin.flow.server.ServiceInitEvent;
import com.vaadin.flow.server.VaadinServiceInitListener;
import com.vaadin.flow.spring.annotation.SpringComponent;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@SpringComponent
public class VaadinServiceInit implements VaadinServiceInitListener {
@Override
public void serviceInit(ServiceInitEvent event) {
// cache zoneId of client as soon as possible
event.getSource().addUIInitListener(uiEvent ->
uiEvent.getUI().getPage().retrieveExtendedClientDetails(details -> {}));
}
}

View File

@@ -0,0 +1,250 @@
/**
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.ui.component;
import static org.eclipse.hawkbit.ui.view.Constants.STATUS;
import java.util.List;
import java.util.Optional;
import com.vaadin.flow.component.AttachEvent;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.Unit;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.data.renderer.ComponentRenderer;
import com.vaadin.flow.theme.lumo.LumoUtility;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.mgmt.json.model.action.MgmtAction;
import org.eclipse.hawkbit.mgmt.json.model.action.MgmtActionRequestBodyPut;
import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtActionType;
import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtDistributionSet;
import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTarget;
import org.eclipse.hawkbit.ui.HawkbitMgmtClient;
import org.eclipse.hawkbit.ui.view.TargetView;
import org.eclipse.hawkbit.ui.view.util.Utils;
@Slf4j
public class TargetActionsHistory extends Grid<TargetActionsHistory.ActionStatusEntry> {
private final transient HawkbitMgmtClient hawkbitClient;
private transient MgmtTarget target;
private final TargetView.TargetActionsHistoryLayout.ActionStepsGrid actionStepsGrid;
public TargetActionsHistory(final HawkbitMgmtClient hawkbitClient, TargetView.TargetActionsHistoryLayout.ActionStepsGrid actionStepsGrid) {
this.hawkbitClient = hawkbitClient;
setWidthFull();
addColumn(new ComponentRenderer<>(ActionStatusEntry::getStatusIcon)).setHeader(STATUS).setAutoWidth(true).setFlexGrow(0);
addColumn(ActionStatusEntry::getDistributionSetName).setHeader("Distribution Set").setAutoWidth(true);
addColumn(Utils.localDateTimeRenderer(ActionStatusEntry::getLastModifiedAt))
.setHeader("Last Modified")
.setAutoWidth(true)
.setFlexGrow(0)
.setComparator(ActionStatusEntry::getLastModifiedAt);
addColumn(new ComponentRenderer<>(ActionStatusEntry::getForceTypeIcon)).setHeader("Type").setAutoWidth(true).setFlexGrow(0);
addColumn(new ComponentRenderer<>(ActionStatusEntry::getActionsLayout)).setHeader("Actions").setAutoWidth(true).setFlexGrow(0);
addColumn(new ComponentRenderer<>(ActionStatusEntry::getForceQuitLayout)).setHeader("Force Quit").setAutoWidth(true)
.setFlexGrow(0);
addItemClickListener(e -> actionStepsGrid.setActionId(e.getItem().action.getId()));
this.actionStepsGrid = actionStepsGrid;
}
public void setItem(final MgmtTarget target) {
this.target = target;
this.actionStepsGrid.setTarget(target);
}
private List<ActionStatusEntry> fetchActions() {
return hawkbitClient.getTargetRestApi().getActionHistory(target.getControllerId(), null, 0, 30, null)
.getBody()
.getContent()
.stream()
.map(action -> new ActionStatusEntry(action, () -> setItems(fetchActions())))
.filter(value -> value.action != null)
.toList();
}
@Override
protected void onAttach(AttachEvent attachEvent) {
List<ActionStatusEntry> actionStatusEntries = fetchActions();
setItems(actionStatusEntries);
actionStatusEntries.stream().findFirst().ifPresentOrElse(e -> {
// select first action in the list by default
asSingleSelect().setValue(e);
actionStepsGrid.setActionId(e.action.getId());
}, () -> actionStepsGrid.setActionId(null));
}
protected class ActionStatusEntry {
final MgmtAction action;
final Runnable onUpdate;
MgmtDistributionSet distributionSet;
public ActionStatusEntry(final MgmtAction mgmtAction, final Runnable onUpdate) {
this.action = hawkbitClient.getActionRestApi().getAction(mgmtAction.getId()).getBody();
this.onUpdate = onUpdate;
if (action == null) {
log.error("Unable to fetch the action with id : {}", mgmtAction.getId());
return;
}
this.action.getLink("distributionset").ifPresent(link -> {
try {
Long dsId = Long.parseLong(link.getHref().substring(link.getHref().lastIndexOf("/") + 1));
this.distributionSet = hawkbitClient.getDistributionSetRestApi().getDistributionSet(dsId).getBody();
} catch (NumberFormatException e) {
log.error("Error parsing distribution set ID", e);
}
});
}
private boolean isActive() {
return action.isActive();
}
private boolean isCancelingOrCanceled() {
return action.getType().equals(MgmtAction.ACTION_CANCEL);
}
public Component getStatusIcon() {
final HorizontalLayout layout = new HorizontalLayout();
final Icon icon;
if (isActive()) {
if (isCancelingOrCanceled()) {
icon = Utils.tooltip(VaadinIcon.ADJUST.create(), "Pending Cancellation");
icon.setColor("red");
} else {
icon = Utils.tooltip(VaadinIcon.ADJUST.create(), "Pending Update");
icon.setColor("orange");
}// todo getDetailStatus should return an enum from src/main/java/org/eclipse/hawkbit/repository/model/Action.java
} else if (action.getType().equals(MgmtAction.ACTION_UPDATE) && action.getStatus().equals("finished")) {
icon = Utils.tooltip(VaadinIcon.CHECK_CIRCLE.create(), "Updated");
icon.setColor("green");
} else {
icon = Utils.tooltip(VaadinIcon.CLOSE_CIRCLE.create(), "Canceled");
icon.setColor("red");
}
icon.addClassNames(LumoUtility.IconSize.SMALL);
layout.add(icon);
layout.setWidth(50, Unit.PIXELS);
layout.setJustifyContentMode(FlexComponent.JustifyContentMode.CENTER);
return layout;
}
public String getDistributionSetName() {
return Optional.ofNullable(distributionSet).map(d -> d.getName() + ":" + d.getVersion()).orElse(
"Distribution Set not found");
}
public Long getLastModifiedAt() {
return action.getLastModifiedAt();
}
public Icon getForceTypeIcon() {
Icon icon = switch (action.getForceType()) {
case FORCED -> VaadinIcon.BOLT.create();
case TIMEFORCED -> VaadinIcon.USER_CLOCK.create();
case SOFT -> VaadinIcon.USER_CHECK.create();
case DOWNLOAD_ONLY -> VaadinIcon.DOWNLOAD.create();
};
return Utils.tooltip(icon, action.getForceType().getName());
}
public HorizontalLayout getActionsLayout() {
final HorizontalLayout actionsLayout = new HorizontalLayout();
actionsLayout.setSpacing(true);
final Button cancelButton = Utils.tooltip(new Button(VaadinIcon.CLOSE.create()), "Cancel Action");
if (isActive() && !isCancelingOrCanceled()) {
cancelButton.addClickListener(e -> {
String message = "Are you sure you want to cancel the action ?";
promptForConfirmAction(
message, onUpdate,
() -> hawkbitClient.getTargetRestApi().cancelAction(target.getControllerId(), action.getId(), false))
.open();
});
} else {
cancelButton.setEnabled(false);
}
final Button forceButton = Utils.tooltip(new Button(VaadinIcon.BOLT.create()), "Force Action");
if (isActive() && !isCancelingOrCanceled() && action.getForceType() != MgmtActionType.FORCED) {
forceButton.addClickListener(e -> {
String message = "Are you sure you want to force the action ?";
promptForConfirmAction(
message, onUpdate, () -> {
MgmtActionRequestBodyPut setForced = new MgmtActionRequestBodyPut();
setForced.setForceType(MgmtActionType.FORCED);
hawkbitClient.getTargetRestApi()
.updateAction(target.getControllerId(), action.getId(), setForced);
}
).open();
});
} else {
forceButton.setEnabled(false);
}
actionsLayout.add(cancelButton, forceButton);
return actionsLayout;
}
public HorizontalLayout getForceQuitLayout() {
final HorizontalLayout forceQuitLayout = new HorizontalLayout();
forceQuitLayout.setSpacing(true);
forceQuitLayout.setPadding(true);
forceQuitLayout.setWidthFull();
forceQuitLayout.setJustifyContentMode(FlexComponent.JustifyContentMode.CENTER);
final Button forceQuitButton = Utils.tooltip(new Button(VaadinIcon.CLOSE.create()), "Force Cancel");
forceQuitButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY_INLINE);
if (isActive() && isCancelingOrCanceled()) {
forceQuitButton.addClickListener(e -> {
String message = "Are you sure you want to force cancel the action ?";
promptForConfirmAction(
message, onUpdate,
() -> hawkbitClient.getTargetRestApi().cancelAction(target.getControllerId(), action.getId(), true)).open();
});
} else {
forceQuitButton.setEnabled(false);
}
forceQuitLayout.add(forceQuitButton);
return forceQuitLayout;
}
private static ConfirmDialog promptForConfirmAction(String message, Runnable refreshActions, Runnable actionConsumer) {
final ConfirmDialog dialog = new ConfirmDialog();
dialog.setHeader("Confirm Action");
dialog.setText(message);
dialog.setCancelable(true);
dialog.addCancelListener(event -> dialog.close());
dialog.setConfirmButtonTheme(ButtonVariant.LUMO_ERROR.getVariantName());
dialog.setConfirmText("Confirm");
dialog.addConfirmListener(event -> {
actionConsumer.run();
refreshActions.run();
dialog.close();
});
return dialog;
}
}
}

View File

@@ -0,0 +1,36 @@
/**
* Copyright (c) 2023 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.ui.security;
import java.util.Optional;
import com.vaadin.flow.spring.security.AuthenticationContext;
import org.springframework.stereotype.Component;
@Component
public class AuthenticatedUser {
private final AuthenticationContext authenticationContext;
private final GrantedAuthoritiesService grantedAuthoritiesService;
public AuthenticatedUser(final AuthenticationContext authenticationContext, GrantedAuthoritiesService grantedAuthoritiesService) {
this.authenticationContext = authenticationContext;
this.grantedAuthoritiesService = grantedAuthoritiesService;
}
public Optional<String> getName() {
return authenticationContext.getPrincipalName();
}
public void logout() {
this.getName().ifPresent(grantedAuthoritiesService::evictUserFromCache);
authenticationContext.logout();
}
}

View File

@@ -0,0 +1,65 @@
/**
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.ui.security;
import java.util.LinkedList;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.ui.HawkbitMgmtClient;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Service;
@Service
@AllArgsConstructor
@Slf4j
public class GrantedAuthoritiesService {
public static final String USER_CACHE = "userDetails";
private HawkbitMgmtClient hawkbitClient;
@Cacheable(cacheNames = "userDetails", key = "#authentication.getName()")
public List<SimpleGrantedAuthority> getGrantedAuthorities(Authentication authentication) {
final List<String> roles = new LinkedList<>();
roles.add("ANONYMOUS");
if (hawkbitClient.hasSoftwareModulesRead()) {
roles.add("SOFTWARE_MODULE_READ");
}
if (hawkbitClient.hasRolloutRead()) {
roles.add("ROLLOUT_READ");
}
if (hawkbitClient.hasDistributionSetRead()) {
roles.add("DISTRIBUTION_SET_READ");
}
if (hawkbitClient.hasTargetRead()) {
roles.add("TARGET_READ");
}
if (hawkbitClient.hasConfigRead()) {
roles.add("CONFIG_READ");
}
return roles.stream().map(role -> new SimpleGrantedAuthority("ROLE_" + role)).toList();
}
@Scheduled(fixedRateString = "${caching.spring.userDetailsTTL:1h}")
@CacheEvict(cacheNames = USER_CACHE, allEntries = true)
public void emptyCache() {
log.debug("emptying userDetails cache");
}
@CacheEvict(cacheNames = "userDetails")
public void evictUserFromCache(String principalName) {
log.debug("remove user from {} cache", principalName);
}
}

View File

@@ -0,0 +1,28 @@
/**
* Copyright (c) 2023 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.ui.security;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2LoginConfigurer;
@Configuration
public class Oauth2ClientConfig {
@Bean(name = "hawkbitOAuth2ClientCustomizer")
@ConditionalOnProperty(prefix = "hawkbit.server.security.oauth2.client", name = "enabled")
@ConditionalOnMissingBean(name = "hawkbitOAuth2ClientCustomizer")
Customizer<OAuth2LoginConfigurer<HttpSecurity>> defaultOAuth2ClientCustomizer() {
return Customizer.withDefaults();
}
}

View File

@@ -0,0 +1,34 @@
/**
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.ui.security;
import lombok.Data;
import lombok.ToString;
import org.springframework.boot.context.properties.ConfigurationProperties;
@Data
@ToString
@ConfigurationProperties("hawkbit.server.security")
public class OidcClientProperties {
private final OidcClientProperties.Oauth2 oauth2 = new OidcClientProperties.Oauth2();
@Data
public static class Oauth2 {
private final OidcClientProperties.Oauth2.Client client = new OidcClientProperties.Oauth2.Client();
@Data
public static class Client {
private boolean enabled = false;
}
}
}

View File

@@ -0,0 +1,83 @@
/**
* Copyright (c) 2023 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.ui.security;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import com.vaadin.flow.spring.security.VaadinAwareSecurityContextHolderStrategyConfiguration;
import com.vaadin.flow.spring.security.VaadinSecurityConfigurer;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.ui.view.LoginView;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2LoginConfigurer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.intercept.AuthorizationFilter;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
@EnableWebSecurity
@Configuration
@EnableConfigurationProperties(OidcClientProperties.class)
@Slf4j
@Import(VaadinAwareSecurityContextHolderStrategyConfiguration.class)
public class SecurityConfiguration {
private Customizer<OAuth2LoginConfigurer<HttpSecurity>> oAuth2LoginConfigurerCustomizer;
@Autowired(required = false)
public void setOAuth2LoginConfigurerCustomizer(
@Qualifier("hawkbitOAuth2ClientCustomizer") final Customizer<OAuth2LoginConfigurer<HttpSecurity>> oauth2LoginConfigurerCustomizer) {
this.oAuth2LoginConfigurerCustomizer = oauth2LoginConfigurerCustomizer;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationFailureHandler customFailureHandler() {
// Redirect back to login with your message
return (request, response, exception) -> response.sendRedirect("/login?error=" + URLEncoder.encode(exception.getMessage(),
StandardCharsets.UTF_8));
}
@Bean
SecurityFilterChain securityFilterChain(
final HttpSecurity http,
final UserDetailsSetter userDetailsSetter,
@Autowired(required = false) InMemoryClientRegistrationRepository clientRegistrationRepository) throws Exception {
http.authorizeHttpRequests(authorize -> authorize.requestMatchers("/images/*.png").permitAll());
http.addFilterAfter(userDetailsSetter, AuthorizationFilter.class);
return http.with(VaadinSecurityConfigurer.vaadin(), configurer -> {
if (oAuth2LoginConfigurerCustomizer == null) {
configurer.loginView(LoginView.class);
} else {
var defaultClientRegistration = clientRegistrationRepository.iterator().next();
configurer.oauth2LoginPage(
"/oauth2/authorization/" + defaultClientRegistration.getRegistrationId(),
"{baseUrl}/"
);
}
}).build();
}
}

View File

@@ -0,0 +1,73 @@
/**
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.ui.security;
import java.io.IOException;
import java.time.Instant;
import java.util.Collection;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.springframework.security.authentication.AccountExpiredException;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolderStrategy;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@RequiredArgsConstructor
@Component
@Slf4j
class UserDetailsSetter extends OncePerRequestFilter {
private final SecurityContextHolderStrategy securityContextHolderStrategy;
private final GrantedAuthoritiesService grantedAuthoritiesService;
@SuppressWarnings("java:S1066") // java:S1066 - readability preferred
@Override
protected void doFilterInternal(@NotNull final HttpServletRequest request, @NotNull final HttpServletResponse response,
@NotNull final FilterChain filterChain)
throws ServletException, IOException {
Authentication authentication = securityContextHolderStrategy.getContext().getAuthentication();
Authentication newAuthentication;
if (!(authentication instanceof AnonymousAuthenticationToken) && authentication.isAuthenticated()) {
Collection<? extends GrantedAuthority> grantedAuthorities = grantedAuthoritiesService.getGrantedAuthorities(authentication);
if (authentication instanceof OAuth2AuthenticationToken oAuth2AuthenticationToken) {
newAuthentication = new OAuth2AuthenticationToken(oAuth2AuthenticationToken.getPrincipal(), grantedAuthorities,
oAuth2AuthenticationToken.getAuthorizedClientRegistrationId());
if (authentication.getPrincipal() instanceof OidcUser user) {
// if there is no refresh token and the access token is expired then relogin is required
if (user.getIdToken().getExpiresAt() != null && Instant.now().isAfter(user.getIdToken().getExpiresAt())) {
throw new AccountExpiredException("Token expired");
}
}
} else {
newAuthentication = new UsernamePasswordAuthenticationToken(authentication.getName(), authentication.getCredentials(),
grantedAuthorities);
}
securityContextHolderStrategy.getContext().setAuthentication(newAuthentication);
}
filterChain.doFilter(request, response);
}
}

View File

@@ -0,0 +1,44 @@
/**
* Copyright (c) 2023 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.ui.view;
import jakarta.annotation.security.RolesAllowed;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.html.Image;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.router.RouteAlias;
import com.vaadin.flow.theme.lumo.LumoUtility.Margin;
import org.eclipse.hawkbit.ui.MainLayout;
@PageTitle("About")
@Route(value = "about", layout = MainLayout.class)
@RouteAlias(value = "", layout = MainLayout.class)
@RolesAllowed({ "ANONYMOUS" })
public class AboutView extends VerticalLayout {
public AboutView() {
setSpacing(false);
final Image img = new Image("images/about_image.png", "hawkBit");
img.setWidth("200px");
add(img);
final H2 header = new H2("Eclipse hawkBit UI");
header.addClassNames(Margin.Top.XLARGE, Margin.Bottom.MEDIUM);
setSizeFull();
setJustifyContentMode(JustifyContentMode.CENTER);
setDefaultHorizontalComponentAlignment(Alignment.CENTER);
getStyle().set("text-align", "center");
}
}

View File

@@ -0,0 +1,89 @@
/**
* Copyright (c) 2023 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.ui.view;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import jakarta.annotation.security.RolesAllowed;
import com.vaadin.flow.component.Key;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.checkbox.Checkbox;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.NumberField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.mgmt.json.model.system.MgmtSystemTenantConfigurationValueRequest;
import org.eclipse.hawkbit.ui.HawkbitMgmtClient;
import org.eclipse.hawkbit.ui.MainLayout;
@PageTitle("Config")
@Route(value = "config", layout = MainLayout.class)
@RolesAllowed({ "CONFIG_READ" })
@Slf4j
public class ConfigView extends VerticalLayout {
private static final String WIDTH = "width";
private static final String PX_300 = "300px";
private final Map<String, MgmtSystemTenantConfigurationValueRequest> configValue = new HashMap<>();
public ConfigView(final HawkbitMgmtClient hawkbitClient) {
setSpacing(false);
final Button saveButton = new Button("Save");
Optional.ofNullable(
hawkbitClient.getTenantManagementRestApi().getTenantConfiguration().getBody()).ifPresent(config ->
config.forEach((k, v) -> {
if (v.getValue() instanceof String strValue) {
final TextField tf = new TextField(k, strValue, event -> {
final MgmtSystemTenantConfigurationValueRequest vre = new MgmtSystemTenantConfigurationValueRequest();
vre.setValue(event.getValue());
configValue.put(k, vre);
});
tf.getElement().getStyle().set(WIDTH, PX_300);
add(tf);
} else if (v.getValue() instanceof Boolean boolValue) {
add(new Checkbox(k, boolValue, event -> {
final MgmtSystemTenantConfigurationValueRequest vre = new MgmtSystemTenantConfigurationValueRequest();
vre.setValue(event.getValue());
configValue.put(k, vre);
}));
} else if (v.getValue() instanceof Long longValue) {
final NumberField nf = new NumberField(k, (double) longValue, event -> {
final MgmtSystemTenantConfigurationValueRequest vre = new MgmtSystemTenantConfigurationValueRequest();
vre.setValue(event.getValue());
configValue.put(k, vre);
});
nf.getElement().getStyle().set(WIDTH, PX_300);
add(nf);
} else if (v.getValue() instanceof Integer intValue) {
final NumberField nf = new NumberField(k, (double) intValue, event -> {
MgmtSystemTenantConfigurationValueRequest vre = new MgmtSystemTenantConfigurationValueRequest();
vre.setValue(event.getValue());
configValue.put(k, vre);
});
nf.getElement().getStyle().set(WIDTH, PX_300);
add(nf);
} else {
log.debug("Unexpected value type: {} -> {} (class: {})",
k, v.getValue(), v.getValue() == null ? "null" : v.getValue().getClass());
}
}));
saveButton.addClickListener(click -> configValue.forEach(
(key, value) -> hawkbitClient.getTenantManagementRestApi().updateTenantConfigurationValue(key, value)));
saveButton.addClickShortcut(Key.ENTER);
add(saveButton);
}
}

View File

@@ -0,0 +1,64 @@
/**
* Copyright (c) 2023 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.ui.view;
// java:S1214 - implementations of Constants interface extends other classes, so if make this class we shall go for static imports
// which is not not better
@SuppressWarnings("java:S1214")
public interface Constants {
// properties
String ID = "Id";
String ADDRESS = "Address";
String NAME = "Name";
String DESCRIPTION = "Description";
String VERSION = "Version";
String VENDOR = "Vendor";
String TYPE = "Type";
String GROUP = "Group";
String CREATED_BY = "Created by";
String CREATED_AT = "Created at";
String LAST_MODIFIED_BY = "Last modified by";
String LAST_MODIFIED_AT = "Last modified at";
String LAST_POLL = "Last Poll";
String SECURITY_TOKEN = "Security Token";
String ATTRIBUTES = "Attributes";
// rollout
String GROUP_COUNT = "Group Count";
String TARGET_COUNT = "Target Count";
String STATS = "Stats";
String STATUS = "Status";
String ACTIONS = "Actions";
// create rollout
String TARGET_FILTER = "Target Filter";
String DISTRIBUTION_SET = "Distribution Set";
String ACTION_TYPE = "Action Type";
String START_AT = "Start At";
String SOFT = "Soft";
String FORCED = "Forced";
String DOWNLOAD_ONLY = "Download Only";
String START_TYPE = "Start Type";
String MANUAL = "Manual";
String AUTO = "Auto";
String DYNAMIC = "Dynamic";
// dialog
String CANCEL = "Cancel";
String CANCEL_ESC = "Cancel (Esc)";
String CREATED_AT_DESC = "createdAt:desc";
String NAME_ASC = "name:asc";
String NAME_DESC = "name:desc";
String NOT_AVAILABLE_NULL = "n/a (null)";
}

View File

@@ -0,0 +1,385 @@
/**
* Copyright (c) 2023 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.ui.view;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import com.vaadin.flow.component.grid.GridSortOrder;
import com.vaadin.flow.data.provider.SortDirection;
import jakarta.annotation.security.RolesAllowed;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.Key;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.checkbox.Checkbox;
import com.vaadin.flow.component.checkbox.CheckboxGroup;
import com.vaadin.flow.component.dependency.Uses;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.select.Select;
import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.renderer.ComponentRenderer;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import org.eclipse.hawkbit.mgmt.json.model.PagedList;
import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtDistributionSet;
import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtDistributionSetRequestBodyPost;
import org.eclipse.hawkbit.mgmt.json.model.distributionsettype.MgmtDistributionSetType;
import org.eclipse.hawkbit.mgmt.json.model.softwaremodule.MgmtSoftwareModule;
import org.eclipse.hawkbit.mgmt.json.model.softwaremodule.MgmtSoftwareModuleAssignment;
import org.eclipse.hawkbit.mgmt.json.model.tag.MgmtTag;
import org.eclipse.hawkbit.ui.HawkbitMgmtClient;
import org.eclipse.hawkbit.ui.MainLayout;
import org.eclipse.hawkbit.ui.view.util.Filter;
import org.eclipse.hawkbit.ui.view.util.SelectionGrid;
import org.eclipse.hawkbit.ui.view.util.TableView;
import org.eclipse.hawkbit.ui.view.util.Utils;
@PageTitle("Distribution Sets")
@Route(value = "distribution_sets", layout = MainLayout.class)
@RolesAllowed({ "DISTRIBUTION_SET_READ" })
@Uses(Icon.class)
public class DistributionSetView extends TableView<MgmtDistributionSet, Long> {
public DistributionSetView(final HawkbitMgmtClient hawkbitClient) {
super(
new DistributionSetFilter(hawkbitClient),
new DistributionSetRawFilter(),
new SelectionGrid.EntityRepresentation<>(MgmtDistributionSet.class, MgmtDistributionSet::getId) {
private final DistributionSetDetails details = new DistributionSetDetails(hawkbitClient);
@Override
protected void addColumns(Grid<MgmtDistributionSet> grid) {
var createdAtCol = grid.addColumn(Utils.localDateTimeRenderer(MgmtDistributionSet::getCreatedAt)).setHeader(
Constants.CREATED_AT).setAutoWidth(true).setKey("createdAt").setSortable(true);
grid.addColumn(MgmtDistributionSet::getName).setHeader(Constants.NAME).setAutoWidth(true).setKey("name").setSortable(
true);
grid.addColumn(MgmtDistributionSet::getVersion).setHeader(Constants.VERSION).setAutoWidth(true).setKey("version")
.setSortable(true);
grid.addColumn(MgmtDistributionSet::getTypeName).setHeader(Constants.TYPE).setAutoWidth(true).setKey("typename")
.setSortable(true);
grid.sort(List.of(new GridSortOrder<>(createdAtCol, SortDirection.DESCENDING)));
grid.setItemDetailsRenderer(new ComponentRenderer<>(
() -> details, DistributionSetDetails::setItem));
}
},
(query, rsqlFilter) -> Optional.ofNullable(
hawkbitClient.getDistributionSetRestApi()
.getDistributionSets(rsqlFilter, query.getOffset(), query.getPageSize(), Utils.getSortParam(query
.getSortOrders()))
.getBody())
.stream().flatMap(body -> body.getContent().stream()),
e -> new CreateDialog(hawkbitClient).result(),
selectionGrid -> {
selectionGrid.getSelectedItems().forEach(
distributionSet -> hawkbitClient.getDistributionSetRestApi()
.deleteDistributionSet(distributionSet.getId()));
return CompletableFuture.completedFuture(null);
}, null);
}
private static SelectionGrid<MgmtSoftwareModule, Long> selectSoftwareModuleGrid() {
return new SelectionGrid<>(
new SelectionGrid.EntityRepresentation<>(
MgmtSoftwareModule.class, MgmtSoftwareModule::getId) {
@Override
protected void addColumns(Grid<MgmtSoftwareModule> grid) {
grid.addColumn(MgmtSoftwareModule::getId).setHeader(Constants.ID).setAutoWidth(true);
grid.addColumn(MgmtSoftwareModule::getName).setHeader(Constants.NAME).setAutoWidth(true);
grid.addColumn(MgmtSoftwareModule::getVersion).setHeader(Constants.VERSION).setAutoWidth(true);
grid.addColumn(MgmtSoftwareModule::getTypeName).setHeader(Constants.TYPE).setAutoWidth(true);
grid.addColumn(MgmtSoftwareModule::getVendor).setHeader(Constants.VENDOR).setAutoWidth(true);
}
});
}
private static class DistributionSetRawFilter implements Filter.Rsql, Filter.RsqlRw {
private final TextField name = Utils.textField("Name");
private DistributionSetRawFilter() {
name.setPlaceholder("<rsql filter>");
}
@Override
public List<Component> components() {
return List.of(name);
}
@Override
public String filter() {
return name.getOptionalValue().orElse(null);
}
@Override
public void setFilter(String filter) {
name.setValue(filter);
}
}
private static class DistributionSetFilter implements Filter.Rsql {
private final TextField textFilter = Utils.textField("Filter");
private final CheckboxGroup<MgmtDistributionSetType> type = new CheckboxGroup<>("Type");
private final CheckboxGroup<MgmtTag> tag = new CheckboxGroup<>("Tag");
private DistributionSetFilter(final HawkbitMgmtClient hawkbitClient) {
textFilter.setPlaceholder("<name/version filter>");
type.setItemLabelGenerator(MgmtDistributionSetType::getName);
type.setItems(Optional.ofNullable(
hawkbitClient.getDistributionSetTypeRestApi()
.getDistributionSetTypes(null, 0, 20, Constants.NAME_ASC)
.getBody())
.map(PagedList::getContent)
.orElseGet(Collections::emptyList));
tag.setItemLabelGenerator(MgmtTag::getName);
tag.setItems(Optional.ofNullable(
hawkbitClient.getDistributionSetTagRestApi()
.getDistributionSetTags(null, 0, 20, Constants.NAME_ASC)
.getBody())
.map(PagedList::getContent)
.orElseGet(Collections::emptyList));
}
@Override
public List<Component> components() {
return List.of(textFilter, type);
}
@Override
public String filter() {
return Filter.filter(
Map.of(
List.of("version", "name"), textFilter.getOptionalValue().map(s -> "*" + s + "*"),
"type", type.getSelectedItems().stream().map(MgmtDistributionSetType::getKey).toList(),
"tag", tag.getSelectedItems().stream().map(MgmtTag::getName).toList()));
}
}
private static class DistributionSetDetails extends FormLayout {
private final transient HawkbitMgmtClient hawkbitClient;
private final TextArea description = new TextArea("Description");
private final TextField createdBy = Utils.textField("Created by");
private final TextField createdAt = Utils.textField("Created at");
private final TextField lastModifiedBy = Utils.textField("Last modified by");
private final TextField lastModifiedAt = Utils.textField("Last modified at");
private final TextArea metadata = new TextArea("Metadata");
private final SelectionGrid<MgmtSoftwareModule, Long> softwareModulesGrid = selectSoftwareModuleGrid();
private DistributionSetDetails(final HawkbitMgmtClient hawkbitClient) {
this.hawkbitClient = hawkbitClient;
description.setMinLength(2);
Stream.of(
description,
createdBy, createdAt,
lastModifiedBy, lastModifiedAt, metadata)
.forEach(field -> {
field.setReadOnly(true);
add(field);
});
add(softwareModulesGrid);
setResponsiveSteps(new ResponsiveStep("0", 2));
setColspan(description, 2);
setColspan(softwareModulesGrid, 2);
}
private void setItem(final MgmtDistributionSet distributionSet) {
description.setValue(distributionSet.getDescription());
createdBy.setValue(distributionSet.getCreatedBy());
createdAt.setValue(Utils.localDateTimeFromTs(distributionSet.getCreatedAt()));
lastModifiedBy.setValue(distributionSet.getLastModifiedBy());
lastModifiedAt.setValue(Utils.localDateTimeFromTs(distributionSet.getLastModifiedAt()));
metadata.setValue(Optional.ofNullable(
hawkbitClient.getDistributionSetRestApi().getMetadata(distributionSet.getId()).getBody())
.map(body -> body.getContent().stream()
.map(b -> String.format("%s: %s\n", b.getKey(), b.getValue())).collect(
Collectors.joining())).orElse(""));
softwareModulesGrid.setItems(query -> Optional.ofNullable(
hawkbitClient.getDistributionSetRestApi()
.getAssignedSoftwareModules(
distributionSet.getId(),
query.getOffset(), query.getLimit(), Constants.NAME_ASC)
.getBody()).stream().flatMap(body -> body.getContent().stream()));
softwareModulesGrid.setSelectionMode(Grid.SelectionMode.NONE);
}
}
private static class CreateDialog extends Utils.BaseDialog<Void> {
private final transient HawkbitMgmtClient hawkbitClient;
private final Select<MgmtDistributionSetType> type;
private final TextField name;
private final TextField version;
private final TextArea description;
private final Checkbox requiredMigrationStep;
private final Button create;
private CreateDialog(final HawkbitMgmtClient hawkbitClient) {
super("Create Distribution Set");
this.hawkbitClient = hawkbitClient;
type = new Select<>(
"Type",
this::readyToCreate,
Optional.ofNullable(
hawkbitClient.getDistributionSetTypeRestApi()
.getDistributionSetTypes(null, 0, 30, Constants.CREATED_AT_DESC)
.getBody())
.map(body -> body.getContent().toArray(new MgmtDistributionSetType[0]))
.orElseGet(() -> new MgmtDistributionSetType[0]));
type.focus();
type.setWidthFull();
type.setRequiredIndicatorVisible(true);
type.setItemLabelGenerator(MgmtDistributionSetType::getName);
name = Utils.textField(Constants.NAME, this::readyToCreate);
version = Utils.textField(Constants.VERSION, this::readyToCreate);
final TextField vendor = Utils.textField(Constants.VENDOR);
description = new TextArea(Constants.DESCRIPTION);
description.setWidthFull();
description.setMinLength(2);
requiredMigrationStep = new Checkbox("Required Migration Step");
create = Utils.tooltip(new Button("Create"), "Create (Enter)");
create.setEnabled(false);
addCreateClickListener();
create.addClickShortcut(Key.ENTER);
final Button cancel = Utils.tooltip(new Button(CANCEL), CANCEL_ESC);
cancel.addClickListener(e -> close());
create.addClickShortcut(Key.ESCAPE);
create.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
getFooter().add(cancel);
getFooter().add(create);
final VerticalLayout layout = new VerticalLayout();
layout.setSizeFull();
layout.setPadding(true);
layout.setSpacing(false);
layout.add(type, name, version, vendor, description, requiredMigrationStep);
add(layout);
open();
}
private void readyToCreate(final Object v) {
final boolean createEnabled = !type.isEmpty() && !name.isEmpty() && !version.isEmpty();
if (create.isEnabled() != createEnabled) {
create.setEnabled(createEnabled);
}
}
private void addCreateClickListener() {
create.addClickListener(e -> {
close();
final long distributionSetId = Optional.ofNullable(
hawkbitClient.getDistributionSetRestApi()
.createDistributionSets(
List.of((MgmtDistributionSetRequestBodyPost) new MgmtDistributionSetRequestBodyPost()
.setType(type.getValue().getKey())
.setName(name.getValue())
.setVersion(version.getValue())
.setDescription(description.getValue())
.setRequiredMigrationStep(requiredMigrationStep.getValue())))
.getBody())
.stream()
.flatMap(Collection::stream)
.findFirst()
.orElseThrow()
.getId();
new AddSoftwareModulesDialog(distributionSetId, hawkbitClient).open();
});
}
}
@SuppressWarnings({ "java:S1171", "java:S3599" })
private static class AddSoftwareModulesDialog extends Utils.BaseDialog<Void> {
private final transient Set<MgmtSoftwareModule> softwareModules = Collections.synchronizedSet(new HashSet<>());
private AddSoftwareModulesDialog(final long distributionSetId, final HawkbitMgmtClient hawkbitClient) {
super("Add Software Modules");
final SelectionGrid<MgmtSoftwareModule, Long> softwareModulesGrid = selectSoftwareModuleGrid();
softwareModulesGrid.setItems(query -> {
query.getOffset(); // to keep vaadin contract
return softwareModules.stream().limit(query.getLimit());
});
final Component addRemoveControls = Utils.addRemoveControls(
v -> new Utils.BaseDialog<Void>("Add Software Modules") {
{
final SoftwareModuleView softwareModulesView = new SoftwareModuleView(false, hawkbitClient);
add(softwareModulesView);
final Button addBtn = new Button("Add");
addBtn.addClickListener(e -> {
softwareModules.addAll(softwareModulesView.getSelection());
softwareModulesGrid.refreshGrid(false);
close();
});
addBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
getFooter().add(addBtn);
open();
}
}.result(),
v -> {
Utils.remove(softwareModulesGrid.getSelectedItems(), softwareModules, MgmtSoftwareModule::getId);
softwareModulesGrid.refreshGrid(false);
return CompletableFuture.completedFuture(null);
},
softwareModulesGrid, true);
final Button finishBtn = Utils.tooltip(new Button("Finish"), "Finish (Esc)");
finishBtn.addClickListener(e -> {
hawkbitClient.getDistributionSetRestApi().assignSoftwareModules(
distributionSetId, softwareModules.stream().map(softwareModule -> {
final MgmtSoftwareModuleAssignment assignment = new MgmtSoftwareModuleAssignment();
assignment.setId(softwareModule.getId());
return assignment;
}).toList());
close();
});
finishBtn.addClickShortcut(Key.ENTER);
finishBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
getFooter().add(finishBtn);
final HorizontalLayout addRemove = new HorizontalLayout(addRemoveControls);
addRemove.setJustifyContentMode(FlexComponent.JustifyContentMode.END);
addRemove.setWidthFull();
final VerticalLayout layout = new VerticalLayout();
layout.setSizeFull();
layout.setSpacing(false);
layout.add(softwareModulesGrid, addRemove);
add(layout);
}
}
}

View File

@@ -0,0 +1,54 @@
/**
* Copyright (c) 2023 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.ui.view;
import com.vaadin.flow.component.login.LoginI18n;
import com.vaadin.flow.component.login.LoginOverlay;
import com.vaadin.flow.router.BeforeEnterEvent;
import com.vaadin.flow.router.BeforeEnterObserver;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.router.internal.RouteUtil;
import com.vaadin.flow.server.VaadinService;
import com.vaadin.flow.server.auth.AnonymousAllowed;
import org.eclipse.hawkbit.ui.security.AuthenticatedUser;
@AnonymousAllowed
@PageTitle("Login")
@Route(value = "login")
public class LoginView extends LoginOverlay implements BeforeEnterObserver {
private final transient AuthenticatedUser authenticatedUser;
public LoginView(final AuthenticatedUser authenticatedUser) {
this.authenticatedUser = authenticatedUser;
setAction(RouteUtil.getRoutePath(VaadinService.getCurrent().getContext(), getClass()));
final LoginI18n i18n = LoginI18n.createDefault();
i18n.setHeader(new LoginI18n.Header());
i18n.getHeader().setTitle("hawkBit");
i18n.getHeader().setDescription("Login in hawkBit");
i18n.setAdditionalInformation(null);
setI18n(i18n);
setForgotPasswordButtonVisible(false);
setOpened(true);
}
@Override
public void beforeEnter(final BeforeEnterEvent event) {
if (authenticatedUser.getName().isPresent()) { // already logged in
setOpened(false);
event.forwardTo("");
}
setError(event.getLocation().getQueryParameters().getParameters().containsKey("error"));
}
}

View File

@@ -0,0 +1,440 @@
/**
* Copyright (c) 2023 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.ui.view;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Stream;
import jakarta.annotation.security.RolesAllowed;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.Key;
import com.vaadin.flow.component.Text;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.checkbox.Checkbox;
import com.vaadin.flow.component.datetimepicker.DateTimePicker;
import com.vaadin.flow.component.dependency.Uses;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.select.Select;
import com.vaadin.flow.component.textfield.NumberField;
import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.renderer.ComponentRenderer;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtActionType;
import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtDistributionSet;
import org.eclipse.hawkbit.mgmt.json.model.rollout.MgmtRolloutCondition;
import org.eclipse.hawkbit.mgmt.json.model.rollout.MgmtRolloutErrorAction;
import org.eclipse.hawkbit.mgmt.json.model.rollout.MgmtRolloutResponseBody;
import org.eclipse.hawkbit.mgmt.json.model.rollout.MgmtRolloutRestRequestBodyPost;
import org.eclipse.hawkbit.mgmt.json.model.rolloutgroup.MgmtRolloutGroupResponseBody;
import org.eclipse.hawkbit.mgmt.json.model.targetfilter.MgmtTargetFilterQuery;
import org.eclipse.hawkbit.ui.HawkbitMgmtClient;
import org.eclipse.hawkbit.ui.MainLayout;
import org.eclipse.hawkbit.ui.view.util.Filter;
import org.eclipse.hawkbit.ui.view.util.SelectionGrid;
import org.eclipse.hawkbit.ui.view.util.TableView;
import org.eclipse.hawkbit.ui.view.util.Utils;
import org.springframework.util.ObjectUtils;
@PageTitle("Rollouts")
@Route(value = "rollouts", layout = MainLayout.class)
@RolesAllowed({ "ROLLOUT_READ" })
@Uses(Icon.class)
@SuppressWarnings({ "java:S1171", "java:S3599" })
public class RolloutView extends TableView<MgmtRolloutResponseBody, Long> {
public RolloutView(final HawkbitMgmtClient hawkbitClient) {
super(
new RolloutFilter(),
new SelectionGrid.EntityRepresentation<>(
MgmtRolloutResponseBody.class, MgmtRolloutResponseBody::getId) {
private final RolloutDetails details = new RolloutDetails(hawkbitClient);
@Override
protected void addColumns(final Grid<MgmtRolloutResponseBody> grid) {
grid.addColumn(MgmtRolloutResponseBody::getId).setHeader(Constants.ID).setAutoWidth(true);
grid.addColumn(MgmtRolloutResponseBody::getName).setHeader(Constants.NAME).setAutoWidth(true);
grid.addColumn(MgmtRolloutResponseBody::getTotalGroups).setHeader(Constants.GROUP_COUNT).setAutoWidth(true);
grid.addColumn(MgmtRolloutResponseBody::getTotalTargets).setHeader(Constants.TARGET_COUNT).setAutoWidth(true);
grid.addColumn(MgmtRolloutResponseBody::getTotalTargetsPerStatus).setHeader(Constants.STATS).setAutoWidth(true);
grid.addColumn(MgmtRolloutResponseBody::getStatus).setHeader(Constants.STATUS).setAutoWidth(true);
grid.addComponentColumn(rollout -> new Actions(rollout, grid, hawkbitClient)).setHeader(
Constants.ACTIONS).setAutoWidth(true);
grid.setItemDetailsRenderer(new ComponentRenderer<>(
() -> details, RolloutDetails::setItem));
}
},
(query, rsqlFilter) -> Optional.ofNullable(
hawkbitClient.getRolloutRestApi()
.getRollouts(
rsqlFilter, query.getOffset(), query.getPageSize(), Constants.NAME_ASC, "full")
.getBody()).stream().flatMap(page -> page.getContent().stream()),
selectionGrid -> new CreateDialog(hawkbitClient).result(),
selectionGrid -> {
selectionGrid.getSelectedItems().forEach(
rollout -> hawkbitClient.getRolloutRestApi().delete(rollout.getId()));
selectionGrid.refreshGrid(false);
return CompletableFuture.completedFuture(null);
});
}
private static class Actions extends HorizontalLayout {
private final long rolloutId;
private final Grid<MgmtRolloutResponseBody> grid;
private final transient HawkbitMgmtClient hawkbitClient;
private Actions(final MgmtRolloutResponseBody rollout, final Grid<MgmtRolloutResponseBody> grid,
final HawkbitMgmtClient hawkbitClient) {
this.rolloutId = rollout.getId();
this.grid = grid;
this.hawkbitClient = hawkbitClient;
init(rollout);
}
private void init(final MgmtRolloutResponseBody rollout) {
if ("READY".equalsIgnoreCase(rollout.getStatus())) {
add(Utils.tooltip(new Button(VaadinIcon.START_COG.create()) {
{
addClickListener(v -> {
hawkbitClient.getRolloutRestApi().start(rollout.getId());
refresh();
});
}
}, "Start"));
} else if ("RUNNING".equalsIgnoreCase(rollout.getStatus())) {
add(Utils.tooltip(new Button(VaadinIcon.PAUSE.create()) {
{
addClickListener(v -> {
hawkbitClient.getRolloutRestApi().pause(rollout.getId());
refresh();
});
}
}, "Pause"));
} else if ("PAUSED".equalsIgnoreCase(rollout.getStatus())) {
add(Utils.tooltip(new Button(VaadinIcon.START_COG.create()) {
{
addClickListener(v -> {
hawkbitClient.getRolloutRestApi().resume(rollout.getId());
refresh();
});
}
}, "Resume"));
}
add(Utils.tooltip(new Button(VaadinIcon.TRASH.create()) {
{
addClickListener(v -> {
hawkbitClient.getRolloutRestApi().delete(rollout.getId());
grid.getDataProvider().refreshAll();
});
}
}, "Cancel and Remove"));
}
private void refresh() {
removeAll();
final MgmtRolloutResponseBody body = hawkbitClient.getRolloutRestApi().getRollout(rolloutId).getBody();
if (body != null) {
init(body);
}
}
}
private static class RolloutFilter implements Filter.Rsql {
private final TextField name = Utils.textField(Constants.NAME);
private RolloutFilter() {
name.setPlaceholder("<name filter>");
}
@Override
public List<Component> components() {
return List.of(name);
}
@Override
public String filter() {
return Filter.filter(Map.of("name", name.getOptionalValue()));
}
}
private static class RolloutDetails extends FormLayout {
private final transient HawkbitMgmtClient hawkbitClient;
private final TextArea description = new TextArea(Constants.DESCRIPTION);
private final TextField createdBy = Utils.textField(Constants.CREATED_BY);
private final TextField createdAt = Utils.textField(Constants.CREATED_AT);
private final TextField lastModifiedBy = Utils.textField(Constants.LAST_MODIFIED_BY);
private final TextField lastModifiedAt = Utils.textField(Constants.LAST_MODIFIED_AT);
private final TextField targetFilter = Utils.textField(Constants.TARGET_FILTER);
private final TextField distributionSet = Utils.textField(Constants.DISTRIBUTION_SET);
private final TextField actonType = Utils.textField(Constants.ACTION_TYPE);
private final TextField startAt = Utils.textField(Constants.START_AT);
private final Checkbox dynamic = new Checkbox(Constants.DYNAMIC);
private final SelectionGrid<MgmtRolloutGroupResponseBody, Long> groupGrid;
private RolloutDetails(final HawkbitMgmtClient hawkbitClient) {
this.hawkbitClient = hawkbitClient;
description.setMinLength(2);
groupGrid = createGroupGrid();
Stream.of(
description,
createdBy, createdAt,
lastModifiedBy, lastModifiedAt,
targetFilter, distributionSet,
actonType, startAt)
.forEach(field -> {
field.setReadOnly(true);
add(field);
});
dynamic.setReadOnly(true);
dynamic.setEnabled(false);
add(dynamic);
add(groupGrid);
setResponsiveSteps(new ResponsiveStep("0", 2));
setColspan(description, 2);
setColspan(groupGrid, 2);
}
private void setItem(final MgmtRolloutResponseBody rollout) {
description.setValue(rollout.getDescription());
createdBy.setValue(rollout.getCreatedBy());
createdAt.setValue(Utils.localDateTimeFromTs(rollout.getCreatedAt()));
lastModifiedBy.setValue(rollout.getLastModifiedBy());
lastModifiedAt.setValue(Utils.localDateTimeFromTs(rollout.getLastModifiedAt()));
targetFilter.setValue(rollout.getTargetFilterQuery());
final MgmtDistributionSet distributionSetMgmt = hawkbitClient.getDistributionSetRestApi()
.getDistributionSet(rollout.getDistributionSetId()).getBody();
distributionSet.setValue(distributionSetMgmt == null
? NOT_AVAILABLE_NULL // should not be the case
: distributionSetMgmt.getName() + ":" + distributionSetMgmt.getVersion());
actonType.setValue(switch (rollout.getType()) {
case SOFT -> Constants.SOFT;
case FORCED -> Constants.FORCED;
case DOWNLOAD_ONLY -> Constants.DOWNLOAD_ONLY;
case TIMEFORCED -> "Scheduled at " + Utils.localDateTimeFromTs(rollout.getForcetime());
});
startAt.setValue(ObjectUtils.isEmpty(rollout.getStartAt()) ? "" : Utils.localDateTimeFromTs(rollout.getStartAt()));
dynamic.setValue(rollout.isDynamic());
groupGrid.setItems(query -> Optional.ofNullable(
hawkbitClient.getRolloutRestApi()
.getRolloutGroups(
rollout.getId(),
null, query.getOffset(), query.getPageSize(),
null, "full")
.getBody())
.stream().flatMap(body -> body.getContent().stream())
.skip(query.getOffset())
.limit(query.getPageSize()));
groupGrid.setSelectionMode(Grid.SelectionMode.NONE);
}
private static SelectionGrid<MgmtRolloutGroupResponseBody, Long> createGroupGrid() {
return new SelectionGrid<>(
new SelectionGrid.EntityRepresentation<>(MgmtRolloutGroupResponseBody.class, MgmtRolloutGroupResponseBody::getId) {
@Override
protected void addColumns(final Grid<MgmtRolloutGroupResponseBody> grid) {
grid.addColumn(MgmtRolloutGroupResponseBody::getId).setHeader(Constants.ID).setAutoWidth(true);
grid.addColumn(MgmtRolloutGroupResponseBody::getName).setHeader(Constants.NAME).setAutoWidth(true);
grid.addColumn(MgmtRolloutGroupResponseBody::getTotalTargets).setHeader(Constants.TARGET_COUNT).setAutoWidth(true);
grid.addColumn(MgmtRolloutGroupResponseBody::getTotalTargetsPerStatus).setHeader(Constants.STATS).setAutoWidth(
true);
grid.addColumn(MgmtRolloutGroupResponseBody::getStatus).setHeader(Constants.STATUS).setAutoWidth(true);
}
});
}
}
private static class CreateDialog extends Utils.BaseDialog<Void> {
private final TextField name;
private final Select<MgmtDistributionSet> distributionSet;
private final Select<MgmtTargetFilterQuery> targetFilter;
private final TextArea description;
private final Select<MgmtActionType> actionType;
private final DateTimePicker forceTime = new DateTimePicker("Force Time");
private final Select<StartType> startType;
private final DateTimePicker startAt = new DateTimePicker(Constants.START_AT);
private final NumberField groupNumber;
private final NumberField triggerThreshold;
private final NumberField errorThreshold;
private final Checkbox dynamic = new Checkbox(Constants.DYNAMIC);
private final Button create = new Button("Create");
private CreateDialog(final HawkbitMgmtClient hawkbitClient) {
super("Create Rollout");
name = Utils.textField("Name", this::readyToCreate);
name.focus();
distributionSet = new Select<>(
"Distribution Set",
this::readyToCreate,
Optional.ofNullable(
hawkbitClient.getDistributionSetRestApi()
.getDistributionSets(null, 0, 30, Constants.CREATED_AT_DESC)
.getBody())
.map(body -> body.getContent().toArray(new MgmtDistributionSet[0]))
.orElseGet(() -> new MgmtDistributionSet[0]));
distributionSet.setRequiredIndicatorVisible(true);
distributionSet.setItemLabelGenerator(distributionSetO -> distributionSetO.getName() + ":" + distributionSetO.getVersion());
distributionSet.setWidthFull();
targetFilter = new Select<>(
"Target Filter",
this::readyToCreate,
Optional.ofNullable(
hawkbitClient.getTargetFilterQueryRestApi()
.getFilters(null, 0, 30, Constants.NAME_ASC, null)
.getBody())
.map(body -> body.getContent().toArray(new MgmtTargetFilterQuery[0]))
.orElseGet(() -> new MgmtTargetFilterQuery[0]));
targetFilter.setRequiredIndicatorVisible(true);
targetFilter.setItemLabelGenerator(MgmtTargetFilterQuery::getName);
targetFilter.setWidthFull();
description = new TextArea(Constants.DESCRIPTION);
description.setMinLength(2);
description.setWidthFull();
actionType = Utils.actionTypeControls(forceTime);
startType = new Select<>();
startType.setValue(StartType.MANUAL);
startType.setLabel(Constants.START_TYPE);
startType.setItems(StartType.values());
startType.setValue(StartType.MANUAL);
final ComponentRenderer<Component, StartType> startTypeRenderer = new ComponentRenderer<>(startTypeO -> switch (startTypeO) {
case MANUAL -> new Text(Constants.MANUAL);
case AUTO -> new Text(Constants.AUTO);
case SCHEDULED -> startAt;
});
startType.setRenderer(startTypeRenderer);
startType.addValueChangeListener(e -> startType.setRenderer(startTypeRenderer));
startType.setItemLabelGenerator(startTypeO -> switch (startTypeO) {
case MANUAL -> Constants.MANUAL;
case AUTO -> Constants.AUTO;
case SCHEDULED -> "Scheduled" + (startAt.isEmpty() ? "" : " at " + startAt.getValue());
});
startType.setWidthFull();
final Div percentSuffix = new Div();
percentSuffix.setText("%");
groupNumber = Utils.numberField("Group number", this::readyToCreate);
groupNumber.setMin(1);
groupNumber.setValue(1.0);
triggerThreshold = Utils.numberField("Trigger Threshold", this::readyToCreate);
triggerThreshold.setMin(0);
triggerThreshold.setMax(100);
triggerThreshold.setValue(100.0);
triggerThreshold.setSuffixComponent(percentSuffix);
errorThreshold = Utils.numberField("Error Threshold", this::readyToCreate);
errorThreshold.setMin(1);
errorThreshold.setMax(100);
errorThreshold.setValue(10.0);
errorThreshold.setSuffixComponent(percentSuffix);
create.setEnabled(false);
create.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
addCreateClickListener(hawkbitClient);
final Button cancel = Utils.tooltip(new Button(CANCEL), CANCEL_ESC);
cancel.addClickListener(e -> close());
cancel.addClickShortcut(Key.ESCAPE);
getFooter().add(cancel);
getFooter().add(create);
final VerticalLayout layout = new VerticalLayout();
layout.setSizeFull();
layout.setSpacing(false);
layout.add(
name, distributionSet, targetFilter, description,
actionType, startType,
groupNumber, triggerThreshold, errorThreshold,
dynamic);
add(layout);
open();
}
private void readyToCreate(final Object v) {
final boolean createEnabled = !name.isEmpty() && !distributionSet.isEmpty() && !targetFilter.isEmpty() && !groupNumber
.isEmpty() && !triggerThreshold.isEmpty() && !errorThreshold.isEmpty();
if (create.isEnabled() != createEnabled) {
create.setEnabled(createEnabled);
}
}
private void addCreateClickListener(final HawkbitMgmtClient hawkbitClient) {
create.addClickListener(e -> {
close();
final MgmtRolloutRestRequestBodyPost request = new MgmtRolloutRestRequestBodyPost();
request.setName(name.getValue());
request.setDistributionSetId(distributionSet.getValue().getId());
request.setTargetFilterQuery(targetFilter.getValue().getQuery());
request.setDescription(description.getValue());
request.setType(actionType.getValue());
if (actionType.getValue() == MgmtActionType.TIMEFORCED) {
request.setForcetime(
forceTime.isEmpty() ? System.currentTimeMillis() : forceTime.getValue().toEpochSecond(ZoneOffset.UTC) * 1000);
}
switch (startType.getValue()) {
case AUTO -> request.setStartAt(System.currentTimeMillis());
case SCHEDULED -> request.setStartAt(
startAt.isEmpty() ? System.currentTimeMillis() : startAt.getValue().toEpochSecond(ZoneOffset.UTC) * 1000);
case MANUAL -> {
// do nothing, will be started manually
}
}
request.setAmountGroups(groupNumber.getValue().intValue());
request.setSuccessCondition(
new MgmtRolloutCondition(
MgmtRolloutCondition.Condition.THRESHOLD,
triggerThreshold.getValue().intValue() + ""));
request.setErrorCondition(
new MgmtRolloutCondition(
MgmtRolloutCondition.Condition.THRESHOLD,
errorThreshold.getValue().intValue() + ""));
request.setErrorAction(
new MgmtRolloutErrorAction(
MgmtRolloutErrorAction.ErrorAction.PAUSE, ""));
request.setDynamic(dynamic.getValue());
hawkbitClient.getRolloutRestApi().create(request).getBody();
});
}
private enum StartType {
MANUAL, AUTO, SCHEDULED
}
}
}

View File

@@ -0,0 +1,432 @@
/**
* Copyright (c) 2023 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.ui.view;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Stream;
import jakarta.annotation.security.RolesAllowed;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.Key;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.checkbox.Checkbox;
import com.vaadin.flow.component.checkbox.CheckboxGroup;
import com.vaadin.flow.component.dependency.Uses;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.select.Select;
import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.component.upload.Upload;
import com.vaadin.flow.data.renderer.ComponentRenderer;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.server.streams.UploadEvent;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.mgmt.json.model.PagedList;
import org.eclipse.hawkbit.mgmt.json.model.artifact.MgmtArtifact;
import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtDistributionSetRequestBodyPost;
import org.eclipse.hawkbit.mgmt.json.model.distributionsettype.MgmtDistributionSetType;
import org.eclipse.hawkbit.mgmt.json.model.softwaremodule.MgmtSoftwareModule;
import org.eclipse.hawkbit.mgmt.json.model.softwaremodule.MgmtSoftwareModuleAssignment;
import org.eclipse.hawkbit.mgmt.json.model.softwaremodule.MgmtSoftwareModuleRequestBodyPost;
import org.eclipse.hawkbit.mgmt.json.model.softwaremoduletype.MgmtSoftwareModuleType;
import org.eclipse.hawkbit.ui.HawkbitMgmtClient;
import org.eclipse.hawkbit.ui.MainLayout;
import org.eclipse.hawkbit.ui.view.util.Filter;
import org.eclipse.hawkbit.ui.view.util.SelectionGrid;
import org.eclipse.hawkbit.ui.view.util.TableView;
import org.eclipse.hawkbit.ui.view.util.Utils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.multipart.MultipartFile;
@PageTitle("Software Modules")
@Route(value = "software_modules", layout = MainLayout.class)
@RolesAllowed({ "SOFTWARE_MODULE_READ" })
@Uses(Icon.class)
@Slf4j
public class SoftwareModuleView extends TableView<MgmtSoftwareModule, Long> {
@Autowired
public SoftwareModuleView(final HawkbitMgmtClient hawkbitClient) {
this(true, hawkbitClient);
}
public SoftwareModuleView(final boolean isParent, final HawkbitMgmtClient hawkbitClient) {
super(
new SoftwareModuleFilter(hawkbitClient),
new SelectionGrid.EntityRepresentation<>(MgmtSoftwareModule.class, MgmtSoftwareModule::getId) {
private final SoftwareModuleDetails details = new SoftwareModuleDetails(hawkbitClient);
@Override
protected void addColumns(final Grid<MgmtSoftwareModule> grid) {
grid.addColumn(MgmtSoftwareModule::getId).setHeader(Constants.ID).setAutoWidth(true);
grid.addColumn(MgmtSoftwareModule::getName).setHeader(Constants.NAME).setAutoWidth(true);
grid.addColumn(MgmtSoftwareModule::getVersion).setHeader(Constants.VERSION).setAutoWidth(true);
grid.addColumn(MgmtSoftwareModule::getTypeName).setHeader(Constants.TYPE).setAutoWidth(true);
grid.addColumn(MgmtSoftwareModule::getVendor).setHeader(Constants.VENDOR).setAutoWidth(true);
grid.setItemDetailsRenderer(new ComponentRenderer<>(() -> details, SoftwareModuleDetails::setItem));
}
},
(query, rsqlFilter) -> Optional.ofNullable(
hawkbitClient.getSoftwareModuleRestApi()
.getSoftwareModules(rsqlFilter, query.getOffset(), query.getPageSize(), Constants.NAME_ASC)
.getBody())
.stream().map(PagedList::getContent).flatMap(List::stream),
isParent ? v -> new CreateDialog(hawkbitClient).result() : null,
isParent ? selectionGrid -> {
selectionGrid.getSelectedItems().forEach(
module -> hawkbitClient.getSoftwareModuleRestApi().deleteSoftwareModule(module.getId()));
selectionGrid.refreshGrid(false);
return CompletableFuture.completedFuture(null);
} : null);
}
public Set<MgmtSoftwareModule> getSelection() {
return selectionGrid.getSelectedItems();
}
private static SelectionGrid<MgmtArtifact, Long> createArtifactGrid() {
return new SelectionGrid<>(
new SelectionGrid.EntityRepresentation<>(MgmtArtifact.class, MgmtArtifact::getId) {
@Override
protected void addColumns(final Grid<MgmtArtifact> grid) {
grid.addColumn(MgmtArtifact::getId).setHeader(Constants.ID).setAutoWidth(true);
grid.addColumn(MgmtArtifact::getProvidedFilename).setHeader(Constants.NAME).setAutoWidth(true);
grid.addColumn(MgmtArtifact::getSize).setHeader("Size").setAutoWidth(true);
}
});
}
private static class SoftwareModuleFilter implements Filter.Rsql {
private final TextField name = Utils.textField(Constants.NAME);
private final CheckboxGroup<MgmtSoftwareModuleType> type = new CheckboxGroup<>(Constants.TYPE);
private SoftwareModuleFilter(final HawkbitMgmtClient hawkbitClient) {
name.setPlaceholder("<name filter>");
type.setItemLabelGenerator(MgmtSoftwareModuleType::getName);
type.setItems(Optional.ofNullable(
hawkbitClient.getSoftwareModuleTypeRestApi()
.getTypes(null, 0, 20, Constants.NAME_ASC)
.getBody())
.map(PagedList::getContent)
.orElseGet(Collections::emptyList));
}
@Override
public List<Component> components() {
return List.of(name, type);
}
@Override
public String filter() {
return Filter.filter(
Map.of(
"name", name.getOptionalValue(),
"type", type.getSelectedItems().stream().map(MgmtSoftwareModuleType::getKey).toList()
));
}
}
private static class SoftwareModuleDetails extends FormLayout {
private final transient HawkbitMgmtClient hawkbitClient;
private final TextArea description = new TextArea(Constants.DESCRIPTION);
private final TextField createdBy = Utils.textField(Constants.CREATED_BY);
private final TextField createdAt = Utils.textField(Constants.CREATED_AT);
private final TextField lastModifiedBy = Utils.textField(Constants.LAST_MODIFIED_BY);
private final TextField lastModifiedAt = Utils.textField(Constants.LAST_MODIFIED_AT);
private final SelectionGrid<MgmtArtifact, Long> artifactGrid;
private SoftwareModuleDetails(final HawkbitMgmtClient hawkbitClient) {
this.hawkbitClient = hawkbitClient;
description.setMinLength(2);
artifactGrid = createArtifactGrid();
Stream.of(description, createdBy, createdAt, lastModifiedBy, lastModifiedAt).forEach(field -> {
field.setReadOnly(true);
add(field);
});
add(artifactGrid);
setResponsiveSteps(new ResponsiveStep("0", 2));
setColspan(description, 2);
setColspan(artifactGrid, 2);
}
private void setItem(final MgmtSoftwareModule softwareModule) {
description.setValue(softwareModule.getDescription());
createdBy.setValue(softwareModule.getCreatedBy());
createdAt.setValue(Utils.localDateTimeFromTs(softwareModule.getCreatedAt()));
lastModifiedBy.setValue(softwareModule.getLastModifiedBy());
lastModifiedAt.setValue(Utils.localDateTimeFromTs(softwareModule.getLastModifiedAt()));
artifactGrid.setItems(query -> Optional.ofNullable(
hawkbitClient.getSoftwareModuleRestApi()
.getArtifacts(softwareModule.getId(), null, null)
.getBody())
.stream()
.flatMap(Collection::stream)
.skip(query.getOffset())
.limit(query.getPageSize()));
artifactGrid.setSelectionMode(Grid.SelectionMode.NONE);
}
}
private static class CreateDialog extends Utils.BaseDialog<Void> {
private final Select<MgmtSoftwareModuleType> type;
private final TextField name;
private final TextField version;
private final TextField vendor;
private final TextArea description;
private final Checkbox enableArtifactEncryption;
private final Checkbox createDistributionSet;
private final Select<MgmtDistributionSetType> distType;
private final Checkbox distRequiredMigrationStep;
private final Button create;
private CreateDialog(final HawkbitMgmtClient hawkbitClient) {
super("Create Software Module");
type = new Select<>(
Constants.TYPE,
this::readyToCreate,
Optional.ofNullable(
hawkbitClient.getSoftwareModuleTypeRestApi()
.getTypes(null, 0, 30, Constants.NAME_ASC)
.getBody())
.map(body -> body.getContent().toArray(new MgmtSoftwareModuleType[0]))
.orElseGet(() -> new MgmtSoftwareModuleType[0]));
type.setWidthFull();
type.setRequiredIndicatorVisible(true);
type.setItemLabelGenerator(MgmtSoftwareModuleType::getName);
type.focus();
name = Utils.textField(Constants.NAME, this::readyToCreate);
version = Utils.textField(Constants.VERSION, this::readyToCreate);
vendor = Utils.textField(Constants.VENDOR);
description = new TextArea(Constants.DESCRIPTION);
description.setWidthFull();
description.setMinLength(2);
enableArtifactEncryption = new Checkbox("Enable artifact encryption");
distType = new Select<>("Distribution Set Type", this::readyToCreate);
distType.setWidthFull();
distType.setRequiredIndicatorVisible(true);
distType.setItemLabelGenerator(MgmtDistributionSetType::getName);
distType.setVisible(false);
distRequiredMigrationStep = new Checkbox("Required Migration Step");
distRequiredMigrationStep.setVisible(false);
createDistributionSet = new Checkbox("Create single software module distribution set");
createDistributionSet.setHelperText("Create single software module distribution set with this software module");
createDistributionSet.addValueChangeListener(e -> {
if (Boolean.TRUE.equals(createDistributionSet.getValue()) && distType.isEmpty()) {
distType.setItems(
Optional.ofNullable(
hawkbitClient.getDistributionSetTypeRestApi()
.getDistributionSetTypes(null, 0, 30, Constants.NAME_ASC)
.getBody())
.map(body -> body.getContent().toArray(new MgmtDistributionSetType[0]))
.orElseGet(() -> new MgmtDistributionSetType[0]));
}
distType.setVisible(createDistributionSet.getValue());
distRequiredMigrationStep.setVisible(createDistributionSet.getValue());
readyToCreate(e);
});
create = Utils.tooltip(new Button("Create"), "Create (Enter)");
create.setEnabled(false);
addCreateClickListener(hawkbitClient);
create.addClickShortcut(Key.ENTER);
create.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
final Button cancel = Utils.tooltip(new Button(CANCEL), CANCEL_ESC);
cancel.addClickListener(e -> close());
cancel.addClickShortcut(Key.ESCAPE);
getFooter().add(cancel);
getFooter().add(create);
final VerticalLayout layout = new VerticalLayout();
layout.setSizeFull();
layout.setSpacing(false);
layout.add(
type, name, version, vendor, description, enableArtifactEncryption,
createDistributionSet, distType, distRequiredMigrationStep);
add(layout);
open();
}
private void readyToCreate(final Object v) {
final boolean createEnabled = !type.isEmpty() && !name.isEmpty() && !version.isEmpty() && (!createDistributionSet
.getValue() || !distType.isEmpty());
if (create.isEnabled() != createEnabled) {
create.setEnabled(createEnabled);
}
}
private void addCreateClickListener(final HawkbitMgmtClient hawkbitClient) {
create.addClickListener(e -> {
close();
final long softwareModuleId = Optional.ofNullable(
hawkbitClient.getSoftwareModuleRestApi().createSoftwareModules(
List.of(new MgmtSoftwareModuleRequestBodyPost()
.setType(type.getValue().getKey())
.setName(name.getValue())
.setVersion(version.getValue())
.setVendor(vendor.getValue())
.setDescription(description.getValue())
.setEncrypted(enableArtifactEncryption.getValue())))
.getBody())
.stream().flatMap(Collection::stream)
.findFirst()
.orElseThrow()
.getId();
if (Boolean.TRUE.equals(createDistributionSet.getValue())) {
final long distributionSetId = Optional.ofNullable(
hawkbitClient.getDistributionSetRestApi()
.createDistributionSets(
List.of((MgmtDistributionSetRequestBodyPost) new MgmtDistributionSetRequestBodyPost()
.setType(distType.getValue().getKey())
.setName(name.getValue())
.setVersion(version.getValue())
.setDescription(description.getValue())
.setRequiredMigrationStep(distRequiredMigrationStep.getValue())))
.getBody())
.stream()
.flatMap(Collection::stream)
.findFirst()
.orElseThrow()
.getId();
hawkbitClient.getDistributionSetRestApi().assignSoftwareModules(
distributionSetId, List.of((MgmtSoftwareModuleAssignment) new MgmtSoftwareModuleAssignment()
.setId(softwareModuleId))).getBody();
}
new AddArtifactsDialog(softwareModuleId, hawkbitClient).open();
});
}
}
private static class AddArtifactsDialog extends Utils.BaseDialog<Void> {
private final transient Set<MgmtArtifact> artifacts = Collections.synchronizedSet(new HashSet<>());
private AddArtifactsDialog(
final long softwareModuleId,
final HawkbitMgmtClient hawkbitClient) {
super("Add Artifacts");
final SelectionGrid<MgmtArtifact, Long> artifactGrid = createArtifactGrid();
artifactGrid.setItems(query -> {
query.getOffset(); // to keep vaadin contract
return artifacts.stream().limit(query.getLimit());
});
artifactGrid.setSelectionMode(Grid.SelectionMode.NONE);
final Upload uploadBtn = new Upload(uploadEvent -> {
final MgmtArtifact artifact = hawkbitClient.getSoftwareModuleRestApi()
.uploadArtifact(
softwareModuleId,
new MultipartFileImpl(uploadEvent),
uploadEvent.getFileName(), null, null, null)
.getBody();
artifacts.add(artifact);
artifactGrid.refreshGrid(false);
});
uploadBtn.setMaxFiles(10);
uploadBtn.setWidthFull();
uploadBtn.setDropAllowed(true);
final Button finishBtn = Utils.tooltip(new Button("Finish"), "Finish (Enter)");
finishBtn.addClickListener(e -> close());
finishBtn.addClickShortcut(Key.ENTER);
finishBtn.setHeightFull();
finishBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
getFooter().add(finishBtn);
final VerticalLayout layout = new VerticalLayout(artifactGrid, uploadBtn);
layout.setSizeFull();
layout.setSpacing(false);
add(layout);
}
private static class MultipartFileImpl implements MultipartFile {
private final UploadEvent uploadEvent;
public MultipartFileImpl(final UploadEvent uploadEvent) {
this.uploadEvent = uploadEvent;
}
@Override
public String getName() {
return uploadEvent.getFileName();
}
@Override
public String getOriginalFilename() {
return getName();
}
@Override
public String getContentType() {
return uploadEvent.getContentType();
}
@Override
public boolean isEmpty() {
return uploadEvent.getFileSize() == 0;
}
@Override
public long getSize() {
return uploadEvent.getFileSize();
}
@Override
public byte[] getBytes() throws IOException {
log.warn("Multipart file getBytes() is called. Whole input stream is loaded into the memory!");
try (final InputStream is = getInputStream()) {
return is.readAllBytes();
}
}
@Override
public InputStream getInputStream() {
return uploadEvent.getInputStream();
}
@Override
public void transferTo(final File dest) throws IllegalStateException {
throw new UnsupportedOperationException();
}
}
}
}

View File

@@ -0,0 +1,963 @@
/**
* Copyright (c) 2023 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.ui.view;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.vaadin.flow.data.provider.ListDataProvider;
import jakarta.annotation.security.RolesAllowed;
import com.vaadin.flow.component.AttachEvent;
import com.vaadin.flow.component.ClickEvent;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.ComponentEventListener;
import com.vaadin.flow.component.Key;
import com.vaadin.flow.component.Unit;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.checkbox.CheckboxGroup;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.datetimepicker.DateTimePicker;
import com.vaadin.flow.component.dependency.Uses;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.Input;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.select.Select;
import com.vaadin.flow.component.tabs.TabSheet;
import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.renderer.ComponentRenderer;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.shared.Registration;
import com.vaadin.flow.theme.lumo.LumoUtility;
import lombok.EqualsAndHashCode;
import org.eclipse.hawkbit.mgmt.json.model.MgmtPollStatus;
import org.eclipse.hawkbit.mgmt.json.model.PagedList;
import org.eclipse.hawkbit.mgmt.json.model.action.MgmtActionStatus;
import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtActionType;
import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtDistributionSet;
import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtTargetAssignmentRequestBody;
import org.eclipse.hawkbit.mgmt.json.model.tag.MgmtTag;
import org.eclipse.hawkbit.mgmt.json.model.tag.MgmtTagRequestBodyPut;
import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTarget;
import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTargetAttributes;
import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTargetRequestBody;
import org.eclipse.hawkbit.mgmt.json.model.targetfilter.MgmtTargetFilterQuery;
import org.eclipse.hawkbit.mgmt.json.model.targetfilter.MgmtTargetFilterQueryRequestBody;
import org.eclipse.hawkbit.mgmt.json.model.targettype.MgmtTargetType;
import org.eclipse.hawkbit.ui.HawkbitMgmtClient;
import org.eclipse.hawkbit.ui.MainLayout;
import org.eclipse.hawkbit.ui.component.TargetActionsHistory;
import org.eclipse.hawkbit.ui.view.util.Filter;
import org.eclipse.hawkbit.ui.view.util.LinkedTextArea;
import org.eclipse.hawkbit.ui.view.util.SelectionGrid;
import org.eclipse.hawkbit.ui.view.util.TableView;
import org.eclipse.hawkbit.ui.view.util.Utils;
import org.springframework.http.ResponseEntity;
import org.springframework.util.ObjectUtils;
@PageTitle("Targets")
@Route(value = "targets", layout = MainLayout.class)
@RolesAllowed({ "TARGET_READ" })
@Uses(Icon.class)
public class TargetView extends TableView<TargetView.TargetWithDs, String> {
public static final String STATUS = "Status";
public static final String UPDATE = "Sync";
public static final String CONTROLLER_ID = "Controller Id";
public static final String FILTER = "Filter";
public static final String TAG = "Tag";
public static final String GREEN = "green";
public static final String RED = "red";
public static final String ORANGE = "orange";
public static final String GRAY = "gray";
public static final String BROWN = "brown";
public static final String TEAL = "teal";
public static final String PURPLE = "purple";
public static final String CORAL = "coral";
public static final String BLACK = "black";
public TargetView(final HawkbitMgmtClient hawkbitClient) {
super(
new RawFilter(hawkbitClient), new SimpleFilter(hawkbitClient),
new SelectionGrid.EntityRepresentation<>(TargetWithDs.class, TargetWithDs::getControllerId) {
@Override
protected void addColumns(final Grid<TargetWithDs> grid) {
grid.addColumn(new ComponentRenderer<>(TargetStatusCell::new))
.setHeader(STATUS)
.setAutoWidth(true)
.setFlexGrow(0).setKey("lastControllerRequestAt").setSortable(true);
grid.addColumn(new ComponentRenderer<>(TargetUpdateStatusCell::new))
.setHeader(UPDATE)
.setAutoWidth(true)
.setFlexGrow(0).setKey("updateStatus").setSortable(true);
grid.addColumn(MgmtTarget::getControllerId).setHeader(CONTROLLER_ID).setAutoWidth(true).setKey("id").setSortable(true);
grid.addColumn(Utils.localDateTimeRenderer(MgmtTarget::getLastModifiedAt)).setHeader(LAST_MODIFIED_AT).setAutoWidth(
true).setKey("lastModifiedAt").setSortable(true);
grid.addColumn(MgmtTarget::getName).setHeader(Constants.NAME).setAutoWidth(true).setKey("name").setSortable(true);
grid.addColumn(MgmtTarget::getTargetTypeName).setHeader(Constants.TYPE).setAutoWidth(true).setKey("targetType")
.setSortable(true);
grid.addColumn(TargetWithDs::getDsName).setHeader(Constants.DISTRIBUTION_SET).setAutoWidth(true);
grid.addColumn(TargetWithDs::getDsVersion).setHeader(Constants.VERSION).setAutoWidth(true).setKey("installedds")
.setSortable(true);
}
},
(query, filter) -> hawkbitClient.getTargetRestApi()
.getTargets(filter, query.getOffset(), query.getPageSize(), Utils.getSortParam(query.getSortOrders(),
"lastModifiedAt:desc"))
.getBody()
.getContent()
.stream().map(m -> TargetWithDs.from(hawkbitClient, m)),
source -> new RegisterDialog(hawkbitClient).result(),
selectionGrid -> {
selectionGrid.getSelectedItems()
.forEach(toDelete -> hawkbitClient.getTargetRestApi().deleteTarget(toDelete.getControllerId()));
return CompletableFuture.completedFuture(null);
},
target -> {
final TargetDetailedView targetDetailedView = new TargetDetailedView(hawkbitClient);
targetDetailedView.setItem(target);
return targetDetailedView;
}
);
final Function<SelectionGrid<TargetWithDs, String>, CompletionStage<Void>> assignHandler = source -> new AssignDialog(
hawkbitClient, source.getSelectedItems()).result();
final Button assignBtn = Utils.tooltip(new Button(VaadinIcon.LINK.create()), "Assign");
assignBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
assignBtn.addClickListener(e -> assignHandler.apply(selectionGrid).thenAccept(v -> selectionGrid.refreshGrid(false)));
controlsLayout.addComponentAtIndex(0, assignBtn);
}
private static class SimpleFilter implements Filter.Rsql {
private final HawkbitMgmtClient hawkbitClient;
private final TextField textFilter;
private final CheckboxGroup<MgmtTargetType> type;
private final CheckboxGroup<MgmtTag> tag;
private SimpleFilter(final HawkbitMgmtClient hawkbitClient) {
this.hawkbitClient = hawkbitClient;
textFilter = Utils.textField(FILTER);
textFilter.setPlaceholder("<controller id/name filter>");
type = new CheckboxGroup<>(Constants.TYPE);
type.setItemLabelGenerator(MgmtTargetType::getName);
tag = new CheckboxGroup<>(TAG);
tag.setItemLabelGenerator(MgmtTag::getName);
}
@Override
public List<Component> components() {
final List<Component> components = new LinkedList<>();
components.add(textFilter);
type.setItems(hawkbitClient.getTargetTypeRestApi().getTargetTypes(null, 0, 20, Constants.NAME_ASC).getBody().getContent());
if (!((ListDataProvider) type.getDataProvider()).getItems().isEmpty()) {
components.add(type);
}
tag.setItems(hawkbitClient.getTargetTagRestApi().getTargetTags(null, 0, 20, Constants.NAME_ASC).getBody().getContent());
if (!((ListDataProvider) tag.getDataProvider()).getItems().isEmpty()) {
components.add(tag);
}
return components;
}
@Override
public String filter() {
return Filter.filter(
Map.of(
List.of("controllerid", "name"), textFilter.getOptionalValue().map(s -> "*" + s + "*"),
"targettype.name", type.getSelectedItems().stream().map(MgmtTargetType::getName)
.toList(),
"tag", tag.getSelectedItems().stream().map(MgmtTag::getName).toList()));
}
}
@SuppressWarnings({ "java:S1171", "java:S3599" })
private static class RawFilter implements Filter.Rsql, Filter.RsqlRw {
private final TextField textFilter = new TextField("Raw Filter", "<raw filter>");
private final VerticalLayout layout = new VerticalLayout();
private final Select<MgmtTargetFilterQuery> savedFilters = new Select<>();
private RawFilter(final HawkbitMgmtClient hawkbitClient) {
final Button createBtn = Utils.tooltip(new Button("Save", VaadinIcon.PLUS.create()), "Save");
final Button updateBtn = Utils.tooltip(new Button(VaadinIcon.HARDDRIVE.create()), "Update");
updateBtn.setEnabled(false);
savedFilters.setLabel("Saved Filters");
savedFilters.addValueChangeListener(e -> {
if (e.getValue() != null) {
textFilter.setValue(e.getValue().getQuery());
updateBtn.setEnabled(true);
createBtn.setText("Save as");
} else {
textFilter.clear();
updateBtn.setEnabled(false);
createBtn.setText("Save");
}
});
savedFilters.setEmptySelectionAllowed(true);
savedFilters.setItems(listFilters(hawkbitClient));
savedFilters.setItemLabelGenerator(query -> Optional.ofNullable(query).map(MgmtTargetFilterQuery::getName).orElse(
"<select saved filter>"));
savedFilters.setWidthFull();
textFilter.setWidthFull();
createBtn.addClickListener(createBtnListener(hawkbitClient));
updateBtn.addClickListener(updateBtnListener(hawkbitClient));
layout.setSpacing(false);
layout.setPadding(false);
final HorizontalLayout textSaveLayout = new HorizontalLayout(textFilter, createBtn, updateBtn);
textSaveLayout.setAlignItems(FlexComponent.Alignment.BASELINE);
textSaveLayout.setWidthFull();
layout.add(savedFilters, textSaveLayout);
layout.addClassNames(LumoUtility.Gap.SMALL);
}
private static List<MgmtTargetFilterQuery> listFilters(HawkbitMgmtClient hawkbitClient) {
return Optional
.ofNullable(
hawkbitClient.getTargetFilterQueryRestApi()
.getFilters(null, 0, 30, null, null)
.getBody()
.getContent())
.orElse(Collections.emptyList());
}
private ComponentEventListener<ClickEvent<Button>> createBtnListener(HawkbitMgmtClient hawkbitClient) {
return e -> new Utils.BaseDialog<Void>("Create New Filter") {
{
final Button finishBtn = Utils.tooltip(new Button("Save"), "Save (Enter)");
final TextField name = Utils.textField(Constants.NAME, e -> finishBtn.setEnabled(!e.getHasValue().isEmpty()));
name.focus();
finishBtn.addClickShortcut(Key.ENTER);
finishBtn.setEnabled(false);
finishBtn.addClickListener(e -> {
final MgmtTargetFilterQueryRequestBody createRequest = new MgmtTargetFilterQueryRequestBody();
createRequest.setName(name.getValue());
createRequest.setQuery(textFilter.getValue());
hawkbitClient.getTargetFilterQueryRestApi().createFilter(createRequest);
savedFilters.setItems(listFilters(hawkbitClient));
close();
});
getFooter().add(finishBtn);
add(name);
open();
}
};
}
private ComponentEventListener<ClickEvent<Button>> updateBtnListener(HawkbitMgmtClient hawkbitClient) {
return e -> {
final MgmtTargetFilterQuery selected = savedFilters.getValue();
if (selected == null) {
return;
}
new Utils.BaseDialog<Void>("Update Filter") {
{
final Button finishBtn = Utils.tooltip(new Button("Update"), "Update (Enter)");
finishBtn.setEnabled(false);
final TextField name = Utils.textField(Constants.NAME, e -> finishBtn.setEnabled(!e.getHasValue().isEmpty()));
name.focus();
name.setValue(selected.getName());
final TextArea filterValue = new TextArea("Filter Value");
filterValue.setReadOnly(true);
filterValue.setValue(textFilter.getValue());
filterValue.setWidthFull();
finishBtn.addClickShortcut(Key.ENTER);
finishBtn.addClickListener(e -> {
final MgmtTargetFilterQueryRequestBody updateRequest = new MgmtTargetFilterQueryRequestBody();
updateRequest.setName(name.getValue());
updateRequest.setQuery(textFilter.getValue());
hawkbitClient.getTargetFilterQueryRestApi().updateFilter(selected.getId(), updateRequest);
savedFilters.setItems(listFilters(hawkbitClient));
close();
});
getFooter().add(finishBtn);
add(name);
add(filterValue);
open();
}
};
};
}
@Override
public List<Component> components() {
return List.of(layout);
}
@Override
public String filter() {
return textFilter.getOptionalValue().orElse(null);
}
@Override
public void setFilter(String filter) {
textFilter.setValue(filter);
}
}
protected static class TargetDetailedView extends TabSheet {
private final TargetDetails targetDetails;
private final TargetAssignedInstalled targetAssignedInstalled;
private final TargetTags targetTags;
private final TargetActionsHistoryLayout targetActionsHistoryLayout;
private TargetDetailedView(final HawkbitMgmtClient hawkbitClient) {
targetDetails = new TargetDetails(hawkbitClient);
targetAssignedInstalled = new TargetAssignedInstalled(hawkbitClient);
targetTags = new TargetTags(hawkbitClient);
targetActionsHistoryLayout = new TargetActionsHistoryLayout(hawkbitClient);
setWidthFull();
add("Details", targetDetails);
add("Assigned / Installed", targetAssignedInstalled);
add("Tags", targetTags);
add("Action History", targetActionsHistoryLayout);
}
private void setItem(final MgmtTarget target) {
this.targetDetails.setItem(target);
this.targetAssignedInstalled.setItem(target);
this.targetTags.setItem(target);
this.targetActionsHistoryLayout.setItem(target);
}
}
private static class TargetDetails extends FormLayout {
private final transient HawkbitMgmtClient hawkbitClient;
private final TextArea description = new TextArea(Constants.DESCRIPTION);
private final TextField createdBy = Utils.textField(Constants.CREATED_BY);
private final TextField createdAt = Utils.textField(Constants.CREATED_AT);
private final TextField lastModifiedBy = Utils.textField(Constants.LAST_MODIFIED_BY);
private final TextField lastModifiedAt = Utils.textField(Constants.LAST_MODIFIED_AT);
private final TextField securityToken = Utils.textField(Constants.SECURITY_TOKEN);
private final TextField lastPoll = Utils.textField(Constants.LAST_POLL);
private final TextField group = Utils.textField(Constants.GROUP);
private final TextField targetAddress = Utils.textField(Constants.ADDRESS);
private final TextArea targetAttributes = new TextArea(Constants.ATTRIBUTES);
private transient MgmtTarget target;
private TargetDetails(final HawkbitMgmtClient hawkbitClient) {
this.hawkbitClient = hawkbitClient;
description.setMinLength(2);
Stream.of(
description,
createdBy, createdAt,
lastModifiedBy, lastModifiedAt,
securityToken, lastPoll, group, targetAddress, targetAttributes
)
.forEach(field -> {
field.setReadOnly(true);
add(field);
});
setResponsiveSteps(new ResponsiveStep("0", 2));
setColspan(description, 2);
}
private void setItem(final MgmtTarget target) {
this.target = target;
}
@Override
protected void onAttach(final AttachEvent attachEvent) {
description.setValue(target.getDescription() == null ? "N/A" : target.getDescription());
createdBy.setValue(target.getCreatedBy());
createdAt.setValue(Utils.localDateTimeFromTs(target.getCreatedAt()));
lastModifiedBy.setValue(target.getLastModifiedBy());
lastModifiedAt.setValue(Utils.localDateTimeFromTs(target.getLastModifiedAt()));
securityToken.setValue(Objects.requireNonNullElse(target.getSecurityToken(), ""));
group.setValue(target.getGroup() != null ? target.getGroup() : "");
targetAddress.setValue(target.getAddress() != null ? target.getAddress() : "");
final MgmtPollStatus pollStatus = target.getPollStatus();
lastPoll.setValue(pollStatus == null ? NOT_AVAILABLE_NULL : Utils.localDateTimeFromTs(pollStatus.getLastRequestAt()));
final ResponseEntity<MgmtTargetAttributes> response = hawkbitClient.getTargetRestApi().getAttributes(target.getControllerId());
if (response.getStatusCode().is2xxSuccessful()) {
targetAttributes.setValue(Objects.requireNonNullElse(response.getBody(), Collections.emptyMap()).entrySet().stream()
.map(entry -> entry.getKey() + ": " + entry.getValue())
.collect(Collectors.joining("\n")));
} else {
targetAttributes.setValue("Error occurred fetching attributes from server: " + response.getStatusCode());
}
}
}
private static class TargetAssignedInstalled extends FormLayout {
private final transient HawkbitMgmtClient hawkbitClient;
private final LinkedTextArea assigned = new LinkedTextArea("Assigned Distribution Set", "/distribution_sets?");
private final LinkedTextArea installed = new LinkedTextArea("Installed Distribution Set", "/distribution_sets?");
private transient MgmtTarget target;
private TargetAssignedInstalled(HawkbitMgmtClient hawkbitClient) {
this.hawkbitClient = hawkbitClient;
assigned.setWidthFull();
installed.setWidthFull();
add(assigned, installed);
setResponsiveSteps(new FormLayout.ResponsiveStep("0", 2));
}
private void setItem(final MgmtTarget target) {
this.target = target;
}
@Override
protected void onAttach(AttachEvent attachEvent) {
updateDistributionSetInfo(() -> hawkbitClient.getTargetRestApi().getInstalledDistributionSet(target.getControllerId()), installed);
updateDistributionSetInfo(() -> hawkbitClient.getTargetRestApi().getAssignedDistributionSet(target.getControllerId()), assigned);
}
private void updateDistributionSetInfo(Supplier<ResponseEntity<MgmtDistributionSet>> supplier, LinkedTextArea textArea) {
Optional.ofNullable(supplier.get())
.map(ResponseEntity<MgmtDistributionSet>::getBody)
.ifPresentOrElse(value -> {
final String description = """
Name: %s
Version: %s
%s
""".replace("\n", System.lineSeparator());
textArea.setValueWithLink(description.formatted(
value.getName(),
value.getVersion(),
value.getModules().stream().map(module -> module.getTypeName() + ": " + module.getVersion())
.collect(Collectors.joining(System.lineSeparator()))
), "q=id%3D%3D" + value.getId().toString());
},
() -> textArea.setValueWithLink("", null));
}
}
private static class TargetTags extends VerticalLayout {
private final transient HawkbitMgmtClient hawkbitClient;
private final ComboBox<MgmtTag> tagSelector = new ComboBox<>(TAG);
private final HorizontalLayout tagsArea = new HorizontalLayout();
private Registration changeListener;
private transient MgmtTarget target;
private TargetTags(HawkbitMgmtClient hawkbitClient) {
this.hawkbitClient = hawkbitClient;
setWidthFull();
setPadding(false);
setSpacing(true);
setAlignItems(FlexComponent.Alignment.STRETCH);
setJustifyContentMode(JustifyContentMode.CENTER);
final HorizontalLayout tagSelectorLayout = buildTagSelectionLayout(hawkbitClient);
add(tagSelectorLayout);
tagsArea.setWrap(true);
tagsArea.setWidthFull();
add(tagsArea);
}
private HorizontalLayout buildTagSelectionLayout(HawkbitMgmtClient hawkbitClient) {
final Button createTagButton = new Button("Create Tag");
createTagButton.addClickListener(event -> new CreateTagDialog(hawkbitClient, () -> tagSelector.setItems(fetchAvailableTags()))
.result());
tagSelector.setWidthFull();
tagSelector.setItemLabelGenerator(MgmtTag::getName);
tagSelector.setClearButtonVisible(true);
final HorizontalLayout tagSelectorLayout = new HorizontalLayout(tagSelector, createTagButton);
tagSelectorLayout.setWidthFull();
tagSelectorLayout.setSpacing(true);
tagSelectorLayout.setAlignItems(Alignment.END);
tagSelectorLayout.setJustifyContentMode(JustifyContentMode.CENTER);
return tagSelectorLayout;
}
private void setItem(final MgmtTarget target) {
this.target = target;
}
@Override
protected void onAttach(AttachEvent attachEvent) {
tagSelector.setItems(fetchAvailableTags());
if (changeListener != null) {
changeListener.remove();
}
changeListener = tagSelector.addValueChangeListener(event -> {
if (event.getValue() != null) {
hawkbitClient.getTargetTagRestApi().assignTarget(event.getValue().getId(), target.getControllerId());
refreshTargetTagsList(target);
tagSelector.clear();
}
});
refreshTargetTagsList(target);
}
private void refreshTargetTagsList(MgmtTarget target) {
tagsArea.removeAll();
final List<MgmtTag> tagList = Optional.ofNullable(hawkbitClient.getTargetRestApi().getTags(target.getControllerId()).getBody())
.orElse(Collections.emptyList());
for (MgmtTag tag : tagList) {
tagsArea.add(buildTargetTagBadge(tag, target.getControllerId()));
}
}
private Span buildTargetTagBadge(MgmtTag tag, String controllerId) {
Button clearButton = new Button(VaadinIcon.CLOSE_SMALL.create());
clearButton.addThemeVariants(ButtonVariant.LUMO_CONTRAST, ButtonVariant.LUMO_TERTIARY_INLINE);
clearButton.getStyle().set("margin-inline-start", "var(--lumo-space-xs)");
clearButton.getElement().setAttribute("aria-label", "Clear filter: " + tag.getName());
clearButton.getElement().setAttribute("title", "Clear filter: " + tag.getName());
Span pill = new Span();
pill.getElement().getThemeList().add("badge pill");
pill.getStyle().set("background-color", tag.getColour());
pill.getStyle().set("margin-inline-end", "var(--lumo-space-xs)");
Span badge = new Span(pill, new Span(tag.getName()), clearButton);
badge.getElement().getThemeList().add("badge contrast pill");
clearButton.addClickListener(event -> {
hawkbitClient.getTargetTagRestApi().unassignTarget(tag.getId(), controllerId);
badge.getElement().removeFromParent();
});
return badge;
}
private List<MgmtTag> fetchAvailableTags() {
List<MgmtTag> tags = new ArrayList<>();
int fetched = 0;
int offset = 0;
do {
List<MgmtTag> page = Optional.ofNullable(
hawkbitClient.getTargetTagRestApi().getTargetTags(null, offset, 50, Constants.NAME_ASC).getBody())
.map(PagedList::getContent)
.orElse(Collections.emptyList());
tags.addAll(page);
fetched = page.size();
offset += fetched;
} while (fetched > 0);
return tags;
}
}
public static class TargetActionsHistoryLayout extends VerticalLayout {
private final TargetActionsHistory targetActionsHistory;
public TargetActionsHistoryLayout(HawkbitMgmtClient hawkbitMgmtClient) {
ActionStepsGrid actionStepsGrid = new ActionStepsGrid(hawkbitMgmtClient);
targetActionsHistory = new TargetActionsHistory(hawkbitMgmtClient, actionStepsGrid);
add(targetActionsHistory);
add(actionStepsGrid);
}
public void setItem(MgmtTarget target) {
targetActionsHistory.setItem(target);
}
public static class ActionStepsGrid extends Grid<ActionStepsGrid.ActionStepEntry> {
private final transient HawkbitMgmtClient hawkbitClient;
private transient MgmtTarget target;
private transient Long actionId;
private ActionStepsGrid(final HawkbitMgmtClient hawkbitClient) {
this.hawkbitClient = hawkbitClient;
setWidthFull();
addColumn(new ComponentRenderer<>(ActionStepEntry::getStatusIcon)).setHeader(STATUS).setAutoWidth(true)
.setFlexGrow(0);
addColumn(Utils.localDateTimeRenderer(ActionStepEntry::getLastModifiedAt)).setHeader("Time")
.setAutoWidth(true).setFlexGrow(0).setComparator(ActionStepEntry::getLastModifiedAt);
addColumn(new ComponentRenderer<>(ActionStepEntry::getMessage)).setHeader("Message").setAutoWidth(true).setFlexGrow(0);
}
private List<ActionStepEntry> fetchActionSteps() {
if (actionId == null) {
return new ArrayList<>();
}
return hawkbitClient.getTargetRestApi()
.getActionStatusList(target.getControllerId(), actionId, 0, 30, null).getBody().getContent()
.stream().map(ActionStepEntry::new)
.toList();
}
@Override
protected void onAttach(AttachEvent attachEvent) {
setItems(fetchActionSteps());
}
public void setActionId(Long id) {
actionId = id;
setItems(fetchActionSteps());
}
public void setTarget(MgmtTarget target) {
this.target = target;
actionId = null;
}
private static class ActionStepEntry extends Object {
final MgmtActionStatus status;
public ActionStepEntry(final MgmtActionStatus status) {
this.status = status;
}
public Long getLastModifiedAt() {
return status.getReportedAt();
}
public Component getStatusIcon() {
final HorizontalLayout layout = new HorizontalLayout();
final Icon icon;
switch (status.getType()) {
case FINISHED -> icon = Utils.iconColored(VaadinIcon.CHECK_CIRCLE, "Finished", GREEN);
case ERROR -> icon = Utils.iconColored(VaadinIcon.CLOSE_CIRCLE, "Error", RED);
case WARNING -> icon = Utils.iconColored(VaadinIcon.WARNING, "Warning", ORANGE);
case RUNNING -> icon = Utils.iconColored(VaadinIcon.ADJUST, "Running", GREEN);
case RETRIEVED -> icon = Utils.iconColored(VaadinIcon.CIRCLE_THIN, "Retrieved", GREEN);
case CANCELED -> icon = Utils.iconColored(VaadinIcon.CLOSE_CIRCLE_O, "Canceled", GRAY);
case CANCELING -> icon = Utils.iconColored(VaadinIcon.CLOSE_CIRCLE, "Cancelling", BROWN);
case DOWNLOAD -> icon = Utils.iconColored(VaadinIcon.CLOUD_DOWNLOAD_O, "Download", TEAL);
case DOWNLOADED -> icon = Utils.iconColored(VaadinIcon.CLOUD_DOWNLOAD, "Downloaded", PURPLE);
case WAIT_FOR_CONFIRMATION -> icon = Utils.iconColored(VaadinIcon.QUESTION_CIRCLE, "Wait for confirmation", CORAL);
default -> icon = Utils.iconColored(VaadinIcon.CIRCLE_THIN, status.getType().getName().toLowerCase(), BLACK);
}
icon.addClassNames(LumoUtility.IconSize.SMALL);
layout.add(icon);
layout.setWidth(50, Unit.PIXELS);
layout.setJustifyContentMode(FlexComponent.JustifyContentMode.CENTER);
return layout;
}
public VerticalLayout getMessage() {
return new VerticalLayout(status.getMessages().stream().map(Span::new).toArray(Span[]::new));
}
}
}
}
private static class RegisterDialog extends Utils.BaseDialog<Void> {
private final Select<MgmtTargetType> type;
private final TextField controllerId;
private final TextField name;
private final TextArea description;
private final TextField group;
private RegisterDialog(final HawkbitMgmtClient hawkbitClient) {
super("Register Target");
final Button register = Utils.tooltip(new Button("Register"), "Register (Enter)");
type = new Select<>(
"Type",
e -> {},
hawkbitClient.getTargetTypeRestApi()
.getTargetTypes(null, 0, 30, Constants.NAME_ASC)
.getBody()
.getContent()
.toArray(new MgmtTargetType[0])
);
type.setWidthFull();
type.setEmptySelectionAllowed(true);
type.setItemLabelGenerator(item -> item == null ? "" : item.getName());
controllerId = Utils.textField(CONTROLLER_ID, e -> register.setEnabled(!e.getHasValue().isEmpty()));
controllerId.focus();
name = Utils.textField(Constants.NAME);
name.setWidthFull();
description = new TextArea(Constants.DESCRIPTION);
description.setMinLength(2);
description.setWidthFull();
group = Utils.textField(Constants.GROUP);
group.setWidthFull();
addCreateClickListener(register, hawkbitClient);
register.setEnabled(false);
register.addClickShortcut(Key.ENTER);
register.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
final Button cancel = Utils.tooltip(new Button(CANCEL), CANCEL_ESC);
cancel.addClickListener(e -> close());
cancel.addClickShortcut(Key.ESCAPE);
getFooter().add(cancel);
getFooter().add(register);
final VerticalLayout layout = new VerticalLayout();
layout.setSizeFull();
layout.setPadding(true);
layout.setSpacing(false);
layout.add(type, controllerId, name, description, group);
add(layout);
open();
}
private void addCreateClickListener(final Button register, final HawkbitMgmtClient hawkbitClient) {
register.addClickListener(e -> {
final MgmtTargetRequestBody request = new MgmtTargetRequestBody()
.setControllerId(controllerId.getValue())
.setName(name.getValue())
.setDescription(description.getValue())
.setGroup(group.getValue());
if (!ObjectUtils.isEmpty(type.getValue())) {
request.setTargetType(type.getValue().getId());
}
hawkbitClient.getTargetRestApi().createTargets(
List.of(request))
.getBody()
.stream()
.findFirst()
.orElseThrow()
.getControllerId();
close();
});
}
}
private static class AssignDialog extends Utils.BaseDialog<Void> {
private final Select<MgmtDistributionSet> distributionSet;
private final Select<MgmtActionType> actionType;
private final DateTimePicker forceTime = new DateTimePicker("Force Time");
private final Button assign = new Button("Assign");
private AssignDialog(final HawkbitMgmtClient hawkbitClient, Set<TargetWithDs> selectedTargets) {
super("Assign Distribution Set");
distributionSet = new Select<>(
"Distribution Set",
this::readyToAssign,
Optional.ofNullable(
hawkbitClient.getDistributionSetRestApi()
.getDistributionSets(null, 0, 500, Constants.CREATED_AT_DESC)
.getBody())
.map(body -> body.getContent().toArray(new MgmtDistributionSet[0]))
.orElseGet(() -> new MgmtDistributionSet[0])
);
distributionSet.setRequiredIndicatorVisible(true);
distributionSet.setItemLabelGenerator(distributionSetO -> distributionSetO.getName() + ":" + distributionSetO.getVersion());
distributionSet.setWidthFull();
actionType = Utils.actionTypeControls(forceTime);
assign.setEnabled(false);
assign.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
addAssignClickListener(hawkbitClient, selectedTargets);
final Button cancel = Utils.tooltip(new Button(CANCEL), CANCEL_ESC);
cancel.addClickListener(e -> close());
cancel.addClickShortcut(Key.ESCAPE);
getFooter().add(cancel);
getFooter().add(assign);
final VerticalLayout layout = new VerticalLayout();
layout.setSizeFull();
layout.setSpacing(false);
layout.add(distributionSet, actionType);
add(layout);
open();
}
private void readyToAssign(final Object v) {
final boolean assignEnabled = !distributionSet.isEmpty();
if (assign.isEnabled() != assignEnabled) {
assign.setEnabled(assignEnabled);
}
}
private void addAssignClickListener(final HawkbitMgmtClient hawkbitClient, final Set<TargetWithDs> selectedTargets) {
assign.addClickListener(e -> {
close();
final List<MgmtTargetAssignmentRequestBody> requests = new LinkedList<>();
for (final MgmtTarget target : selectedTargets) {
final MgmtTargetAssignmentRequestBody request = new MgmtTargetAssignmentRequestBody(target.getControllerId());
request.setType(actionType.getValue());
if (actionType.getValue() == MgmtActionType.TIMEFORCED) {
request.setForcetime(
forceTime.isEmpty() ? System.currentTimeMillis() : forceTime.getValue().toEpochSecond(ZoneOffset.UTC) * 1000);
}
requests.add(request);
}
hawkbitClient.getDistributionSetRestApi().createAssignedTarget(distributionSet.getValue().getId(), requests, null).getBody();
});
}
}
private static class CreateTagDialog extends Utils.BaseDialog<Void> {
private final TextField name;
private final TextArea description = new TextArea(Constants.DESCRIPTION);
private CreateTagDialog(final HawkbitMgmtClient hawkbitClient, Runnable onSuccess) {
super("Create Tag");
final FormLayout formLayout = new FormLayout();
formLayout.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1));
final Button create = Utils.tooltip(new Button("Create"), "Create (Enter)");
final Button cancel = Utils.tooltip(new Button(CANCEL), CANCEL_ESC);
final Input colorInput = new Input();
colorInput.setType("color");
name = Utils.textField("Tag Name", e -> create.setEnabled(!e.getHasValue().isEmpty()));
formLayout.add(name);
formLayout.add(description);
formLayout.addFormItem(colorInput, "Color Selection");
add(formLayout);
create.setEnabled(false);
create.addClickShortcut(Key.ENTER);
create.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
create.addClickListener(e -> {
hawkbitClient.getTargetTagRestApi().createTargetTags(List.of(
new MgmtTagRequestBodyPut()
.setName(name.getValue())
.setDescription(description.getValue())
.setColour(colorInput.getValue())));
onSuccess.run();
close();
});
cancel.addClickListener(e -> close());
cancel.addClickShortcut(Key.ESCAPE);
getFooter().add(cancel);
getFooter().add(create);
open();
}
}
private static class TargetStatusCell extends HorizontalLayout {
private TargetStatusCell(final MgmtTarget target) {
final MgmtPollStatus pollStatus = target.getPollStatus();
add(pollStatusIconMapper(pollStatus));
setWidth(25, Unit.PIXELS);
}
private Icon pollStatusIconMapper(MgmtPollStatus pollStatus) {
final Icon pollIcon;
if (pollStatus == null) {
pollIcon = Utils.tooltip(VaadinIcon.QUESTION_CIRCLE.create(), "No Poll Status");
} else if (pollStatus.isOverdue()) {
pollIcon = Utils.tooltip(VaadinIcon.EXCLAMATION_CIRCLE.create(), "Overdue " + Utils.durationFromMillis(pollStatus
.getLastRequestAt()));
} else {
pollIcon = Utils.tooltip(VaadinIcon.CLOCK.create(), "In Time " + Utils.durationFromMillis(pollStatus.getLastRequestAt()));
}
pollIcon.addClassNames(LumoUtility.IconSize.SMALL);
return pollIcon;
}
}
private static class TargetUpdateStatusCell extends HorizontalLayout {
private TargetUpdateStatusCell(final MgmtTarget target) {
final String targetUpdateStatus = Optional.ofNullable(target.getUpdateStatus()).orElse("unknown");
add(targetUpdateStatusMapper(targetUpdateStatus));
setWidth(25, Unit.PIXELS);
}
private Icon targetUpdateStatusMapper(final String targetUpdateStatus) {
final VaadinIcon icon = switch (targetUpdateStatus) {
case "error" -> VaadinIcon.EXCLAMATION_CIRCLE;
case "in_sync" -> VaadinIcon.CHECK_CIRCLE;
case "pending" -> VaadinIcon.ADJUST;
case "registered" -> VaadinIcon.DOT_CIRCLE;
default -> VaadinIcon.QUESTION_CIRCLE;
};
final String color = switch (targetUpdateStatus) {
case "error" -> RED;
case "in_sync" -> GREEN;
case "pending" -> ORANGE;
case "registered" -> "lightblue";
default -> "blue";
};
final Icon statusIcon = Utils.tooltip(icon.create(), targetUpdateStatus.replace("_", " "));
statusIcon.setColor(color);
statusIcon.addClassNames(LumoUtility.IconSize.SMALL);
return statusIcon;
}
}
// todo change /targets api to reduce api calls ?
@EqualsAndHashCode(callSuper = true)
public static class TargetWithDs extends MgmtTarget {
TargetWithDs() {
super();
}
Optional<MgmtDistributionSet> ds;
static ObjectMapper objectMapper = new ObjectMapper();
public static TargetWithDs from(final HawkbitMgmtClient hawkbitClient, MgmtTarget target) {
TargetWithDs targetWithDs = objectMapper.convertValue(target, TargetWithDs.class);
targetWithDs.ds = Optional.ofNullable(hawkbitClient.getTargetRestApi().getInstalledDistributionSet(targetWithDs
.getControllerId())
.getBody());
return targetWithDs;
}
public String getDsVersion() {
return ds.map(MgmtDistributionSet::getVersion).orElse("");
}
public String getDsName() {
return ds.map(MgmtDistributionSet::getName).orElse("");
}
}
}

View File

@@ -0,0 +1,187 @@
/**
* Copyright (c) 2023 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.ui.view.util;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.HasValue;
import com.vaadin.flow.component.Key;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.theme.lumo.LumoUtility;
import org.springframework.util.ObjectUtils;
public class Filter extends Div {
private transient Rsql rsql;
private final transient Rsql secondaryRsql;
private final transient Rsql primaryRsql;
private final transient Div filtersDiv;
public Filter(final Consumer<String> changeListener, final Rsql primaryRsql, final Rsql secondaryOptionalRsql) {
rsql = primaryRsql;
this.primaryRsql = primaryRsql;
secondaryRsql = secondaryOptionalRsql;
final HorizontalLayout layout = new HorizontalLayout();
setWidthFull();
addClassNames(LumoUtility.Padding.Horizontal.NONE, LumoUtility.Padding.Vertical.SMALL,
LumoUtility.BoxSizing.BORDER);
filtersDiv = new Div();
filtersDiv.setWidthFull();
filtersDiv.add(primaryRsql.components());
filtersDiv.addClassName(LumoUtility.Gap.SMALL);
final Button searchBtn = Utils.tooltip(new Button(VaadinIcon.SEARCH.create()), "Search (Enter)");
searchBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
searchBtn.addClickListener(e -> changeListener.accept(rsql.filter()));
searchBtn.addClickShortcut(Key.ENTER);
final Button resetBtn = Utils.tooltip(new Button(VaadinIcon.REFRESH.create()), "Reset");
resetBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
resetBtn.addClickListener(e -> {
clear(layout.getChildren());
changeListener.accept(primaryRsql.filter());
});
final HorizontalLayout actions = new HorizontalLayout(searchBtn, resetBtn);
actions.addClassNames(LumoUtility.Gap.SMALL);
layout.setPadding(true);
layout.setAlignItems(FlexComponent.Alignment.BASELINE);
layout.add(filtersDiv);
layout.add(actions);
if (secondaryOptionalRsql != null) {
final Button toggleBtn = Utils.tooltip(new Button(VaadinIcon.FLIP_V.create()), "Toggle Search");
toggleBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
toggleBtn.addClickListener(e -> {
toggle();
changeListener.accept(primaryRsql.filter());
});
layout.add(toggleBtn);
}
add(layout);
changeListener.accept(primaryRsql.filter());
}
private void toggle() {
// toggle
filtersDiv.removeAll();
synchronized (this) {
rsql = rsql == primaryRsql ? secondaryRsql : primaryRsql;
}
filtersDiv.add(rsql.components());
}
public void setFilter(String string, boolean allowToggle) {
var otherFilter = rsql == primaryRsql ? secondaryRsql : primaryRsql;
Stream<Filter.Rsql> rsqlFIlter;
// logic to find the filter to use
if (allowToggle) {
rsqlFIlter = Stream.of(this.rsql);
} else {
rsqlFIlter = Stream.of(this.rsql, otherFilter);
}
rsqlFIlter.filter(RsqlRw.class::isInstance).findFirst().map(RsqlRw.class::cast).ifPresent(f -> {
if (f == otherFilter) {
toggle();
}
f.setFilter(string);
});
}
public static String filter(final Map<Object, Object> keyToValues) {
final Map<Object, Object> normalized = new HashMap<>(keyToValues)
.entrySet()
.stream()
.filter(e -> !ObjectUtils.isEmpty(e.getValue()))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
if (normalized.isEmpty()) {
return null;
} else {
final StringBuilder sb = new StringBuilder();
normalized.forEach((k, v) -> {
if (k instanceof Collection<?> keyList) {
sb.append('(').append(
keyList.stream().map(subKey -> filter((String) subKey, v))
.collect(Collectors.joining(" or "))).append(")");
} else if (k instanceof String key) {
if (v instanceof Collection<?>) {
sb.append('(').append(filter(key, v)).append(')');
} else {
sb.append(filter(key, v));
}
}
sb.append(';');
});
return sb.substring(0, sb.length() - 1);
}
}
private static String filter(final String key, final Object value) {
if (value == null || (value instanceof Collection<?> coll && coll.isEmpty())) {
return null;
}
if (value instanceof Collection<?> coll) {
final StringBuilder sb = new StringBuilder();
coll.stream().forEach(next -> sb.append(key).append("==").append(next).append(','));
return sb.substring(0, sb.length() - 1);
} else if (value instanceof Optional<?> opt) {
if (opt.isEmpty()) {
return null;
} else {
return key + "==" + opt.get();
}
} else {
return key + "==" + value;
}
}
private static void clear(final Stream<Component> components) {
if (components == null) {
return;
}
components.forEach(component -> {
if (component instanceof HasValue<?, ?> hasValue) {
hasValue.clear();
}
clear(component.getChildren());
});
}
public interface Rsql {
List<Component> components();
String filter();
}
public interface RsqlRw {
void setFilter(String filter);
}
}

View File

@@ -0,0 +1,43 @@
/**
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.ui.view.util;
import com.vaadin.flow.component.card.Card;
import com.vaadin.flow.component.card.CardVariant;
import com.vaadin.flow.component.html.Anchor;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.Span;
public class LinkedTextArea extends Div {
String queryPrefix;
Card card;
public LinkedTextArea(String title, String queryPrefix) {
super();
card = new Card();
card.setTitle(title);
this.queryPrefix = queryPrefix;
}
public void setValueWithLink(String value, String query) {
var span = new Span(value);
span.setWhiteSpace(WhiteSpace.PRE_WRAP);
card.add(span);
card.addThemeVariants(CardVariant.LUMO_ELEVATED);
if (query != null) {
var a = new Anchor(queryPrefix + query, card);
a.addClassName("nocolor");
add(a);
} else {
add(card);
}
}
}

View File

@@ -0,0 +1,102 @@
/**
* Copyright (c) 2023 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.ui.view.util;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.stream.Stream;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.grid.GridVariant;
import com.vaadin.flow.data.provider.Query;
import com.vaadin.flow.theme.lumo.LumoUtility;
// id type shall have proper equals and hashCode - i.e. eligible hash set element
@SuppressWarnings("java:S119") // better readability
public class SelectionGrid<T, ID> extends Grid<T> {
private volatile String rsqlFilter;
public SelectionGrid(
final EntityRepresentation<T, ID> entityRepresentation) {
this(entityRepresentation, null);
}
public SelectionGrid(
final EntityRepresentation<T, ID> entityRepresentation,
final BiFunction<Query<T, Void>, String, Stream<T>> queryFn) {
super(entityRepresentation.beanType, false);
addThemeVariants(GridVariant.LUMO_NO_BORDER);
addClassNames(LumoUtility.Border.TOP, LumoUtility.BorderColor.CONTRAST_10);
setSelectionMode(Grid.SelectionMode.MULTI);
entityRepresentation.addColumns(this);
if (queryFn != null) {
setItems(query -> {
final Stream<T> fetch = queryFn.apply(query, rsqlFilter);
final Set<T> selected = getSelectedItems();
if (selected == null || selected.isEmpty()) {
final List<T> fetchList = fetch.toList();
if (fetchList.size() == 1) {
this.setDetailsVisible(fetchList.get(0), true);
}
return fetchList.stream();
} else {
final Set<ID> selectedIds = new HashSet<>();
selected.forEach(next -> selectedIds.add(entityRepresentation.idFn.apply(next)));
// if matching keeps old entries instead of new the new ones in order to
// select them in case refresh is made with keepSelection
// this however means that if they are changed the old state will be shown!!!
return Stream.concat(selected.stream(),
fetch.filter(next -> !selectedIds.contains(entityRepresentation.idFn.apply(next))));
}
});
} // else externally managed
}
public void setRsqlFilter(final String rsqlFilter, boolean refreshGrid) {
if (!Objects.equals(this.rsqlFilter, rsqlFilter)) {
this.rsqlFilter = rsqlFilter;
if (refreshGrid)
refreshGrid(true);
}
}
public void refreshGrid(final boolean keepSelection) {
if (keepSelection) {
final Set<T> selected = getSelectedItems();
getDataProvider().refreshAll();
if (selected != null && !selected.isEmpty()) {
selected.forEach(this::select);
}
} else {
deselectAll();
getDataProvider().refreshAll();
}
}
public abstract static class EntityRepresentation<T, ID> {
private final Class<T> beanType;
private final Function<T, ID> idFn;
protected EntityRepresentation(final Class<T> beanType, final Function<T, ID> idFn) {
this.beanType = beanType;
this.idFn = idFn;
}
protected abstract void addColumns(final Grid<T> grid);
}
}

View File

@@ -0,0 +1,173 @@
/**
* Copyright (c) 2023 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.ui.view.util;
import java.util.concurrent.CompletionStage;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.stream.Stream;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.splitlayout.SplitLayout;
import com.vaadin.flow.component.splitlayout.SplitLayoutVariant;
import com.vaadin.flow.data.provider.Query;
import com.vaadin.flow.data.renderer.ComponentRenderer;
import com.vaadin.flow.function.SerializableFunction;
import com.vaadin.flow.router.BeforeEnterEvent;
import com.vaadin.flow.router.BeforeEnterObserver;
import com.vaadin.flow.router.NavigationTrigger;
import com.vaadin.flow.theme.lumo.LumoUtility;
import org.eclipse.hawkbit.ui.view.Constants;
@SuppressWarnings("java:S119") // better readability
public class TableView<T, ID> extends Div implements Constants, BeforeEnterObserver {
private static final String COLOR = "color";
private static final String VAR_LUMO_SECONDARY_TEXT_COLOR = "var(--lumo-secondary-text-color)";
private static final String VAR_LUMO_PRIMARY_COLOR = "var(--lumo-primary-color)";
private static final int DEFAULT_OPEN_POSITION_SIZE = 50;
private static final String QUERY_PARAM_FILTER = "q";
protected SelectionGrid<T, ID> selectionGrid;
private final Filter filter;
private final VerticalLayout gridLayout;
protected final HorizontalLayout controlsLayout;
private final SplitLayout splitLayout;
private final Div detailsPanel;
private Button currentSelectionButton;
public TableView(
final Filter.Rsql rsql,
final SelectionGrid.EntityRepresentation<T, ID> entityRepresentation,
final BiFunction<Query<T, Void>, String, Stream<T>> queryFn,
final Function<SelectionGrid<T, ID>, CompletionStage<Void>> addHandler,
final Function<SelectionGrid<T, ID>, CompletionStage<Void>> removeHandler) {
this(rsql, null, entityRepresentation, queryFn, addHandler, removeHandler, null);
}
public TableView(
final Filter.Rsql rsql, final Filter.Rsql alternativeRsql,
final SelectionGrid.EntityRepresentation<T, ID> entityRepresentation,
final BiFunction<Query<T, Void>, String, Stream<T>> queryFn,
final Function<SelectionGrid<T, ID>, CompletionStage<Void>> addHandler,
final Function<SelectionGrid<T, ID>, CompletionStage<Void>> removeHandler,
final Function<T, Component> detailsButtonHandler) {
selectionGrid = new SelectionGrid<>(entityRepresentation, queryFn);
selectionGrid.setSizeFull();
setSizeFull();
splitLayout = new SplitLayout();
splitLayout.setSizeFull();
splitLayout.setOrientation(SplitLayout.Orientation.HORIZONTAL);
splitLayout.addThemeVariants(SplitLayoutVariant.LUMO_SMALL);
splitLayout.setSplitterPosition(100);
splitLayout.addToPrimary(selectionGrid);
detailsPanel = new Div();
if (detailsButtonHandler != null) {
ComponentRenderer<Button, T> renderer = new ComponentRenderer<>(renderDetailsButton(detailsButtonHandler));
selectionGrid.addColumn(renderer).setHeader("Details").setAutoWidth(true).setFlexGrow(0);
}
filter = new Filter(
rsqlFilter -> {
closeDetailsPanel();
if (rsqlFilter != null) {
var queryParameters = UI.getCurrent().getActiveViewLocation()
.getQueryParameters()
.merging(QUERY_PARAM_FILTER, rsqlFilter);
UI.getCurrent().navigate(this.getClass(), queryParameters);
}
selectionGrid.setRsqlFilter(rsqlFilter, true);
}, rsql, alternativeRsql
);
gridLayout = new VerticalLayout(filter, splitLayout);
gridLayout.setSizeFull();
gridLayout.setPadding(false);
gridLayout.setSpacing(false);
if (addHandler != null || removeHandler != null) {
controlsLayout = Utils.addRemoveControls(addHandler, removeHandler, selectionGrid, false);
} else {
controlsLayout = new HorizontalLayout();
controlsLayout.setWidthFull();
controlsLayout.addClassNames(
LumoUtility.Padding.Horizontal.XLARGE, LumoUtility.Padding.Vertical.SMALL, LumoUtility.BoxSizing.BORDER);
controlsLayout.setAlignItems(FlexComponent.Alignment.BASELINE);
}
gridLayout.add(controlsLayout);
add(gridLayout);
}
private void closeDetailsPanel() {
detailsPanel.removeAll();
if (splitLayout.getSecondaryComponent() != null) {
splitLayout.remove(detailsPanel);
}
splitLayout.setSplitterPosition(100);
currentSelectionButton = null;
}
private SerializableFunction<T, Button> renderDetailsButton(final Function<T, Component> selectionHandler) {
return selectedItem -> {
final Button button = new Button(VaadinIcon.EYE.create());
button.getStyle().set(COLOR, VAR_LUMO_SECONDARY_TEXT_COLOR);
button.addClickListener(event -> {
final Icon eyeIcon = VaadinIcon.EYE.create();
final Icon closeIcon = VaadinIcon.CLOSE_SMALL.create();
if (button == currentSelectionButton) {
button.setIcon(eyeIcon);
button.getStyle().set(COLOR, VAR_LUMO_SECONDARY_TEXT_COLOR);
closeDetailsPanel();
} else {
button.setIcon(closeIcon);
button.getStyle().set(COLOR, VAR_LUMO_PRIMARY_COLOR);
if (currentSelectionButton == null) {
splitLayout.addToSecondary(detailsPanel);
} else {
currentSelectionButton.setIcon(eyeIcon);
currentSelectionButton.getStyle().set(COLOR, VAR_LUMO_SECONDARY_TEXT_COLOR);
}
detailsPanel.removeAll();
splitLayout.setSplitterPosition(DEFAULT_OPEN_POSITION_SIZE);
detailsPanel.add(selectionHandler.apply(selectedItem));
currentSelectionButton = button;
}
});
button.setTooltipText("Show details");
button.addThemeVariants(ButtonVariant.LUMO_SMALL);
return button;
};
}
@Override
public void beforeEnter(BeforeEnterEvent event) {
var params = event.getLocation().getQueryParameters();
params.getSingleParameter(QUERY_PARAM_FILTER)
.ifPresent(f -> {
var newPage = event.getTrigger() == NavigationTrigger.UI_NAVIGATE;
selectionGrid.setRsqlFilter(f, newPage);
filter.setFilter(f, newPage);
});
}
}

View File

@@ -0,0 +1,313 @@
/**
* Copyright (c) 2023 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.ui.view.util;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.ToLongFunction;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.IconFactory;
import com.vaadin.flow.data.provider.QuerySortOrder;
import com.vaadin.flow.data.provider.SortDirection;
import com.vaadin.flow.data.renderer.LocalDateTimeRenderer;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtActionType;
import org.eclipse.hawkbit.ui.view.Constants;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.HasValue;
import com.vaadin.flow.component.Text;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.Unit;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
import com.vaadin.flow.component.datetimepicker.DateTimePicker;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.select.Select;
import com.vaadin.flow.component.shared.Tooltip;
import com.vaadin.flow.component.textfield.NumberField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.renderer.ComponentRenderer;
import com.vaadin.flow.data.value.ValueChangeMode;
import com.vaadin.flow.theme.lumo.LumoUtility;
@Slf4j
public class Utils {
private Utils() {
// prevent initialization
}
public static TextField textField(final String label) {
return textField(label, null);
}
public static TextField textField(final String label, final Consumer<HasValue.ValueChangeEvent<String>> changeListener) {
final TextField textField = new TextField(label);
textField.setWidthFull();
if (changeListener != null) {
textField.setRequired(true);
textField.addValueChangeListener(changeListener::accept);
textField.setValueChangeMode(ValueChangeMode.TIMEOUT);
textField.setValueChangeTimeout(200);
}
return textField;
}
public static NumberField numberField(final String label) {
return numberField(label, null);
}
public static NumberField numberField(final String label, final Consumer<HasValue.ValueChangeEvent<Double>> changeListener) {
final NumberField numberField = new NumberField(label);
numberField.setWidthFull();
if (changeListener != null) {
numberField.setRequired(true);
numberField.addValueChangeListener(changeListener::accept);
numberField.setValueChangeMode(ValueChangeMode.TIMEOUT);
numberField.setValueChangeTimeout(200);
}
return numberField;
}
@SuppressWarnings("java:S119") // better readability
public static <T, ID> HorizontalLayout addRemoveControls(
final Function<SelectionGrid<T, ID>, CompletionStage<Void>> addHandler,
final Function<SelectionGrid<T, ID>, CompletionStage<Void>> removeHandler,
final SelectionGrid<T, ID> selectionGrid, final boolean noPadding) {
if (addHandler == null && removeHandler == null) {
throw new IllegalArgumentException("At least one of add or remove handlers must not be null!");
}
final HorizontalLayout layout = new HorizontalLayout();
layout.setWidthFull();
if (!noPadding) {
layout.addClassNames(LumoUtility.Padding.Horizontal.XLARGE, LumoUtility.Padding.Vertical.SMALL, LumoUtility.BoxSizing.BORDER);
}
layout.setAlignItems(FlexComponent.Alignment.BASELINE);
layout.setJustifyContentMode(FlexComponent.JustifyContentMode.END);
if (addHandler != null) {
final Button addBtn = tooltip(new Button(VaadinIcon.PLUS.create()), "Add");
addBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
addBtn.addClickListener(e -> addHandler
.apply(selectionGrid)
.thenAccept(v -> selectionGrid.refreshGrid(true)));
layout.add(addBtn);
}
if (removeHandler != null) {
final ConfirmDialog dialog = promptForDeleteConfirmation(removeHandler, selectionGrid);
final Button removeBtn = tooltip(new Button(VaadinIcon.MINUS.create()), "Remove");
removeBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_CONTRAST);
removeBtn.addClickListener(e -> dialog.open());
layout.add(removeBtn);
}
return layout;
}
private static <T, ID> ConfirmDialog promptForDeleteConfirmation(Function<SelectionGrid<T, ID>, CompletionStage<Void>> removeHandler,
SelectionGrid<T, ID> selectionGrid) {
final ConfirmDialog dialog = new ConfirmDialog();
dialog.setHeader("Confirm Deletion");
dialog.setText("Are you sure you want to delete the selected items? This action cannot be undone.");
dialog.setCancelable(true);
dialog.addCancelListener(event -> dialog.close());
dialog.setConfirmButtonTheme(ButtonVariant.LUMO_ERROR.getVariantName());
dialog.setConfirmText("Delete");
dialog.addConfirmListener(event -> {
removeHandler
.apply(selectionGrid)
.thenAccept(v -> selectionGrid.refreshGrid(false));
dialog.close();
});
return dialog;
}
public static <T> void remove(final Collection<T> remove, final Set<T> from, final Function<T, ?> idFn) {
remove.forEach(toRemove -> {
final Object id = idFn.apply(toRemove);
for (final Iterator<T> i = from.iterator(); i.hasNext();) {
if (idFn.apply(i.next()).equals(id)) {
i.remove();
}
}
});
}
public static void errorNotification(final Throwable t) {
if (UI.getCurrent() == null) {
return;
}
final Notification notification = new Notification();
notification.addThemeVariants(NotificationVariant.LUMO_ERROR);
final Div error = new Div(new Text("Error: " + t.getMessage()));
final Button closeButton = tooltip(new Button(VaadinIcon.CLOSE.create()), "Close");
closeButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY_INLINE);
closeButton.addClickListener(event -> notification.close());
final HorizontalLayout layout = new HorizontalLayout(error, closeButton);
layout.setAlignItems(FlexComponent.Alignment.CENTER);
notification.add(layout);
notification.open();
}
public static <T extends Component> T tooltip(final T component, final String text) {
Tooltip.forComponent(component)
.withText(text)
.withPosition(Tooltip.TooltipPosition.TOP_START);
return component;
}
public static Icon iconColored(final IconFactory component, final String text, final String color) {
var icon = tooltip(component.create(), text);
icon.setColor(color);
return icon;
}
public static Select<MgmtActionType> actionTypeControls(DateTimePicker forceTime) {
Select<MgmtActionType> actionType = new Select<>();
actionType.setLabel(Constants.ACTION_TYPE);
actionType.setItems(MgmtActionType.values());
actionType.setValue(MgmtActionType.FORCED);
final ComponentRenderer<Component, MgmtActionType> actionTypeRenderer = new ComponentRenderer<>(actionTypeO -> switch (actionTypeO) {
case SOFT -> new Text(Constants.SOFT);
case FORCED -> new Text(Constants.FORCED);
case DOWNLOAD_ONLY -> new Text(Constants.DOWNLOAD_ONLY);
case TIMEFORCED -> forceTime;
});
actionType.addValueChangeListener(e -> actionType.setRenderer(actionTypeRenderer));
actionType.setItemLabelGenerator(startTypeO -> switch (startTypeO) {
case SOFT -> Constants.SOFT;
case FORCED -> Constants.FORCED;
case DOWNLOAD_ONLY -> Constants.DOWNLOAD_ONLY;
case TIMEFORCED -> "Time Forced at " + (forceTime.isEmpty() ? "" : " " + forceTime.getValue());
});
actionType.setWidthFull();
return actionType;
}
public static class BaseDialog<T> extends Dialog {
protected final transient CompletableFuture<T> result = new CompletableFuture<>();
protected BaseDialog(final String headerTitle) {
setHeaderTitle(headerTitle);
setMinWidth(640, Unit.PIXELS);
setModal(true);
setDraggable(true);
setResizable(true);
setCloseOnEsc(true);
setCloseOnOutsideClick(true);
final Button closeBtn = tooltip(new Button(VaadinIcon.CLOSE.create()), "Close");
closeBtn.addClickListener(e -> {
result.complete(null);
super.close();
});
getHeader().add(closeBtn);
}
public CompletionStage<T> result() {
return result;
}
@Override
public void close() {
if (!result.isDone()) {
result.complete(null);
}
super.close();
}
}
private static ZoneId getZoneId() {
CompletableFuture<ZoneId> zoneId = new CompletableFuture<>();
UI.getCurrent().getPage().retrieveExtendedClientDetails(details -> zoneId.complete(ZoneId.of(details.getTimeZoneId())));
try {
return zoneId.get(1, TimeUnit.SECONDS);
} catch (final InterruptedException e) {
Thread.currentThread().interrupt();
} catch (TimeoutException | ExecutionException ignored) {
log.warn("failed to get zone");
}
return ZoneId.systemDefault();
}
public static <G> LocalDateTimeRenderer<G> localDateTimeRenderer(ToLongFunction<G> f) {
return new LocalDateTimeRenderer<>((e) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(f.applyAsLong(e)), getZoneId()),
() -> DateTimeFormatter.ofLocalizedDateTime(
FormatStyle.SHORT,
FormatStyle.MEDIUM).withLocale(UI.getCurrent().getLocale()));
}
public static String localDateTimeFromTs(long timestamp) {
return LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), getZoneId()).format(DateTimeFormatter.ofLocalizedDateTime(
FormatStyle.SHORT,
FormatStyle.MEDIUM).withLocale(UI.getCurrent().getLocale()));
}
public static String getSortParam(List<QuerySortOrder> querySortOrders) {
return getSortParam(querySortOrders, null);
}
public static String getSortParam(List<QuerySortOrder> querySortOrders, String defaultSort) {
if (!querySortOrders.isEmpty()) {
QuerySortOrder firstSort = querySortOrders.get(0);
String order = firstSort.getDirection() == SortDirection.ASCENDING ? "asc" : "desc";
return String.format("%s:%s", firstSort.getSorted(), order);
}
return defaultSort;
}
public static String durationFromMillis(Long time) {
var duration = Duration.between(Instant.ofEpochMilli(time), Instant.now());
var day = duration.toDaysPart();
if (day > 2) {
return day + "d";
}
return duration.withNanos(0).toString()
.substring(2)
.replaceFirst("(^\\d+[HMS]\\d*M*)", "$1")
.toLowerCase();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,32 @@
#
# Copyright (c) 2023 Contributors to the Eclipse Foundation
#
# This program and the accompanying materials are made
# available under the terms of the Eclipse Public License 2.0
# which is available at https://www.eclipse.org/legal/epl-2.0/
#
# SPDX-License-Identifier: EPL-2.0
#
# Spring configuration
server.port=${PORT:8088}
# Logging configuration
logging.level.org.springframework.boot.actuate.audit.listener.AuditListener=WARN
# logging pattern
logging.pattern.console=%clr(%d{${logging.pattern.dateformat:yyyy-MM-dd'T'HH:mm:ss.SSSXXX}}){faint} %clr(${logging.pattern.level:%5p}) %clr(${PID:}){magenta} %clr(---){faint} %clr([${spring.application.name}] [%X{tenant}:%X{user}] [%15.15t]){faint} %clr(${logging.pattern.correlation:}){faint}%clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${logging.exception-conversion-word:%wEx}
### Vaadin start ###
# build with mvn vaadin:build-frontend to enable / disable
vaadin.productionMode=true
vaadin.frontend.hotdeploy=false
logging.level.org.atmosphere=warn
spring.mustache.check-template-location=false
# Launch the default browser when starting the application in development mode
vaadin.launch-browser=true
# To improve the performance during development.
# For more information https://vaadin.com/docs/flow/spring/tutorial-spring-configuration.html#special-configuration-parameters
vaadin.allowed-packages=com.vaadin,org.vaadin,dev.hilla,org.eclipse.hawkbit
spring.application.name=hawkBit-UI
server.servlet.session.persistent=false
### Vaadin end ###

View File

@@ -0,0 +1,18 @@
______ _ _ _ _ ____ _ _
| ____| | (_) | | | | | _ \(_) |
| |__ ___| |_ _ __ ___ ___ | |__ __ ___ _| | _| |_) |_| |_
| __| / __| | | '_ \/ __|/ _ \ | '_ \ / _` \ \ /\ / / |/ / _ <| | __|
| |___| (__| | | |_) \__ \ __/ | | | | (_| |\ V V /| <| |_) | | |_
|______\___|_|_| .__/|___/\___|_|_| |_|\__,_|_\_/\_/ |_|\_\____/|_|\__|
/ ____(_)| | | | | | | |_ _|
| (___ _ |_|_ ___ _ __ | | ___ | | | | | |
\___ \| | '_ ` _ \| '_ \| |/ _ \ | | | | | |
____) | | | | | | | |_) | | __/ | |__| |_| |_
|_____/|_|_| |_| |_| .__/|_|\___| \____/|_____|
| |
|_|
Eclipse hawkBit UI ${application.formatted-version}
using Spring Boot ${spring-boot.formatted-version}
Go to https://www.eclipse.org/hawkbit for more information.