diff --git a/app/save-and-restore/app/doc/index.rst b/app/save-and-restore/app/doc/index.rst index cc08a6da49..b7c4816767 100644 --- a/app/save-and-restore/app/doc/index.rst +++ b/app/save-and-restore/app/doc/index.rst @@ -58,9 +58,13 @@ A word of caution ----------------- Save-and-restore data is persisted in a central service and is therefore accessible by multiple -clients. Users should keep in mind that changes (e.g. new or deleted nodes) are not pushed to all clients. -Caution is therefore advocated when working on the nodes in the tree, in particular when changing the structure by -copying, deleting or moving nodes. +clients. Users should keep in mind that changes (e.g. new or deleted nodes) are pushed by the service to +all connected clients. If any other user is working in the save-and-restore app, saved changes may update +the current view. For instance, if a folder node is expanded and another user adds an object (folder +or configuration) to that folder, the new object will automatically be added to the expanded folder. + +In other words, changes in the current view are triggered not only by the current user, but may be triggered as a result of +changes done by others. Tree View Context Menu ---------------------- diff --git a/app/save-and-restore/app/pom.xml b/app/save-and-restore/app/pom.xml index 18d95dcdf7..506cbed138 100644 --- a/app/save-and-restore/app/pom.xml +++ b/app/save-and-restore/app/pom.xml @@ -25,6 +25,16 @@ core-logbook 5.0.1-SNAPSHOT + + org.phoebus + core-ui + 5.0.1-SNAPSHOT + + + org.phoebus + core-websocket + 5.0.1-SNAPSHOT + org.phoebus save-and-restore-model diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/FilterViewInstance.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/FilterViewInstance.java index 59fd97ad9d..8dcaae5c83 100644 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/FilterViewInstance.java +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/FilterViewInstance.java @@ -54,6 +54,8 @@ public FilterViewInstance(AppDescriptor appDescriptor) { return CompletableFuture.completedFuture(true); }); + dockItem.setOnCloseRequest(e -> searchResultTableViewController.handleTabClosed()); + DockPane.getActiveDockPane().addTab(dockItem); } diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/Messages.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/Messages.java index 660debeb9a..e5ec5bf940 100644 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/Messages.java +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/Messages.java @@ -178,6 +178,8 @@ public class Messages { public static String toolTipConfigurationExists; public static String toolTipConfigurationExistsOption; public static String toolTipMultiplierSpinner; + public static String unnamedConfiguration; + public static String unnamedCompositeSnapshot; public static String unnamedSnapshot; public static String updateCompositeSnapshotFailed; @@ -185,6 +187,9 @@ public class Messages { public static String updateNodeFailed; + public static String webSocketConnected; + public static String webSocketDisconnected; + static { NLS.initializeMessages(Messages.class); } diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/SaveAndRestoreInstance.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/SaveAndRestoreInstance.java index e55c44e555..e763e3e75b 100644 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/SaveAndRestoreInstance.java +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/SaveAndRestoreInstance.java @@ -55,7 +55,7 @@ public SaveAndRestoreInstance(AppDescriptor appDescriptor) { loader.setLocation(SaveAndRestoreApplication.class.getResource("ui/SaveAndRestoreUI.fxml")); dockItem = new DockItem(this, loader.load()); } catch (Exception e) { - Logger.getLogger(SaveAndRestoreApplication.class.getName()).log(Level.SEVERE, "Failed loading fxml", e); + Logger.getLogger(SaveAndRestoreInstance.class.getName()).log(Level.SEVERE, "Failed loading fxml", e); } saveAndRestoreController = loader.getController(); @@ -82,11 +82,11 @@ public void openResource(URI uri) { saveAndRestoreController.openResource(uri); } - public void secureStoreChanged(List validTokens){ + public void secureStoreChanged(List validTokens) { saveAndRestoreController.secureStoreChanged(validTokens); } - public void raise(){ + public void raise() { dockItem.select(); } } diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/client/SaveAndRestoreClientImpl.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/client/SaveAndRestoreClientImpl.java index 1df06ff849..786270532e 100644 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/client/SaveAndRestoreClientImpl.java +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/client/SaveAndRestoreClientImpl.java @@ -36,11 +36,9 @@ import java.net.CookieManager; import java.net.CookiePolicy; import java.net.URI; -import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Base64; import java.util.List; @@ -588,10 +586,10 @@ public UserData authenticate(String userName, String password) { String stringBuilder = Preferences.jmasarServiceUrl + "/login"; HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(stringBuilder)) - .header("Content-Type", CONTENT_TYPE_JSON) - .POST(HttpRequest.BodyPublishers.ofString(OBJECT_MAPPER.writeValueAsString(new LoginCredentials(userName, password)))) - .build(); + .uri(URI.create(stringBuilder)) + .header("Content-Type", CONTENT_TYPE_JSON) + .POST(HttpRequest.BodyPublishers.ofString(OBJECT_MAPPER.writeValueAsString(new LoginCredentials(userName, password)))) + .build(); HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); return OBJECT_MAPPER.readValue(response.body(), UserData.class); } catch (Exception e) { diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/FilterChangeListener.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/FilterChangeListener.java deleted file mode 100644 index 108f204e5b..0000000000 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/FilterChangeListener.java +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright (C) 2020 European Spallation Source ERIC. - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU General Public License - * as published by the Free Software Foundation; either version 2 - * of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. - * - */ - -package org.phoebus.applications.saveandrestore.ui; - -import org.phoebus.applications.saveandrestore.model.search.Filter; - -/** - * Interface to handle changes in save-n-restore {@link Filter}s. - */ -public interface FilterChangeListener { - - void filterAddedOrUpdated(Filter filter); - - void filterRemoved(Filter filter); - -} diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/NodeAddedListener.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/NodeAddedListener.java deleted file mode 100644 index 6f7305e2d1..0000000000 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/NodeAddedListener.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (C) 2020 European Spallation Source ERIC. - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU General Public License - * as published by the Free Software Foundation; either version 2 - * of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. - */ - -package org.phoebus.applications.saveandrestore.ui; - -import org.phoebus.applications.saveandrestore.model.Node; - -import java.util.List; - -public interface NodeAddedListener { - - /** - * To be called when a new node has been created (typically new snapshot node). - * - * @param parentNode The parent of the new node as defined in the back-end data model. - * @param newNodes The list of {@link Node}s added to the parent {@link Node}. - */ - void nodesAdded(Node parentNode, List newNodes); -} diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/NodeChangedListener.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/NodeChangedListener.java deleted file mode 100644 index 7fba282bec..0000000000 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/NodeChangedListener.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright (C) 2020 European Spallation Source ERIC. - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU General Public License - * as published by the Free Software Foundation; either version 2 - * of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. - */ -package org.phoebus.applications.saveandrestore.ui; - -import org.phoebus.applications.saveandrestore.model.Node; - -public interface NodeChangedListener { - - /** - * To be called when an existing node has been changed, e.g. renamed or a property has changed. - * - * @param node The updated node. - */ - void nodeChanged(Node node); -} diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/SaveAndRestoreBaseController.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/SaveAndRestoreBaseController.java index 428ee52127..fc30652bcf 100644 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/SaveAndRestoreBaseController.java +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/SaveAndRestoreBaseController.java @@ -20,6 +20,7 @@ package org.phoebus.applications.saveandrestore.ui; import javafx.beans.property.SimpleStringProperty; +import org.phoebus.applications.saveandrestore.model.websocket.SaveAndRestoreWebSocketMessage; import org.phoebus.security.store.SecureStore; import org.phoebus.security.tokens.AuthenticationScope; import org.phoebus.security.tokens.ScopedAuthenticationToken; @@ -32,8 +33,12 @@ public abstract class SaveAndRestoreBaseController { protected final SimpleStringProperty userIdentity = new SimpleStringProperty(); + protected final WebSocketClientService webSocketClientService; + protected final SaveAndRestoreService saveAndRestoreService; public SaveAndRestoreBaseController() { + this.webSocketClientService = WebSocketClientService.getInstance(); + this.saveAndRestoreService = SaveAndRestoreService.getInstance(); try { SecureStore secureStore = new SecureStore(); ScopedAuthenticationToken token = @@ -63,4 +68,16 @@ public void secureStoreChanged(List validTokens) { public SimpleStringProperty getUserIdentity() { return userIdentity; } + + /** + * Default no-op implementation of a handler for {@link SaveAndRestoreWebSocketMessage}s. + * @param webSocketMessage See {@link SaveAndRestoreWebSocketMessage} + */ + protected void handleWebSocketMessage(SaveAndRestoreWebSocketMessage webSocketMessage){ + } + + + protected boolean handleTabClosed(){ + return true; + } } diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/SaveAndRestoreController.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/SaveAndRestoreController.java index 952fe132e5..b9f9bda58d 100644 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/SaveAndRestoreController.java +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/SaveAndRestoreController.java @@ -76,6 +76,7 @@ import org.phoebus.applications.saveandrestore.model.search.SearchQueryUtil; import org.phoebus.applications.saveandrestore.model.search.SearchQueryUtil.Keys; import org.phoebus.applications.saveandrestore.model.search.SearchResult; +import org.phoebus.applications.saveandrestore.model.websocket.SaveAndRestoreWebSocketMessage; import org.phoebus.applications.saveandrestore.ui.configuration.ConfigurationTab; import org.phoebus.applications.saveandrestore.ui.contextmenu.CopyUniqueIdToClipboardMenuItem; import org.phoebus.applications.saveandrestore.ui.contextmenu.CreateSnapshotMenuItem; @@ -134,7 +135,7 @@ * Main controller for the save and restore UI. */ public class SaveAndRestoreController extends SaveAndRestoreBaseController - implements Initializable, NodeChangedListener, NodeAddedListener, FilterChangeListener { + implements Initializable, WebSocketMessageHandler { @FXML protected TreeView treeView; @@ -165,24 +166,14 @@ public class SaveAndRestoreController extends SaveAndRestoreBaseController @FXML private VBox treeViewPane; - protected SaveAndRestoreService saveAndRestoreService; - private final ObjectMapper objectMapper = new ObjectMapper(); - protected MultipleSelectionModel> browserSelectionModel; - private static final String TREE_STATE = "tree_state"; - private static final String FILTER_NAME = "filter_name"; - protected static final Logger LOG = Logger.getLogger(SaveAndRestoreController.class.getName()); - protected Comparator> treeNodeComparator; - protected SimpleBooleanProperty disabledUi = new SimpleBooleanProperty(false); - private final SimpleBooleanProperty filterEnabledProperty = new SimpleBooleanProperty(false); - private static final Logger logger = Logger.getLogger(SaveAndRestoreController.class.getName()); @SuppressWarnings("unused") @@ -194,11 +185,11 @@ public class SaveAndRestoreController extends SaveAndRestoreBaseController private VBox errorPane; private final ObservableList searchResultNodes = FXCollections.observableArrayList(); - private final ObservableList filtersList = FXCollections.observableArrayList(); private final CountDownLatch treeInitializationCountDownLatch = new CountDownLatch(1); private final ObservableList selectedItemsProperty = FXCollections.observableArrayList(); + private final SimpleBooleanProperty serviceConnected = new SimpleBooleanProperty(); private final ContextMenu contextMenu = new ContextMenu(); private final Menu tagWithComment = new Menu(Messages.contextMenuTags, new ImageView(ImageCache.getImage(SaveAndRestoreController.class, "/icons/save-and-restore/snapshot-add_tag.png"))); @@ -211,14 +202,10 @@ public class SaveAndRestoreController extends SaveAndRestoreBaseController List menuItems = Arrays.asList( new LoginMenuItem(this, selectedItemsProperty, () -> ApplicationService.createInstance("credentials_management")), - new NewFolderMenuItem(this, selectedItemsProperty, - () -> createNewFolder()), - new NewConfigurationMenuItem(this, selectedItemsProperty, - () -> createNewConfiguration()), - new CreateSnapshotMenuItem(this, selectedItemsProperty, - () -> openConfigurationForSnapshot()), - new NewCompositeSnapshotMenuItem(this, selectedItemsProperty, - () -> createNewCompositeSnapshot()), + new NewFolderMenuItem(this, selectedItemsProperty, this::createNewFolder), + new NewConfigurationMenuItem(this, selectedItemsProperty, this::createNewConfiguration), + new CreateSnapshotMenuItem(this, selectedItemsProperty, this::openConfigurationForSnapshot), + new NewCompositeSnapshotMenuItem(this, selectedItemsProperty, this::createNewCompositeSnapshot), new RestoreFromClientMenuItem(this, selectedItemsProperty, () -> { disabledUi.set(true); @@ -230,8 +217,8 @@ public class SaveAndRestoreController extends SaveAndRestoreBaseController RestoreUtil.restore(RestoreMode.SERVICE_RESTORE, saveAndRestoreService, selectedItemsProperty.get(0), () -> disabledUi.set(false)); }), new SeparatorMenuItem(), - new EditCompositeMenuItem(this, selectedItemsProperty, () -> editCompositeSnapshot()), - new RenameFolderMenuItem(this, selectedItemsProperty, () -> renameNode()), + new EditCompositeMenuItem(this, selectedItemsProperty, this::editCompositeSnapshot), + new RenameFolderMenuItem(this, selectedItemsProperty, this::renameNode), copyMenuItem, pasteMenuItem, deleteNodeMenuItem, @@ -240,11 +227,10 @@ public class SaveAndRestoreController extends SaveAndRestoreBaseController new TagGoldenMenuItem(this, selectedItemsProperty), tagWithComment, new SeparatorMenuItem(), - new CopyUniqueIdToClipboardMenuItem(this, selectedItemsProperty, - () -> copyUniqueNodeIdToClipboard()), + new CopyUniqueIdToClipboardMenuItem(this, selectedItemsProperty, this::copyUniqueNodeIdToClipboard), new SeparatorMenuItem(), - new ImportFromCSVMenuItem(this, selectedItemsProperty, () -> importFromCSV()), - new ExportToCSVMenuItem(this, selectedItemsProperty, () -> exportToCSV()) + new ImportFromCSVMenuItem(this, selectedItemsProperty, this::importFromCSV), + new ExportToCSVMenuItem(this, selectedItemsProperty, this::exportToCSV) ); @@ -254,7 +240,6 @@ public void initialize(URL url, ResourceBundle resourceBundle) { // Tree items are first compared on type, then on name (case-insensitive). treeNodeComparator = Comparator.comparing(TreeItem::getValue); - saveAndRestoreService = SaveAndRestoreService.getInstance(); treeView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); treeView.getStylesheets().add(getClass().getResource("/save-and-restore-style.css").toExternalForm()); @@ -282,11 +267,6 @@ public void initialize(URL url, ResourceBundle resourceBundle) { }); treeView.setShowRoot(true); - - saveAndRestoreService.addNodeChangeListener(this); - saveAndRestoreService.addNodeAddedListener(this); - saveAndRestoreService.addFilterChangeListener(this); - treeView.setCellFactory(p -> new BrowserTreeCell(this)); treeViewPane.disableProperty().bind(disabledUi); progressIndicator.visibleProperty().bind(disabledUi); @@ -359,9 +339,15 @@ public Filter fromString(String s) { contextMenu.getItems().addAll(menuItems); treeView.setContextMenu(contextMenu); - loadTreeData(); - } + splitPane.disableProperty().bind(serviceConnected.not()); + treeView.visibleProperty().bind(serviceConnected); + errorPane.visibleProperty().bind(serviceConnected.not()); + webSocketClientService.addWebSocketMessageHandler(this); + webSocketClientService.setConnectCallback(this::handleWebSocketConnected); + webSocketClientService.setDisconnectCallback(this::handleWebSocketDisconnected); + webSocketClientService.connect(); + } /** * Loads the data for the tree root as provided (persisted) by the current @@ -371,11 +357,7 @@ public void loadTreeData() { JobManager.schedule("Load save-and-restore tree data", monitor -> { Node rootNode = saveAndRestoreService.getRootNode(); - if (rootNode == null) { // Service off-line or not reachable - treeInitializationCountDownLatch.countDown(); - errorPane.visibleProperty().set(true); - return; - } + treeInitializationCountDownLatch.countDown(); TreeItem rootItem = createTreeItem(rootNode); List savedTreeViewStructure = getSavedTreeStructure(); // Check if there is a save tree structure. Also check that the first node id (=tree root) @@ -391,12 +373,12 @@ public void loadTreeData() { } }); setChildItems(childNodesMap, rootItem); + } else { List childNodes = saveAndRestoreService.getChildNodes(rootItem.getValue()); List> childItems = childNodes.stream().map(this::createTreeItem).sorted(treeNodeComparator).toList(); rootItem.getChildren().addAll(childItems); } - Platform.runLater(() -> { treeView.setRoot(rootItem); expandNodes(treeView.getRoot()); @@ -404,6 +386,7 @@ public void loadTreeData() { treeView.getRoot().addEventHandler(TreeItem.branchExpandedEvent(), e -> expandTreeNode(e.getTreeItem())); treeInitializationCountDownLatch.countDown(); }); + loadFilters(); }); } @@ -445,12 +428,9 @@ private String getSavedFilterName() { protected void expandTreeNode(TreeItem targetItem) { List childNodes = saveAndRestoreService.getChildNodes(targetItem.getValue()); List> list = - childNodes.stream().map(n -> createTreeItem(n)).toList(); + childNodes.stream().map(this::createTreeItem).toList(); targetItem.getChildren().setAll(list); targetItem.getChildren().sort(treeNodeComparator); - targetItem.setExpanded(true); - treeView.getSelectionModel().clearSelection(); - treeView.getSelectionModel().select(null); } /** @@ -462,9 +442,8 @@ private void compareSnapshot() { if (tab == null) { return; } - if (tab instanceof SnapshotTab) { + if (tab instanceof SnapshotTab currentTab) { try { - SnapshotTab currentTab = (SnapshotTab) tab; currentTab.addSnapshot(node); } catch (Exception e) { LOG.log(Level.WARNING, "Failed to compare snapshot", e); @@ -493,36 +472,19 @@ private void deleteNodes() { } private void deleteTreeItems(ObservableList> items) { - TreeItem parent = items.get(0).getParent(); disabledUi.set(true); List nodeIds = items.stream().map(item -> item.getValue().getUniqueId()).collect(Collectors.toList()); JobManager.schedule("Delete nodes", monitor -> { try { saveAndRestoreService.deleteNodes(nodeIds); + disabledUi.set(false); } catch (Exception e) { ExceptionDetailsErrorDialog.openError(Messages.errorGeneric, MessageFormat.format(Messages.errorDeleteNodeFailed, items.get(0).getValue().getName()), e); disabledUi.set(false); - return; } - - Platform.runLater(() -> { - List tabsToRemove = new ArrayList<>(); - List visibleTabs = tabPane.getTabs(); - for (Tab tab : visibleTabs) { - for (TreeItem treeItem : items) { - if (tab.getId().equals(treeItem.getValue().getUniqueId())) { - tabsToRemove.add(tab); - tab.getOnCloseRequest().handle(null); - } - } - } - disabledUi.set(false); - tabPane.getTabs().removeAll(tabsToRemove); - parent.getChildren().removeAll(items); - }); }); } @@ -553,6 +515,7 @@ public SearchAndFilterTab openSearchWindow() { return searchAndFilterTab; } } + /** * Creates a new folder {@link Node}. */ @@ -764,14 +727,7 @@ private void renameNode() { if (result.isPresent()) { node.getValue().setName(result.get()); try { - saveAndRestoreService.updateNode(node.getValue()); - // Since a changed node name may push the node to a different location in the tree view, - // we need to locate it to keep it selected. The tree view will otherwise "select" the node - // at the previous position of the renamed node. This is standard JavaFX TreeView behavior - // where TreeItems are "recycled", and updated by the cell renderer. - Stack copiedStack = new Stack<>(); - DirectoryUtilities.CreateLocationStringAndNodeStack(node.getValue(), false).getValue().forEach(copiedStack::push); - locateNode(copiedStack); + saveAndRestoreService.updateNode(node.getValue(), false); } catch (Exception e) { Alert alert = new Alert(AlertType.ERROR); alert.setTitle(Messages.errorActionFailed); @@ -801,39 +757,52 @@ public boolean isLeaf() { * * @param node The updated node. */ - @Override - public void nodeChanged(Node node) { + + private void nodeChanged(Node node) { // Find the node that has changed TreeItem nodeSubjectToUpdate = recursiveSearch(node.getUniqueId(), treeView.getRoot()); if (nodeSubjectToUpdate == null) { return; } nodeSubjectToUpdate.setValue(node); - // Folder and configuration node changes may include structure changes, so expand to force update. - if (nodeSubjectToUpdate.isExpanded() && (nodeSubjectToUpdate.getValue().getNodeType().equals(NodeType.FOLDER) || - nodeSubjectToUpdate.getValue().getNodeType().equals(NodeType.CONFIGURATION))) { - if (nodeSubjectToUpdate.getParent() != null) { // null means root folder as it has no parent - nodeSubjectToUpdate.getParent().getChildren().sort(treeNodeComparator); - } + // For updated and expanded folder nodes, refresh with respect to child nodes as + // a move/copy operation may add/remove nodes. + if (nodeSubjectToUpdate.getValue().getNodeType().equals(NodeType.FOLDER) && nodeSubjectToUpdate.isExpanded()) { expandTreeNode(nodeSubjectToUpdate); } } /** * Handles callback in order to update the tree view when a {@link Node} has been added, e.g. when - * a snapshot is saved. + * a snapshot is saved. The purpose is to update the {@link TreeView} accordingly to reflect the change. * - * @param parentNode Parent of the new {@link Node} - * @param newNodes The list of new {@link Node}s + * @param nodeId Unique id of the added {@link Node} */ - @Override - public void nodesAdded(Node parentNode, List newNodes) { - // Find the parent to which the new node is to be added - TreeItem parentTreeItem = recursiveSearch(parentNode.getUniqueId(), treeView.getRoot()); - if (parentTreeItem == null) { - return; + private void nodeAdded(String nodeId) { + Node newNode = saveAndRestoreService.getNode(nodeId); + try { + Node parentNode = saveAndRestoreService.getParentNode(nodeId); + // Find the parent to which the new node is to be added + TreeItem parentTreeItem = recursiveSearch(parentNode.getUniqueId(), treeView.getRoot()); + TreeItem newTreeItem = createTreeItem(newNode); + parentTreeItem.getChildren().add(newTreeItem); + parentTreeItem.getChildren().sort(treeNodeComparator); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** + * Handles callback in order to update the tree view when a {@link Node} has been deleted. + * The purpose is to update the {@link TreeView} accordingly to reflect the change. + * + * @param nodeId Unique id of the deleted {@link Node} + */ + private void nodeRemoved(String nodeId) { + TreeItem treeItemToRemove = recursiveSearch(nodeId, treeView.getRoot()); + if (treeItemToRemove != null) { + treeItemToRemove.getParent().getChildren().remove(treeItemToRemove); } - expandTreeNode(parentTreeItem); } /** @@ -938,11 +907,11 @@ public void saveLocalState() { } } - public void handleTabClosed() { + @Override + public boolean handleTabClosed() { saveLocalState(); - saveAndRestoreService.removeNodeChangeListener(this); - saveAndRestoreService.removeNodeAddedListener(this); - saveAndRestoreService.removeFilterChangeListener(this); + webSocketClientService.closeWebSocket(); + return true; } /** @@ -997,8 +966,7 @@ private void exportToCSV() { protected void addTagToSnapshots() { ObservableList> selectedItems = browserSelectionModel.getSelectedItems(); List selectedNodes = selectedItems.stream().map(TreeItem::getValue).collect(Collectors.toList()); - List updatedNodes = TagUtil.addTag(selectedNodes); - updatedNodes.forEach(this::nodeChanged); + TagUtil.addTag(selectedNodes); } /** @@ -1043,18 +1011,10 @@ public boolean selectedNodesOfSameType() { protected void moveNodes(List sourceNodes, Node targetNode, TransferMode transferMode) { disabledUi.set(true); JobManager.schedule("Copy Or Move save&restore node(s)", monitor -> { - TreeItem rootTreeItem = treeView.getRoot(); - TreeItem targetTreeItem = recursiveSearch(targetNode.getUniqueId(), rootTreeItem); try { - TreeItem sourceTreeItem = recursiveSearch(sourceNodes.get(0).getUniqueId(), rootTreeItem); - TreeItem sourceParentTreeItem = sourceTreeItem.getParent(); if (transferMode.equals(TransferMode.MOVE)) { saveAndRestoreService.moveNodes(sourceNodes, targetNode); - Platform.runLater(() -> { - removeMovedNodes(sourceParentTreeItem, sourceNodes); - addMovedNodes(targetTreeItem, sourceNodes); - }); - } // TransferMode.COPY not supported + }// TransferMode.COPY not supported } catch (Exception exception) { Logger.getLogger(SaveAndRestoreController.class.getName()) .log(Level.SEVERE, "Failed to move or copy"); @@ -1065,45 +1025,6 @@ protected void moveNodes(List sourceNodes, Node targetNode, TransferMode t }); } - /** - * Updates the tree view such that moved items are shown in the drop target. - * - * @param parentTreeItem The drop target - * @param nodes List of {@link Node}s that were moved. - */ - private void addMovedNodes(TreeItem parentTreeItem, List nodes) { - parentTreeItem.getChildren().addAll(nodes.stream().map(this::createTreeItem).toList()); - parentTreeItem.getChildren().sort(treeNodeComparator); - TreeItem nextItemToExpand = parentTreeItem; - while (nextItemToExpand != null) { - nextItemToExpand.setExpanded(true); - nextItemToExpand = nextItemToExpand.getParent(); - } - - } - - /** - * Updates the tree view such that moved items are removed from source nodes' parent. - * - * @param parentTreeItem The parent of the {@link Node}s before the move. - * @param nodes List of {@link Node}s that were moved. - */ - private void removeMovedNodes(TreeItem parentTreeItem, List nodes) { - List> childItems = parentTreeItem.getChildren(); - List> treeItemsToRemove = new ArrayList<>(); - childItems.forEach(childItem -> { - if (nodes.contains(childItem.getValue())) { - treeItemsToRemove.add(childItem); - } - }); - parentTreeItem.getChildren().removeAll(treeItemsToRemove); - TreeItem nextItemToExpand = parentTreeItem; - while (nextItemToExpand != null) { - nextItemToExpand.setExpanded(true); - nextItemToExpand = nextItemToExpand.getParent(); - } - } - /** * Parses the {@link URI} to determine what to do. Supported actions/behavior: *
    @@ -1233,28 +1154,27 @@ public Node[] getConfigAndSnapshotForActiveSnapshotTab() { return null; } - @Override - public void filterAddedOrUpdated(Filter filter) { + private void filterAddedOrUpdated(Filter filter) { if (!filtersList.contains(filter)) { filtersList.add(filter); } else { final int index = filtersList.indexOf(filter); - Platform.runLater(() -> { - filtersList.set(index, filter); - filtersComboBox.valueProperty().set(filter); - // If this is the active filter, update the tree view - if (filter.equals(filtersComboBox.getSelectionModel().getSelectedItem())) { - applyFilter(filter); - } - }); + filtersList.set(index, filter); + filtersComboBox.valueProperty().set(filter); + // If this is the active filter, update the tree view + if (filter.equals(filtersComboBox.getSelectionModel().getSelectedItem())) { + applyFilter(filter); + } } } - @Override - public void filterRemoved(Filter filter) { - if (filtersList.contains(filter)) { - filtersList.remove(filter); - // If this is the active filter, de-select filter completely + private void filterRemoved(String name) { + Optional filterOptional = filtersList.stream().filter(f -> f.getName().equals(name)).findFirst(); + if (filterOptional.isPresent()) { + Filter filterToRemove = new Filter(); + filterToRemove.setName(name); + filtersList.remove(filterToRemove); + // If this is the active filter, unselect it filterEnabledProperty.set(false); filtersComboBox.getSelectionModel().select(null); // And refresh tree view @@ -1314,10 +1234,10 @@ public boolean mayPaste() { if (clipBoardContent == null || browserSelectionModel.getSelectedItems().size() != 1) { return false; } - if(selectedItemsProperty.size() != 1 || - selectedItemsProperty.get(0).getUniqueId().equals(Node.ROOT_FOLDER_UNIQUE_ID) || - (!selectedItemsProperty.get(0).getNodeType().equals(NodeType.FOLDER) && !selectedItemsProperty.get(0).getNodeType().equals(NodeType.CONFIGURATION))){ - return false; + if (selectedItemsProperty.size() != 1 || + selectedItemsProperty.get(0).getUniqueId().equals(Node.ROOT_FOLDER_UNIQUE_ID) || + (!selectedItemsProperty.get(0).getNodeType().equals(NodeType.FOLDER) && !selectedItemsProperty.get(0).getNodeType().equals(NodeType.CONFIGURATION))) { + return false; } // Check is made if target node is of supported type for the clipboard content. List selectedNodes = (List) clipBoardContent; @@ -1339,7 +1259,7 @@ private void pasteFromClipboard() { } List selectedNodeIds = ((List) selectedNodes).stream().map(Node::getUniqueId).collect(Collectors.toList()); - JobManager.schedule("copy nodes", monitor -> { + JobManager.schedule("Copy nodes", monitor -> { try { saveAndRestoreService.copyNodes(selectedNodeIds, browserSelectionModel.getSelectedItem().getValue().getUniqueId()); disabledUi.set(false); @@ -1347,12 +1267,7 @@ private void pasteFromClipboard() { disabledUi.set(false); ExceptionDetailsErrorDialog.openError(Messages.errorGeneric, Messages.failedToPasteObjects, e); LOG.log(Level.WARNING, "Failed to paste nodes into target " + browserSelectionModel.getSelectedItem().getValue().getName()); - return; } - Platform.runLater(() -> { - expandTreeNode(browserSelectionModel.getSelectedItem()); - treeView.refresh(); - }); }); } @@ -1438,8 +1353,8 @@ private void openNode(String nodeId) { return; } Stack copiedStack = new Stack<>(); - DirectoryUtilities.CreateLocationStringAndNodeStack(node, false).getValue().forEach(copiedStack::push); Platform.runLater(() -> { + DirectoryUtilities.CreateLocationStringAndNodeStack(node, false).getValue().forEach(copiedStack::push); locateNode(copiedStack); nodeDoubleClicked(node); }); @@ -1505,4 +1420,25 @@ private void addOptionalLoggingMenuItem() { }); } } + + @Override + public void handleWebSocketMessage(SaveAndRestoreWebSocketMessage saveAndRestoreWebSocketMessage) { + switch (saveAndRestoreWebSocketMessage.messageType()) { + case NODE_ADDED -> nodeAdded((String) saveAndRestoreWebSocketMessage.payload()); + case NODE_REMOVED -> nodeRemoved((String) saveAndRestoreWebSocketMessage.payload()); + case NODE_UPDATED -> nodeChanged((Node) saveAndRestoreWebSocketMessage.payload()); + case FILTER_ADDED_OR_UPDATED -> filterAddedOrUpdated((Filter) saveAndRestoreWebSocketMessage.payload()); + case FILTER_REMOVED -> filterRemoved((String) saveAndRestoreWebSocketMessage.payload()); + } + } + + private void handleWebSocketConnected() { + serviceConnected.setValue(true); + loadTreeData(); + } + + private void handleWebSocketDisconnected() { + serviceConnected.setValue(false); + saveLocalState(); + } } diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/SaveAndRestoreService.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/SaveAndRestoreService.java index a689b817ad..76b9422110 100644 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/SaveAndRestoreService.java +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/SaveAndRestoreService.java @@ -18,6 +18,10 @@ package org.phoebus.applications.saveandrestore.ui; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.epics.vtype.VType; import org.phoebus.applications.saveandrestore.client.SaveAndRestoreClient; import org.phoebus.applications.saveandrestore.client.SaveAndRestoreClientImpl; @@ -45,7 +49,6 @@ import javax.ws.rs.core.MultivaluedMap; import java.time.Instant; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; @@ -60,20 +63,20 @@ public class SaveAndRestoreService { private final ExecutorService executor; - private final List nodeChangeListeners = Collections.synchronizedList(new ArrayList<>()); - private final List nodeAddedListeners = Collections.synchronizedList(new ArrayList<>()); - - private final List filterChangeListeners = Collections.synchronizedList(new ArrayList<>()); - private static final Logger LOG = Logger.getLogger(SaveAndRestoreService.class.getName()); private static SaveAndRestoreService instance; private final SaveAndRestoreClient saveAndRestoreClient; + private final ObjectMapper objectMapper; private SaveAndRestoreService() { saveAndRestoreClient = new SaveAndRestoreClientImpl(); executor = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.SECONDS, new LinkedBlockingQueue<>()); + objectMapper = new ObjectMapper(); + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); } public static SaveAndRestoreService getInstance() { @@ -113,21 +116,13 @@ public List getChildNodes(Node node) { return null; } - public Node updateNode(Node nodeToUpdate) throws Exception { - return updateNode(nodeToUpdate, false); - } - public Node updateNode(Node nodeToUpdate, boolean customTimeForMigration) throws Exception { Future future = executor.submit(() -> saveAndRestoreClient.updateNode(nodeToUpdate, customTimeForMigration)); - Node node = future.get(); - notifyNodeChangeListeners(node); - return node; + return future.get(); } public Node createNode(String parentNodeId, Node newTreeNode) throws Exception { - Future future = executor.submit(() -> saveAndRestoreClient.createNewNode(parentNodeId, newTreeNode)); - notifyNodeAddedListeners(getNode(parentNodeId), Collections.singletonList(newTreeNode)); - return future.get(); + return executor.submit(() -> saveAndRestoreClient.createNewNode(parentNodeId, newTreeNode)).get(); } public void deleteNodes(List nodeIds) throws Exception { @@ -145,17 +140,12 @@ public Node getParentNode(String uniqueNodeId) throws Exception { public Configuration createConfiguration(final Node parentNode, final Configuration configuration) throws Exception { Future future = executor.submit(() -> saveAndRestoreClient.createConfiguration(parentNode.getUniqueId(), configuration)); - Configuration newConfiguration = future.get(); - notifyNodeChangeListeners(parentNode); - return newConfiguration; + return future.get(); } public Configuration updateConfiguration(Configuration configuration) throws Exception { Future future = executor.submit(() -> saveAndRestoreClient.updateConfiguration(configuration)); - Configuration updatedConfiguration = future.get(); - // Associated configuration Node may have a new name - notifyNodeChangeListeners(updatedConfiguration.getConfigurationNode()); - return updatedConfiguration; + return future.get(); } public List getAllTags() throws Exception { @@ -163,36 +153,12 @@ public List getAllTags() throws Exception { return future.get(); } - public void addNodeChangeListener(NodeChangedListener nodeChangeListener) { - nodeChangeListeners.add(nodeChangeListener); - } - - public void removeNodeChangeListener(NodeChangedListener nodeChangeListener) { - nodeChangeListeners.remove(nodeChangeListener); - } - - private void notifyNodeChangeListeners(Node changedNode) { - nodeChangeListeners.forEach(listener -> listener.nodeChanged(changedNode)); - } - - public void addNodeAddedListener(NodeAddedListener nodeAddedListener) { - nodeAddedListeners.add(nodeAddedListener); - } - - public void removeNodeAddedListener(NodeAddedListener nodeAddedListener) { - nodeAddedListeners.remove(nodeAddedListener); - } - - private void notifyNodeAddedListeners(Node parentNode, List newNodes) { - nodeAddedListeners.forEach(listener -> listener.nodesAdded(parentNode, newNodes)); - } - /** * Moves the sourceNode to the targetNode. The target {@link Node} may not contain * any {@link Node} of same name and type as the source {@link Node}. *

    - * Once the move completes successfully in the remote service, this method will updated both the source node's parent - * as well as the target node. This is needed in order to keep the view updated with the changes performed. + * Once the move completes successfully in the remote service, this method will update both the source node's parent + * and the target node. This is needed in order to keep the view updated with the changes performed. * * @param sourceNodes A list of {@link Node}s of type {@link NodeType#FOLDER} or {@link NodeType#CONFIGURATION}. * @param targetNode A {@link Node} of type {@link NodeType#FOLDER}. @@ -251,10 +217,7 @@ public Snapshot saveSnapshot(Node configurationNode, Snapshot snapshot) throws E return saveAndRestoreClient.updateSnapshot(snapshot); } }); - Snapshot updatedSnapshot = future.get(); - // Notify listeners as the configuration node has a new child node. - notifyNodeChangeListeners(configurationNode); - return updatedSnapshot; + return future.get(); } public List getCompositeSnapshotNodes(String compositeSnapshotNodeUniqueId) throws Exception { @@ -272,17 +235,12 @@ public List getCompositeSnapshotItems(String compositeSnapshotNode public CompositeSnapshot saveCompositeSnapshot(Node parentNode, CompositeSnapshot compositeSnapshot) throws Exception { Future future = executor.submit(() -> saveAndRestoreClient.createCompositeSnapshot(parentNode.getUniqueId(), compositeSnapshot)); - CompositeSnapshot newCompositeSnapshot = future.get(); - notifyNodeChangeListeners(parentNode); - return newCompositeSnapshot; + return future.get(); } public CompositeSnapshot updateCompositeSnapshot(final CompositeSnapshot compositeSnapshot) throws Exception { Future future = executor.submit(() -> saveAndRestoreClient.updateCompositeSnapshot(compositeSnapshot)); - CompositeSnapshot updatedCompositeSnapshot = future.get(); - // Associated composite snapshot Node may have a new name - notifyNodeChangeListeners(updatedCompositeSnapshot.getCompositeSnapshotNode()); - return updatedCompositeSnapshot; + return future.get(); } /** @@ -318,9 +276,7 @@ public SearchResult search(MultivaluedMap searchParams) throws E public Filter saveFilter(Filter filter) throws Exception { Future future = executor.submit(() -> saveAndRestoreClient.saveFilter(filter)); - Filter addedOrUpdatedFilter = future.get(); - notifyFilterAddedOrUpdated(addedOrUpdatedFilter); - return addedOrUpdatedFilter; + return future.get(); } /** @@ -339,7 +295,6 @@ public List getAllFilters() throws Exception { */ public void deleteFilter(final Filter filter) throws Exception { executor.submit(() -> saveAndRestoreClient.deleteFilter(filter.getName())).get(); - notifyFilterDeleted(filter); } /** @@ -352,9 +307,7 @@ public void deleteFilter(final Filter filter) throws Exception { public List addTag(TagData tagData) throws Exception { Future> future = executor.submit(() -> saveAndRestoreClient.addTag(tagData)); - List updatedNodes = future.get(); - updatedNodes.forEach(this::notifyNodeChangeListeners); - return updatedNodes; + return future.get(); } /** @@ -367,25 +320,7 @@ public List addTag(TagData tagData) throws Exception { public List deleteTag(TagData tagData) throws Exception { Future> future = executor.submit(() -> saveAndRestoreClient.deleteTag(tagData)); - List updatedNodes = future.get(); - updatedNodes.forEach(this::notifyNodeChangeListeners); - return updatedNodes; - } - - public void addFilterChangeListener(FilterChangeListener filterChangeListener) { - filterChangeListeners.add(filterChangeListener); - } - - public void removeFilterChangeListener(FilterChangeListener filterChangeListener) { - filterChangeListeners.remove(filterChangeListener); - } - - private void notifyFilterAddedOrUpdated(Filter filter) { - filterChangeListeners.forEach(l -> l.filterAddedOrUpdated(filter)); - } - - private void notifyFilterDeleted(Filter filter) { - filterChangeListeners.forEach(l -> l.filterRemoved(filter)); + return future.get(); } /** diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/SaveAndRestoreTab.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/SaveAndRestoreTab.java index 7b7180140c..195532a589 100644 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/SaveAndRestoreTab.java +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/SaveAndRestoreTab.java @@ -24,6 +24,7 @@ import javafx.scene.control.MenuItem; import javafx.scene.control.Tab; import javafx.scene.image.ImageView; +import org.phoebus.applications.saveandrestore.model.websocket.SaveAndRestoreWebSocketMessage; import org.phoebus.applications.saveandrestore.ui.snapshot.SnapshotTab; import org.phoebus.security.tokens.ScopedAuthenticationToken; import org.phoebus.ui.javafx.ImageCache; @@ -34,9 +35,10 @@ /** * Base class for save-n-restore {@link Tab}s containing common functionality. */ -public abstract class SaveAndRestoreTab extends Tab implements NodeChangedListener { +public abstract class SaveAndRestoreTab extends Tab implements WebSocketMessageHandler { protected SaveAndRestoreBaseController controller; + protected WebSocketClientService webSocketClientService; public SaveAndRestoreTab() { ContextMenu contextMenu = new ContextMenu(); @@ -60,6 +62,18 @@ public SaveAndRestoreTab() { contextMenu.getItems().addAll(closeAll, closeOthers); setContextMenu(contextMenu); + + webSocketClientService = WebSocketClientService.getInstance(); + + setOnCloseRequest(event -> { + if (!controller.handleTabClosed()) { + event.consume(); + } else { + webSocketClientService.removeWebSocketMessageHandler(this); + } + }); + + webSocketClientService.addWebSocketMessageHandler(this); } /** @@ -70,4 +84,9 @@ public SaveAndRestoreTab() { public void secureStoreChanged(List validTokens) { controller.secureStoreChanged(validTokens); } + + @Override + public void handleWebSocketMessage(SaveAndRestoreWebSocketMessage saveAndRestoreWebSocketMessage) { + + } } diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/WebSocketClientService.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/WebSocketClientService.java new file mode 100644 index 0000000000..0deddbd10d --- /dev/null +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/WebSocketClientService.java @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2025 European Spallation Source ERIC. + */ + +package org.phoebus.applications.saveandrestore.ui; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.phoebus.applications.saveandrestore.client.Preferences; +import org.phoebus.applications.saveandrestore.model.websocket.SaveAndRestoreWebSocketMessage; +import org.phoebus.applications.saveandrestore.model.websocket.WebMessageDeserializer; +import org.phoebus.core.websocket.WebSocketClient; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class WebSocketClientService { + + private final List webSocketMessageHandlers = Collections.synchronizedList(new ArrayList<>()); + + private static WebSocketClientService instance; + private final WebSocketClient webSocketClient; + + private final ObjectMapper objectMapper; + + private WebSocketClientService() { + String baseUrl = Preferences.jmasarServiceUrl; + String schema = baseUrl.startsWith("https") ? "wss" : "ws"; + String webSocketUrl = schema + baseUrl.substring(baseUrl.indexOf("://")) + "/web-socket"; + URI webSocketUri = URI.create(webSocketUrl); + webSocketClient = new WebSocketClient(webSocketUri, this::handleWebSocketMessage); + objectMapper = new ObjectMapper(); + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + SimpleModule module = new SimpleModule(); + module.addDeserializer(SaveAndRestoreWebSocketMessage.class, + new WebMessageDeserializer(SaveAndRestoreWebSocketMessage.class)); + objectMapper.registerModule(module); + } + + public static WebSocketClientService getInstance() { + if (instance == null) { + instance = new WebSocketClientService(); + } + return instance; + } + + public void connect() { + webSocketClient.connect(); + } + + public void closeWebSocket() { + webSocketMessageHandlers.clear(); + webSocketClient.close("Application shutdown"); + } + + public void addWebSocketMessageHandler(WebSocketMessageHandler webSocketMessageHandler) { + webSocketMessageHandlers.add(webSocketMessageHandler); + } + + public void removeWebSocketMessageHandler(WebSocketMessageHandler webSocketMessageHandler) { + webSocketMessageHandlers.remove(webSocketMessageHandler); + } + + private void handleWebSocketMessage(CharSequence charSequence) { + try { + SaveAndRestoreWebSocketMessage saveAndRestoreWebSocketMessage = + objectMapper.readValue(charSequence.toString(), SaveAndRestoreWebSocketMessage.class); + webSocketMessageHandlers.forEach(w -> w.handleWebSocketMessage(saveAndRestoreWebSocketMessage)); + + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + public void setConnectCallback(Runnable connectCallback) { + webSocketClient.setConnectCallback(connectCallback); + } + + public void setDisconnectCallback(Runnable disconnectCallback) { + webSocketClient.setDisconnectCallback(disconnectCallback); + } +} diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/WebSocketMessageHandler.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/WebSocketMessageHandler.java new file mode 100644 index 0000000000..3e202fb660 --- /dev/null +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/WebSocketMessageHandler.java @@ -0,0 +1,12 @@ +/* + * Copyright (C) 2025 European Spallation Source ERIC. + */ + +package org.phoebus.applications.saveandrestore.ui; + +import org.phoebus.applications.saveandrestore.model.websocket.SaveAndRestoreWebSocketMessage; + +public interface WebSocketMessageHandler { + + void handleWebSocketMessage(SaveAndRestoreWebSocketMessage saveAndRestoreWebSocketMessage); +} diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/configuration/ConfigurationController.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/configuration/ConfigurationController.java index d5d9dbb831..6ba19f78d5 100644 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/configuration/ConfigurationController.java +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/configuration/ConfigurationController.java @@ -23,13 +23,12 @@ import javafx.beans.binding.Bindings; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; -import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleStringProperty; +import javafx.beans.value.ChangeListener; import javafx.collections.FXCollections; import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; import javafx.fxml.FXML; -import javafx.fxml.FXMLLoader; import javafx.scene.control.Alert; import javafx.scene.control.Button; import javafx.scene.control.ButtonType; @@ -53,17 +52,18 @@ import javafx.util.converter.DoubleStringConverter; import org.phoebus.applications.saveandrestore.Messages; import org.phoebus.applications.saveandrestore.SaveAndRestoreApplication; +import org.phoebus.applications.saveandrestore.model.ComparisonMode; import org.phoebus.applications.saveandrestore.model.ConfigPv; import org.phoebus.applications.saveandrestore.model.Configuration; import org.phoebus.applications.saveandrestore.model.ConfigurationData; import org.phoebus.applications.saveandrestore.model.Node; import org.phoebus.applications.saveandrestore.model.NodeType; -import org.phoebus.applications.saveandrestore.model.ComparisonMode; -import org.phoebus.applications.saveandrestore.ui.NodeChangedListener; +import org.phoebus.applications.saveandrestore.model.websocket.MessageType; +import org.phoebus.applications.saveandrestore.model.websocket.SaveAndRestoreWebSocketMessage; import org.phoebus.applications.saveandrestore.ui.SaveAndRestoreBaseController; -import org.phoebus.applications.saveandrestore.ui.SaveAndRestoreService; +import org.phoebus.applications.saveandrestore.ui.WebSocketMessageHandler; import org.phoebus.core.types.ProcessVariable; -import org.phoebus.framework.nls.NLS; +import org.phoebus.framework.jobs.JobManager; import org.phoebus.framework.selection.SelectionService; import org.phoebus.ui.application.ContextMenuHelper; import org.phoebus.ui.dialog.ExceptionDetailsErrorDialog; @@ -71,20 +71,17 @@ import org.phoebus.ui.javafx.ImageCache; import org.phoebus.util.time.TimestampFormats; -import java.io.IOException; import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Optional; -import java.util.ResourceBundle; -import java.util.concurrent.Executor; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; -public class ConfigurationController extends SaveAndRestoreBaseController implements NodeChangedListener { +public class ConfigurationController extends SaveAndRestoreBaseController implements WebSocketMessageHandler { @FXML @SuppressWarnings("unused") @@ -164,10 +161,6 @@ public class ConfigurationController extends SaveAndRestoreBaseController implem @SuppressWarnings("unused") private Pane addPVsPane; - private SaveAndRestoreService saveAndRestoreService; - - private static final Executor UI_EXECUTOR = Platform::runLater; - private final ObservableList configurationEntries = FXCollections.observableArrayList(); private final SimpleBooleanProperty selectionEmpty = new SimpleBooleanProperty(false); @@ -175,28 +168,30 @@ public class ConfigurationController extends SaveAndRestoreBaseController implem private final SimpleStringProperty configurationNameProperty = new SimpleStringProperty(); private Node configurationNodeParent; - private final SimpleObjectProperty configurationNode = new SimpleObjectProperty<>(); + private final Logger logger = Logger.getLogger(ConfigurationController.class.getName()); - private final ConfigurationTab configurationTab; + private final BooleanProperty dirty = new SimpleBooleanProperty(); - private ConfigurationData configurationData; + private final SimpleStringProperty tabTitleProperty = new SimpleStringProperty(); - private final Logger logger = Logger.getLogger(ConfigurationController.class.getName()); + /** + * Manages the id of the containing {@link javafx.scene.control.Tab}. This property will + * also indicate if the UI has been configured to edit a new or existing configuration: for a new configuration + * the id is null. + */ + private final SimpleStringProperty tabIdProperty = new SimpleStringProperty(); - private final BooleanProperty loadInProgress = new SimpleBooleanProperty(); - private final BooleanProperty dirty = new SimpleBooleanProperty(); + private ChangeListener nodeNameChangeListener; + private ChangeListener descriptionChangeListener; public ConfigurationController(ConfigurationTab configurationTab) { - this.configurationTab = configurationTab; + configurationTab.textProperty().bind(tabTitleProperty); + configurationTab.idProperty().bind(tabIdProperty); } @FXML public void initialize() { - saveAndRestoreService = SaveAndRestoreService.getInstance(); - - dirty.addListener((obs, o, n) -> configurationTab.annotateDirty(n)); - pvTable.editableProperty().bind(userIdentity.isNull().not()); pvTable.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); pvTable.getSelectionModel().selectedItemProperty().addListener((obs, ov, nv) -> selectionEmpty.set(nv == null)); @@ -205,8 +200,7 @@ public void initialize() { new ImageView(ImageCache.getImage(ConfigurationController.class, "/icons/delete.png"))); deleteMenuItem.setOnAction(ae -> { configurationEntries.removeAll(pvTable.getSelectionModel().getSelectedItems()); - //configurationTab.annotateDirty(true); - pvTable.refresh(); + dirty.setValue(true); }); deleteMenuItem.disableProperty().bind(Bindings.createBooleanBinding(() -> pvTable.getSelectionModel().getSelectedItems().isEmpty() @@ -234,20 +228,20 @@ public void initialize() { pvNameColumn.setCellValueFactory(cell -> cell.getValue().getPvNameProperty()); pvNameColumn.setOnEditCommit(t -> { t.getTableView().getItems().get(t.getTablePosition().getRow()).setPvNameProperty(t.getNewValue()); - setDirty(true); + dirty.setValue(true); }); readbackPvNameColumn.setCellFactory(TextFieldTableCell.forTableColumn()); readbackPvNameColumn.setCellValueFactory(cell -> cell.getValue().getReadBackPvNameProperty()); readbackPvNameColumn.setOnEditCommit(t -> { t.getTableView().getItems().get(t.getTablePosition().getRow()).setReadBackPvNameProperty(t.getNewValue()); - setDirty(true); + dirty.setValue(true); }); readOnlyColumn.setCellFactory(CheckBoxTableCell.forTableColumn(readOnlyColumn)); readOnlyColumn.setCellValueFactory(cell -> { BooleanProperty readOnly = cell.getValue().getReadOnlyProperty(); - readOnly.addListener((obs, o, n) -> setDirty(true)); + readOnly.addListener((obs, o, n) -> dirty.setValue(true)); return readOnly; }); @@ -259,11 +253,16 @@ public void initialize() { @Override public void commitEdit(ComparisonMode comparisonMode) { + ComparisonMode currentMode = getTableView().getItems().get(getIndex()).getComparisonModeProperty().get(); getTableView().getItems().get(getIndex()).setComparisonModeProperty(comparisonMode); if (comparisonMode == null) { getTableView().getItems().get(getIndex()).setToleranceProperty(null); } - setDirty(true); + // User has selected a mode that was previously null -> set tolerance to a default value. + else if (currentMode == null) { + getTableView().getItems().get(getIndex()).setToleranceProperty(0.0); + } + dirty.setValue(true); super.commitEdit(comparisonMode); } }; @@ -299,7 +298,7 @@ public String toString(Double value) { public Double fromString(String string) { try { double value = Double.parseDouble(string); - if(value >= 0){ + if (value >= 0) { // Tolerance must be >= 0. return value; } @@ -316,7 +315,7 @@ public void commitEdit(Double value) { return; } getTableView().getItems().get(getIndex()).setToleranceProperty(value); - setDirty(true); + dirty.setValue(true); super.commitEdit(value); } }); @@ -333,13 +332,21 @@ public void commitEdit(Double value) { while (change.next()) { if (change.wasAdded() || change.wasRemoved()) { FXCollections.sort(configurationEntries); - setDirty(true); } } }); + pvTable.setItems(configurationEntries); + + dirty.addListener((obs, o, n) -> { + if (n && !tabTitleProperty.get().startsWith("* ")) { + Platform.runLater(() -> tabTitleProperty.setValue("* " + tabTitleProperty.get())); + } else if (!n && tabTitleProperty.get().startsWith("* ")) { + Platform.runLater(() -> tabTitleProperty.setValue(tabTitleProperty.get().substring(2))); + } + }); - configurationNameProperty.addListener((observableValue, oldValue, newValue) -> setDirty(!newValue.equals(configurationNode.getName()))); - configurationDescriptionProperty.addListener((observable, oldValue, newValue) -> setDirty(!newValue.equals(configurationNode.get().getDescription()))); + nodeNameChangeListener = (observableValue, oldValue, newValue) -> dirty.setValue(true); + descriptionChangeListener = (observableValue, oldValue, newValue) -> dirty.setValue(true); saveButton.disableProperty().bind(Bindings.createBooleanBinding(() -> dirty.not().get() || configurationDescriptionProperty.isEmpty().get() || @@ -348,58 +355,48 @@ public void commitEdit(Double value) { dirty, configurationDescriptionProperty, configurationNameProperty, userIdentity)); addPvButton.disableProperty().bind(Bindings.createBooleanBinding(() -> - pvNameField.textProperty().isEmpty().get() && - readbackPvNameField.textProperty().isEmpty().get(), + pvNameField.textProperty().isEmpty().get() && + readbackPvNameField.textProperty().isEmpty().get(), pvNameField.textProperty(), readbackPvNameField.textProperty())); readOnlyCheckBox.selectedProperty().bindBidirectional(readOnlyProperty); - configurationNode.addListener(observable -> { - if (observable != null) { - SimpleObjectProperty simpleObjectProperty = (SimpleObjectProperty) observable; - Node newValue = simpleObjectProperty.get(); - configurationNameProperty.set(newValue.getName()); - Platform.runLater(() -> { - configurationCreatedDateField.textProperty().set(newValue.getCreated() != null ? - TimestampFormats.SECONDS_FORMAT.format(Instant.ofEpochMilli(newValue.getCreated().getTime())) : null); - configurationLastModifiedDateField.textProperty().set(newValue.getLastModified() != null ? - TimestampFormats.SECONDS_FORMAT.format(Instant.ofEpochMilli(newValue.getLastModified().getTime())) : null); - createdByField.textProperty().set(newValue.getUserName()); - }); - configurationDescriptionProperty.set(configurationNode.get().getDescription()); - } - }); - addPVsPane.disableProperty().bind(userIdentity.isNull()); - SaveAndRestoreService.getInstance().addNodeChangeListener(this); + webSocketClientService.addWebSocketMessageHandler(this); } @FXML @SuppressWarnings("unused") public void saveConfiguration() { - UI_EXECUTOR.execute(() -> { + JobManager.schedule("Save save&restore configuration", monitor -> { try { - configurationNode.get().setName(configurationNameProperty.get()); - configurationNode.get().setDescription(configurationDescriptionProperty.get()); + Node configurationNode = + Node.builder().nodeType(NodeType.CONFIGURATION) + .name(configurationNameProperty.get()) + .description(configurationDescriptionProperty.get()) + .uniqueId(tabIdProperty.get()) + .build(); + ConfigurationData configurationData = new ConfigurationData(); configurationData.setPvList(configurationEntries.stream().map(ConfigPvEntry::toConfigPv).toList()); + configurationData.setUniqueId(tabIdProperty.get()); Configuration configuration = new Configuration(); - configuration.setConfigurationNode(configurationNode.get()); + configuration.setConfigurationNode(configurationNode); configuration.setConfigurationData(configurationData); - if (configurationNode.get().getUniqueId() == null) { // New configuration + if (tabIdProperty.get() == null) { // New configuration configuration = saveAndRestoreService.createConfiguration(configurationNodeParent, configuration); - configurationTab.setId(configuration.getConfigurationNode().getUniqueId()); + tabIdProperty.setValue(configuration.getConfigurationNode().getUniqueId()); } else { configuration = saveAndRestoreService.updateConfiguration(configuration); } - configurationData = configuration.getConfigurationData(); loadConfiguration(configuration.getConfigurationNode()); + dirty.setValue(false); } catch (Exception e1) { - ExceptionDetailsErrorDialog.openError(pvTable, + Platform.runLater(() -> ExceptionDetailsErrorDialog.openError(pvTable, Messages.errorActionFailed, Messages.errorCreateConfigurationFailed, - e1); + e1)); } }); } @@ -408,12 +405,12 @@ public void saveConfiguration() { @SuppressWarnings("unused") public void addPv() { - UI_EXECUTOR.execute(() -> { + Platform.runLater(() -> { // Process a list of space or semicolon separated pvs String[] pvNames = pvNameProperty.get().trim().split("[\\s;]+"); String[] readbackPvNames = readbackPvNameProperty.get().trim().split("[\\s;]+"); - if(!checkForDuplicatePvNames(pvNames)){ + if (!checkForDuplicatePvNames(pvNames)) { return; } @@ -429,22 +426,23 @@ public void addPv() { configPVs.add(new ConfigPvEntry(configPV)); } configurationEntries.addAll(configPVs); + dirty.setValue(true); resetAddPv(); }); } /** * Checks that added PV names are not added multiple times + * * @param addedPvNames New PV names added in the UI * @return true if no duplicates are detected, otherwise false */ - private boolean checkForDuplicatePvNames(String[] addedPvNames){ - List pvNamesAsList = new ArrayList<>(); - pvNamesAsList.addAll(Arrays.asList(addedPvNames)); + private boolean checkForDuplicatePvNames(String[] addedPvNames) { + List pvNamesAsList = new ArrayList<>(Arrays.asList(addedPvNames)); pvTable.itemsProperty().get().forEach(i -> pvNamesAsList.add(i.getPvNameProperty().get())); List duplicatePvNames = pvNamesAsList.stream().filter(n -> Collections.frequency(pvNamesAsList, n) > 1).toList(); - if(duplicatePvNames.size() > 0){ + if (!duplicatePvNames.isEmpty()) { Alert alert = new Alert(Alert.AlertType.ERROR); alert.setHeaderText(Messages.duplicatePVNamesNotSupported); alert.showAndWait(); @@ -469,11 +467,9 @@ private void resetAddPv() { */ public void newConfiguration(Node parentNode) { configurationNodeParent = parentNode; - configurationNode.set(Node.builder().nodeType(NodeType.CONFIGURATION).build()); - configurationData = new ConfigurationData(); - pvTable.setItems(configurationEntries); - UI_EXECUTOR.execute(() -> configurationNameField.requestFocus()); - setDirty(false); + addListeners(); + tabTitleProperty.setValue(Messages.unnamedConfiguration); + Platform.runLater(() -> configurationNameField.requestFocus()); } /** @@ -482,64 +478,77 @@ public void newConfiguration(Node parentNode) { * @param node An existing {@link Node} of type {@link NodeType#CONFIGURATION}. */ public void loadConfiguration(final Node node) { - try { - configurationData = saveAndRestoreService.getConfiguration(node.getUniqueId()); - } catch (Exception e) { - ExceptionDetailsErrorDialog.openError(root, Messages.errorGeneric, Messages.errorUnableToRetrieveData, e); - return; - } - loadInProgress.set(true); - // Create a cloned Node object to avoid changes in the Node object contained in the tree view. - configurationNode.set(Node.builder().uniqueId(node.getUniqueId()) - .name(node.getName()) - .nodeType(NodeType.CONFIGURATION) - .description(node.getDescription()) - .userName(node.getUserName()) - .created(node.getCreated()) - .lastModified(node.getLastModified()) - .build()); - loadConfigurationData(() -> loadInProgress.set(false)); - } - - private void loadConfigurationData(Runnable completion) { - UI_EXECUTOR.execute(() -> { + removeListeners(); + JobManager.schedule("Load save&restore configuration", monitor -> { + final ConfigurationData configurationData; try { - Collections.sort(configurationData.getPvList()); - configurationEntries.setAll(configurationData.getPvList().stream().map(ConfigPvEntry::new).toList()); - pvTable.setItems(configurationEntries); - completion.run(); + configurationData = saveAndRestoreService.getConfiguration(node.getUniqueId()); } catch (Exception e) { - logger.log(Level.WARNING, "Unable to load existing configuration"); + Platform.runLater(() -> ExceptionDetailsErrorDialog.openError(root, Messages.errorGeneric, Messages.errorUnableToRetrieveData, e)); + return; } + + Platform.runLater(() -> { + try { + tabTitleProperty.setValue(node.getName()); + tabIdProperty.setValue(node.getUniqueId()); + Collections.sort(configurationData.getPvList()); + configurationEntries.setAll(configurationData.getPvList().stream().map(ConfigPvEntry::new).toList()); + configurationNameProperty.set(node.getName()); + configurationCreatedDateField.textProperty().set(node.getCreated() != null ? + TimestampFormats.SECONDS_FORMAT.format(Instant.ofEpochMilli(node.getCreated().getTime())) : null); + configurationLastModifiedDateField.textProperty().set(node.getLastModified() != null ? + TimestampFormats.SECONDS_FORMAT.format(Instant.ofEpochMilli(node.getLastModified().getTime())) : null); + createdByField.textProperty().set(node.getUserName()); + configurationDescriptionProperty.set(node.getDescription()); + dirty.setValue(false); + addListeners(); + } catch (Exception e) { + logger.log(Level.WARNING, "Unable to load existing configuration"); + } + }); }); + } - public boolean handleConfigurationTabClosed() { + /** + * Handles clean-up when the associated {@link ConfigurationTab} is closed. + * A check is made if content is dirty, in which case user is prompted to cancel or close anyway. + * + * @return true if content is not dirty or user chooses to close anyway, + * otherwise false. + */ + @Override + public boolean handleTabClosed() { if (dirty.get()) { Alert alert = new Alert(Alert.AlertType.CONFIRMATION); alert.setTitle(Messages.closeTabPrompt); alert.setContentText(Messages.closeConfigurationWarning); Optional result = alert.showAndWait(); return result.isPresent() && result.get().equals(ButtonType.OK); + } else { + webSocketClientService.removeWebSocketMessageHandler(this); + return true; } - return true; } @Override - public void nodeChanged(Node node) { - if (node.getUniqueId().equals(configurationNode.get().getUniqueId())) { - configurationNode.setValue(Node.builder().uniqueId(node.getUniqueId()) - .name(node.getName()) - .nodeType(NodeType.CONFIGURATION) - .userName(node.getUserName()) - .description(node.getDescription()) - .created(node.getCreated()) - .lastModified(node.getLastModified()) - .build()); + public void handleWebSocketMessage(SaveAndRestoreWebSocketMessage saveAndRestoreWebSocketMessage) { + if (saveAndRestoreWebSocketMessage.messageType().equals(MessageType.NODE_UPDATED)) { + Node node = (Node) saveAndRestoreWebSocketMessage.payload(); + if (tabIdProperty.get() != null && node.getUniqueId().equals(tabIdProperty.get())) { + loadConfiguration(node); + } } } - private void setDirty(boolean dirty) { - this.dirty.set(dirty && !loadInProgress.get()); + private void addListeners() { + configurationNameProperty.addListener(nodeNameChangeListener); + configurationDescriptionProperty.addListener(descriptionChangeListener); + } + + private void removeListeners() { + configurationNameProperty.removeListener(nodeNameChangeListener); + configurationDescriptionProperty.removeListener(descriptionChangeListener); } } diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/configuration/ConfigurationTab.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/configuration/ConfigurationTab.java index 572a7f4a2c..dcbb9cb9ca 100644 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/configuration/ConfigurationTab.java +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/configuration/ConfigurationTab.java @@ -23,22 +23,20 @@ import javafx.scene.image.ImageView; import org.phoebus.applications.saveandrestore.Messages; import org.phoebus.applications.saveandrestore.model.Node; +import org.phoebus.applications.saveandrestore.model.websocket.MessageType; +import org.phoebus.applications.saveandrestore.model.websocket.SaveAndRestoreWebSocketMessage; import org.phoebus.applications.saveandrestore.ui.ImageRepository; -import org.phoebus.applications.saveandrestore.ui.SaveAndRestoreService; import org.phoebus.applications.saveandrestore.ui.SaveAndRestoreTab; +import org.phoebus.applications.saveandrestore.ui.WebSocketMessageHandler; import org.phoebus.framework.nls.NLS; import java.util.ResourceBundle; import java.util.logging.Level; import java.util.logging.Logger; -public class ConfigurationTab extends SaveAndRestoreTab { +public class ConfigurationTab extends SaveAndRestoreTab implements WebSocketMessageHandler { public ConfigurationTab() { - configure(); - } - - private void configure() { try { FXMLLoader loader = new FXMLLoader(); ResourceBundle resourceBundle = NLS.getMessages(Messages.class); @@ -62,18 +60,7 @@ private void configure() { } catch (Exception e) { Logger.getLogger(ConfigurationTab.class.getName()) .log(Level.SEVERE, "Failed to load fxml", e); - return; } - - setOnCloseRequest(event -> { - if (!((ConfigurationController) controller).handleConfigurationTabClosed()) { - event.consume(); - } else { - SaveAndRestoreService.getInstance().removeNodeChangeListener(this); - } - }); - - SaveAndRestoreService.getInstance().addNodeChangeListener(this); } /** @@ -82,44 +69,25 @@ private void configure() { * @param configurationNode non-null configuration {@link Node} */ public void editConfiguration(Node configurationNode) { - setId(configurationNode.getUniqueId()); - textProperty().set(configurationNode.getName()); ((ConfigurationController) controller).loadConfiguration(configurationNode); } - public void configureForNewConfiguration(Node parentNode) { - textProperty().set(Messages.contextMenuNewConfiguration); - ((ConfigurationController) controller).newConfiguration(parentNode); - } - - @Override - public void nodeChanged(Node node) { - if (node.getUniqueId().equals(getId())) { - Platform.runLater(() -> textProperty().set(node.getName())); - } - } - /** - * Updates tab title, e.g. if user has renamed the configuration. + * Configures for new configuration * - * @param tabTitle The wanted tab title. + * @param parentNode Parent {@link Node} for the new configuration. */ - private void updateTabTitle(String tabTitle) { - Platform.runLater(() -> textProperty().set(tabTitle)); + public void configureForNewConfiguration(Node parentNode) { + ((ConfigurationController) controller).newConfiguration(parentNode); } - /** - * Updates the tab to indicate if the data is dirty and needs to be saved. - * @param dirty If true, an asterisk is prepended, otherwise - * only the name {@link org.phoebus.applications.saveandrestore.model.Configuration} - * is rendered. - */ - public void annotateDirty(boolean dirty) { - String tabTitle = textProperty().get(); - if (dirty && !tabTitle.startsWith("*")) { - updateTabTitle("* " + tabTitle); - } else if (!dirty && tabTitle.startsWith("*")) { - updateTabTitle(tabTitle.substring(2)); + @Override + public void handleWebSocketMessage(SaveAndRestoreWebSocketMessage saveAndRestoreWebSocketMessage) { + if (saveAndRestoreWebSocketMessage.messageType().equals(MessageType.NODE_REMOVED)) { + String nodeId = (String) saveAndRestoreWebSocketMessage.payload(); + if (getId() != null && nodeId.equals(getId())) { + Platform.runLater(() -> getTabPane().getTabs().remove(this)); + } } } } diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/search/SearchAndFilterTab.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/search/SearchAndFilterTab.java index 5ac75eca1f..7a048e309b 100644 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/search/SearchAndFilterTab.java +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/search/SearchAndFilterTab.java @@ -19,43 +19,30 @@ package org.phoebus.applications.saveandrestore.ui.search; -import javafx.application.Platform; import javafx.fxml.FXMLLoader; import javafx.scene.Node; -import javafx.scene.control.Alert; import javafx.scene.image.ImageView; import org.phoebus.applications.saveandrestore.Messages; import org.phoebus.applications.saveandrestore.SaveAndRestoreApplication; -import org.phoebus.applications.saveandrestore.model.search.Filter; -import org.phoebus.applications.saveandrestore.ui.NodeChangedListener; import org.phoebus.applications.saveandrestore.ui.SaveAndRestoreController; import org.phoebus.applications.saveandrestore.ui.SaveAndRestoreService; import org.phoebus.applications.saveandrestore.ui.SaveAndRestoreTab; -import org.phoebus.framework.jobs.JobManager; import org.phoebus.framework.nls.NLS; -import org.phoebus.ui.dialog.ExceptionDetailsErrorDialog; import org.phoebus.ui.javafx.ImageCache; import java.io.IOException; -import java.text.MessageFormat; -import java.util.List; -import java.util.Optional; import java.util.ResourceBundle; import java.util.logging.Level; import java.util.logging.Logger; -public class SearchAndFilterTab extends SaveAndRestoreTab implements NodeChangedListener { +public class SearchAndFilterTab extends SaveAndRestoreTab { public static final String SEARCH_AND_FILTER_TAB_ID = "SearchAndFilterTab"; private SearchAndFilterViewController searchAndFilterViewController; - private final SaveAndRestoreService saveAndRestoreService; public SearchAndFilterTab(SaveAndRestoreController saveAndRestoreController) { setId(SEARCH_AND_FILTER_TAB_ID); - - saveAndRestoreService = SaveAndRestoreService.getInstance(); - final ResourceBundle bundle = NLS.getMessages(SaveAndRestoreApplication.class); FXMLLoader loader = new FXMLLoader(); @@ -66,8 +53,7 @@ public SearchAndFilterTab(SaveAndRestoreController saveAndRestoreController) { if (clazz.isAssignableFrom(SearchAndFilterViewController.class)) { return clazz.getConstructor(SaveAndRestoreController.class) .newInstance(saveAndRestoreController); - } - else if(clazz.isAssignableFrom(SearchResultTableViewController.class)){ + } else if (clazz.isAssignableFrom(SearchResultTableViewController.class)) { return clazz.getConstructor() .newInstance(); } @@ -91,45 +77,5 @@ else if(clazz.isAssignableFrom(SearchResultTableViewController.class)){ setText(Messages.search); setGraphic(new ImageView(ImageCache.getImage(ImageCache.class, "/icons/sar-search_18x18.png"))); - - setOnCloseRequest(event -> SaveAndRestoreService.getInstance().removeNodeChangeListener(this)); - - saveAndRestoreService.addNodeChangeListener(this); - } - - - @Override - public void nodeChanged(org.phoebus.applications.saveandrestore.model.Node updatedNode) { - searchAndFilterViewController.nodeChanged(updatedNode); - } - - /** - * Shows a {@link Filter} in the view. If the filter identified through the specified (unique) id does not - * exist, an error message is show. - * - * @param filterId Unique, case-sensitive name of a persisted {@link Filter}. - */ - public void showFilter(String filterId) { - JobManager.schedule("Show Filter", monitor -> { - List allFilters; - try { - allFilters = saveAndRestoreService.getAllFilters(); - } catch (Exception e) { - Platform.runLater(() -> ExceptionDetailsErrorDialog.openError(Messages.failedGetFilters, e)); - return; - } - Optional filterOptional = allFilters.stream().filter(f -> f.getName().equalsIgnoreCase(filterId)).findFirst(); - if (!filterOptional.isPresent()) { - Platform.runLater(() -> { - Alert alert = new Alert(Alert.AlertType.ERROR); - alert.setContentText(MessageFormat.format(Messages.filterNotFound, filterId)); - alert.show(); - }); - } else { - Filter filter = filterOptional.get(); - Platform.runLater(() -> - searchAndFilterViewController.setFilter(filter)); - } - }); } } diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/search/SearchAndFilterViewController.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/search/SearchAndFilterViewController.java index acddb6b90c..9bef48e88c 100644 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/search/SearchAndFilterViewController.java +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/search/SearchAndFilterViewController.java @@ -45,17 +45,17 @@ import javafx.scene.input.KeyCode; import javafx.scene.layout.VBox; import org.phoebus.applications.saveandrestore.Messages; -import org.phoebus.applications.saveandrestore.model.Node; import org.phoebus.applications.saveandrestore.model.NodeType; import org.phoebus.applications.saveandrestore.model.Tag; import org.phoebus.applications.saveandrestore.model.search.Filter; import org.phoebus.applications.saveandrestore.model.search.SearchQueryUtil; import org.phoebus.applications.saveandrestore.model.search.SearchQueryUtil.Keys; -import org.phoebus.applications.saveandrestore.ui.FilterChangeListener; +import org.phoebus.applications.saveandrestore.model.websocket.SaveAndRestoreWebSocketMessage; import org.phoebus.applications.saveandrestore.ui.HelpViewer; import org.phoebus.applications.saveandrestore.ui.SaveAndRestoreBaseController; import org.phoebus.applications.saveandrestore.ui.SaveAndRestoreController; import org.phoebus.applications.saveandrestore.ui.SaveAndRestoreService; +import org.phoebus.applications.saveandrestore.ui.WebSocketMessageHandler; import org.phoebus.framework.jobs.JobManager; import org.phoebus.security.tokens.ScopedAuthenticationToken; import org.phoebus.ui.autocomplete.PVAutocompleteMenu; @@ -78,7 +78,8 @@ import java.util.logging.Logger; import java.util.stream.Collectors; -public class SearchAndFilterViewController extends SaveAndRestoreBaseController implements Initializable, FilterChangeListener { +public class SearchAndFilterViewController extends SaveAndRestoreBaseController + implements Initializable, WebSocketMessageHandler { private final SaveAndRestoreController saveAndRestoreController; @@ -180,8 +181,6 @@ public class SearchAndFilterViewController extends SaveAndRestoreBaseController private final SimpleStringProperty filterNameProperty = new SimpleStringProperty(); - private final SaveAndRestoreService saveAndRestoreService; - private final SimpleStringProperty query = new SimpleStringProperty(); private final SimpleStringProperty pvNamesProperty = new SimpleStringProperty(); @@ -219,7 +218,6 @@ public class SearchAndFilterViewController extends SaveAndRestoreBaseController public SearchAndFilterViewController(SaveAndRestoreController saveAndRestoreController) { this.saveAndRestoreController = saveAndRestoreController; - this.saveAndRestoreService = SaveAndRestoreService.getInstance(); } @FXML @@ -367,10 +365,10 @@ public void initialize(URL url, ResourceBundle resourceBundle) { filterNameTextField.textProperty().bindBidirectional(filterNameProperty); filterNameTextField.disableProperty().bind(saveAndRestoreController.getUserIdentity().isNull()); saveFilterButton.disableProperty().bind(Bindings.createBooleanBinding(() -> - filterNameProperty.isEmpty().get() || - saveAndRestoreController.getUserIdentity().isNull().get() || - uniqueIdProperty.isNotEmpty().get(), - filterNameProperty, saveAndRestoreController.getUserIdentity(), uniqueIdProperty)); + filterNameProperty.isEmpty().get() || + saveAndRestoreController.getUserIdentity().isNull().get() || + uniqueIdProperty.isNotEmpty().get(), + filterNameProperty, saveAndRestoreController.getUserIdentity(), uniqueIdProperty)); query.addListener((observable, oldValue, newValue) -> { if (newValue == null || newValue.isEmpty()) { @@ -382,7 +380,7 @@ public void initialize(URL url, ResourceBundle resourceBundle) { loadFilters(); - saveAndRestoreService.addFilterChangeListener(this); + webSocketClientService.addWebSocketMessageHandler(this); progressIndicator.visibleProperty().bind(disableUi); disableUi.addListener((observable, oldValue, newValue) -> mainUi.setDisable(newValue)); @@ -634,28 +632,21 @@ private void updatedQueryEditor() { searchDisabled = false; } - - public void nodeChanged(Node updatedNode) { - searchResultTableViewController.nodeChanged(updatedNode); - } - - @Override - public void filterAddedOrUpdated(Filter filter) { - loadFilters(); - } - - @Override - public void filterRemoved(Filter filter) { - loadFilters(); - } - public void handleSaveAndFilterTabClosed() { - saveAndRestoreService.removeFilterChangeListener(this); + webSocketClientService.removeWebSocketMessageHandler(this); + searchResultTableViewController.handleTabClosed(); } @Override - public void secureStoreChanged(List validTokens){ + public void secureStoreChanged(List validTokens) { super.secureStoreChanged(validTokens); searchResultTableViewController.secureStoreChanged(validTokens); } + + @Override + public void handleWebSocketMessage(SaveAndRestoreWebSocketMessage saveAndRestoreWebSocketMessage) { + switch (saveAndRestoreWebSocketMessage.messageType()) { + case FILTER_REMOVED, FILTER_ADDED_OR_UPDATED -> loadFilters(); + } + } } diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/search/SearchResultTableViewController.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/search/SearchResultTableViewController.java index fad2ebe32c..d800ee2e83 100644 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/search/SearchResultTableViewController.java +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/search/SearchResultTableViewController.java @@ -41,10 +41,12 @@ import org.phoebus.applications.saveandrestore.model.search.Filter; import org.phoebus.applications.saveandrestore.model.search.SearchQueryUtil; import org.phoebus.applications.saveandrestore.model.search.SearchResult; +import org.phoebus.applications.saveandrestore.model.websocket.SaveAndRestoreWebSocketMessage; import org.phoebus.applications.saveandrestore.ui.ImageRepository; import org.phoebus.applications.saveandrestore.ui.RestoreMode; import org.phoebus.applications.saveandrestore.ui.SaveAndRestoreBaseController; import org.phoebus.applications.saveandrestore.ui.SaveAndRestoreService; +import org.phoebus.applications.saveandrestore.ui.WebSocketMessageHandler; import org.phoebus.applications.saveandrestore.ui.contextmenu.LoginMenuItem; import org.phoebus.applications.saveandrestore.ui.contextmenu.RestoreFromClientMenuItem; import org.phoebus.applications.saveandrestore.ui.contextmenu.RestoreFromServiceMenuItem; @@ -77,7 +79,8 @@ /** * Controller for the search result table. */ -public class SearchResultTableViewController extends SaveAndRestoreBaseController implements Initializable { +public class SearchResultTableViewController extends SaveAndRestoreBaseController + implements Initializable, WebSocketMessageHandler { @SuppressWarnings("unused") @FXML @@ -137,13 +140,10 @@ public class SearchResultTableViewController extends SaveAndRestoreBaseControlle private String queryString; private static final Logger LOGGER = Logger.getLogger(SearchResultTableViewController.class.getName()); - private SaveAndRestoreService saveAndRestoreService; @Override public void initialize(URL url, ResourceBundle resourceBundle) { - saveAndRestoreService = SaveAndRestoreService.getInstance(); - tableUi.disableProperty().bind(disableUi); progressIndicator.visibleProperty().bind(disableUi); @@ -287,6 +287,8 @@ protected void updateItem(Node node, boolean empty) { pageSizeTextField.setText(oldValue); } }); + + webSocketClientService.addWebSocketMessageHandler(this); } private ImageView getImageView(Node node) { @@ -307,15 +309,6 @@ private ImageView getImageView(Node node) { return null; } - public void nodeChanged(Node updatedNode) { - for (Node node : resultTableView.getItems()) { - if (node.getUniqueId().equals(updatedNode.getUniqueId())) { - node.setTags(updatedNode.getTags()); - resultTableView.refresh(); - } - } - } - public void clearTable() { tableEntries.clear(); hitCountProperty.set(0); @@ -377,7 +370,7 @@ void uniqueIdSearch(final String uniqueIdString) { tableEntries.setAll(List.of(uniqueIdNode)); hitCountProperty.set(1); }); - /* Clear the results table if no record returned */ + /* Clear the results table if no record returned */ } else { Platform.runLater(tableEntries::clear); hitCountProperty.set(0); @@ -398,16 +391,16 @@ void uniqueIdSearch(final String uniqueIdString) { /** * Retrieves a filter from the service and loads then performs a search for matching {@link Node}s. If * the filter does not exist, or if retrieval fails, an error dialog is shown. + * * @param filterId Unique id of an existing {@link Filter}. */ - public void loadFilter(String filterId){ + public void loadFilter(String filterId) { try { List filters = saveAndRestoreService.getAllFilters(); Optional filter = filters.stream().filter(f -> f.getName().equals(filterId)).findFirst(); - if(filter.isPresent()){ + if (filter.isPresent()) { search(filter.get().getQueryString()); - } - else{ + } else { Alert alert = new Alert(Alert.AlertType.ERROR); alert.setTitle(Messages.errorGeneric); alert.setHeaderText(MessageFormat.format(Messages.failedGetSpecificFilter, filterId)); @@ -418,4 +411,16 @@ public void loadFilter(String filterId){ ExceptionDetailsErrorDialog.openError(resultTableView, Messages.errorGeneric, MessageFormat.format(Messages.failedGetSpecificFilter, filterId), e); } } + + @Override + public void handleWebSocketMessage(SaveAndRestoreWebSocketMessage saveAndRestoreWebSocketMessage) { + switch (saveAndRestoreWebSocketMessage.messageType()) { + case NODE_UPDATED, NODE_REMOVED, NODE_ADDED -> search(); + } + } + + public boolean handleTabClosed() { + webSocketClientService.removeWebSocketMessageHandler(this); + return true; + } } diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/BaseSnapshotTableViewController.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/BaseSnapshotTableViewController.java index edaa3f00d1..dd93dbd134 100644 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/BaseSnapshotTableViewController.java +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/BaseSnapshotTableViewController.java @@ -44,6 +44,7 @@ import org.phoebus.applications.saveandrestore.model.Snapshot; import org.phoebus.applications.saveandrestore.ui.VTypePair; import org.phoebus.core.types.TimeStampedProcessVariable; +import org.phoebus.framework.jobs.JobManager; import org.phoebus.framework.selection.SelectionService; import org.phoebus.ui.application.ContextMenuHelper; import org.phoebus.ui.javafx.FocusUtil; @@ -315,13 +316,15 @@ public void updateTable(List entries) { } protected void connectPVs() { - tableEntryItems.values().forEach(e -> { - SaveAndRestorePV pv = pvs.get(getPVKey(e.getConfigPv().getPvName(), e.getConfigPv().isReadOnly())); - if (pv == null) { - pvs.put(getPVKey(e.getConfigPv().getPvName(), e.getConfigPv().isReadOnly()), new SaveAndRestorePV(e)); - } else { - pv.setSnapshotTableEntry(e); - } + JobManager.schedule("Connect PVs", monitor -> { + tableEntryItems.values().forEach(e -> { + SaveAndRestorePV pv = pvs.get(getPVKey(e.getConfigPv().getPvName(), e.getConfigPv().isReadOnly())); + if (pv == null) { + pvs.put(getPVKey(e.getConfigPv().getPvName(), e.getConfigPv().isReadOnly()), new SaveAndRestorePV(e)); + } else { + pv.setSnapshotTableEntry(e); + } + }); }); } } diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/CompositeSnapshotController.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/CompositeSnapshotController.java index d1f87eeb7c..9eb5101bcf 100644 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/CompositeSnapshotController.java +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/CompositeSnapshotController.java @@ -58,10 +58,12 @@ import org.phoebus.applications.saveandrestore.model.Node; import org.phoebus.applications.saveandrestore.model.NodeType; import org.phoebus.applications.saveandrestore.model.Tag; +import org.phoebus.applications.saveandrestore.model.websocket.MessageType; +import org.phoebus.applications.saveandrestore.model.websocket.SaveAndRestoreWebSocketMessage; import org.phoebus.applications.saveandrestore.ui.ImageRepository; import org.phoebus.applications.saveandrestore.ui.SaveAndRestoreBaseController; import org.phoebus.applications.saveandrestore.ui.SaveAndRestoreController; -import org.phoebus.applications.saveandrestore.ui.SaveAndRestoreService; +import org.phoebus.applications.saveandrestore.ui.WebSocketMessageHandler; import org.phoebus.framework.jobs.JobManager; import org.phoebus.ui.dialog.DialogHelper; import org.phoebus.ui.dialog.ExceptionDetailsErrorDialog; @@ -78,7 +80,7 @@ import java.util.function.Consumer; import java.util.stream.Collectors; -public class CompositeSnapshotController extends SaveAndRestoreBaseController { +public class CompositeSnapshotController extends SaveAndRestoreBaseController implements WebSocketMessageHandler { @SuppressWarnings("unused") @FXML @@ -124,9 +126,7 @@ public class CompositeSnapshotController extends SaveAndRestoreBaseController { @FXML private Label createdByField; - private SaveAndRestoreService saveAndRestoreService; - - private final SimpleBooleanProperty dirty = new SimpleBooleanProperty(false); + private final SimpleBooleanProperty dirty = new SimpleBooleanProperty(); private final ObservableList snapshotEntries = FXCollections.observableArrayList(); @@ -143,12 +143,13 @@ public class CompositeSnapshotController extends SaveAndRestoreBaseController { private Node compositeSnapshotNode; - private final CompositeSnapshotTab compositeSnapshotTab; - private final SimpleBooleanProperty disabledUi = new SimpleBooleanProperty(false); private final SaveAndRestoreController saveAndRestoreController; + private final SimpleStringProperty tabTitleProperty = new SimpleStringProperty(Messages.unnamedCompositeSnapshot); + private final SimpleStringProperty tabIdProperty = new SimpleStringProperty(); + @SuppressWarnings("unused") @FXML private VBox progressIndicator; @@ -159,8 +160,9 @@ public class CompositeSnapshotController extends SaveAndRestoreBaseController { public CompositeSnapshotController(CompositeSnapshotTab compositeSnapshotTab, SaveAndRestoreController saveAndRestoreController) { - this.compositeSnapshotTab = compositeSnapshotTab; this.saveAndRestoreController = saveAndRestoreController; + compositeSnapshotTab.textProperty().bind(tabTitleProperty); + compositeSnapshotTab.idProperty().bind(tabIdProperty); } @FXML @@ -168,8 +170,6 @@ public void initialize() { snapshotTable.getStylesheets().add(CompareSnapshotsController.class.getResource("/save-and-restore-style.css").toExternalForm()); - saveAndRestoreService = SaveAndRestoreService.getInstance(); - snapshotTable.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); snapshotTable.getSelectionModel().selectedItemProperty().addListener((obs, ov, nv) -> selectionEmpty.set(nv == null)); @@ -230,7 +230,7 @@ public void updateItem(Node item, boolean empty) { snapshotTable.setContextMenu(contextMenu); snapshotTable.setOnContextMenuRequested(event -> { - if (snapshotTable.getSelectionModel().getSelectedItems().size() == 0) { + if (snapshotTable.getSelectionModel().getSelectedItems().isEmpty()) { contextMenu.hide(); event.consume(); } @@ -297,8 +297,8 @@ public void updateItem(Node item, boolean empty) { snapshotTable.setItems(snapshotEntries); - nodeNameChangeListener = (observableValue, oldValue, newValue) -> dirty.set(true); - descriptionChangeListener = (observableValue, oldValue, newValue) -> dirty.set(true); + nodeNameChangeListener = (observableValue, oldValue, newValue) -> dirty.setValue(true); + descriptionChangeListener = (observableValue, oldValue, newValue) -> dirty.setValue(true); saveButton.disableProperty().bind(Bindings.createBooleanBinding(() -> dirty.not().get() || compositeSnapshotDescriptionProperty.isEmpty().get() || @@ -330,10 +330,14 @@ public void updateItem(Node item, boolean empty) { disabledUi.addListener((observable, oldValue, newValue) -> borderPane.setDisable(newValue)); dirty.addListener((ob, o, n) -> { - if (dirty.get()) { - compositeSnapshotTab.annotateDirty(n); + if (n && !tabTitleProperty.get().startsWith("* ")) { + Platform.runLater(() -> tabTitleProperty.setValue("* " + tabTitleProperty.get())); + } else if (!n && tabTitleProperty.get().startsWith("* ")) { + Platform.runLater(() -> tabTitleProperty.setValue(tabTitleProperty.get().substring(2))); } }); + + webSocketClientService.addWebSocketMessageHandler(this); } @FXML @@ -358,12 +362,11 @@ private void doSave(Consumer completion) { if (compositeSnapshotNode.getUniqueId() == null) { // New composite snapshot compositeSnapshot = saveAndRestoreService.saveCompositeSnapshot(parentFolder, compositeSnapshot); - compositeSnapshotTab.setId(compositeSnapshot.getCompositeSnapshotNode().getUniqueId()); + tabIdProperty.setValue(compositeSnapshot.getCompositeSnapshotNode().getUniqueId()); } else { compositeSnapshotData.setUniqueId(compositeSnapshotNode.getUniqueId()); compositeSnapshot = saveAndRestoreService.updateCompositeSnapshot(compositeSnapshot); } - compositeSnapshotTab.setNodeName(compositeSnapshot.getCompositeSnapshotNode().getName()); dirty.set(false); completion.accept(compositeSnapshot); } catch (Exception e1) { @@ -390,12 +393,13 @@ public void loadCompositeSnapshot(final Node node, final List snapshotNode removeListeners(); JobManager.schedule("Load composite snapshot data", monitor -> { try { - snapshotEntries.clear(); List referencedNodes = saveAndRestoreService.getCompositeSnapshotNodes(compositeSnapshotNode.getUniqueId()); - snapshotEntries.addAll(referencedNodes); // Add change listener added only after the saved entries have been loaded. Collections.sort(snapshotEntries); Platform.runLater(() -> { + tabTitleProperty.setValue(node.getName()); + tabIdProperty.setValue(node.getUniqueId()); + snapshotEntries.setAll(referencedNodes); snapshotTable.setItems(snapshotEntries); compositeSnapshotNameProperty.set(compositeSnapshotNode.getName()); compositeSnapshotDescriptionProperty.set(compositeSnapshotNode.getDescription()); @@ -404,9 +408,8 @@ public void loadCompositeSnapshot(final Node node, final List snapshotNode lastUpdatedProperty.set(compositeSnapshotNode.getLastModified() != null ? TimestampFormats.SECONDS_FORMAT.format(Instant.ofEpochMilli(compositeSnapshotNode.getLastModified().getTime())) : null); createdByProperty.set(compositeSnapshotNode.getUserName()); - addToCompositeSnapshot(snapshotNodes); addListeners(); - + dirty.setValue(false); }); } catch (Exception e) { ExceptionDetailsErrorDialog.openError(root, Messages.errorGeneric, Messages.errorUnableToRetrieveData, e); @@ -416,15 +419,18 @@ public void loadCompositeSnapshot(final Node node, final List snapshotNode }); } - public boolean handleCompositeSnapshotTabClosed() { + @Override + public boolean handleTabClosed() { if (dirty.get()) { Alert alert = new Alert(Alert.AlertType.CONFIRMATION); alert.setTitle(Messages.closeTabPrompt); alert.setContentText(Messages.closeCompositeSnapshotWarning); Optional result = alert.showAndWait(); return result.isPresent() && result.get().equals(ButtonType.OK); + } else { + webSocketClientService.removeWebSocketMessageHandler(this); + return true; } - return true; } /** @@ -442,7 +448,6 @@ public void newCompositeSnapshot(Node parentNode, List snapshotNodes) { dirty.set(false); } else { dirty.set(true); - //snapshotEntries.addAll(snapshotNodes); addToCompositeSnapshot(snapshotNodes); } addListeners(); @@ -470,7 +475,7 @@ public void addToCompositeSnapshot(List sourceNodes) { JobManager.schedule("Check snapshot PV duplicates", monitor -> { disabledUi.set(true); List allSnapshotIds = snapshotEntries.stream().map(Node::getUniqueId).collect(Collectors.toList()); - allSnapshotIds.addAll(sourceNodes.stream().map(Node::getUniqueId).collect(Collectors.toList())); + allSnapshotIds.addAll(sourceNodes.stream().map(Node::getUniqueId).toList()); List duplicates = null; try { duplicates = saveAndRestoreService.checkCompositeSnapshotConsistency(allSnapshotIds); @@ -533,4 +538,14 @@ private void removeListeners() { compositeSnapshotNameProperty.removeListener(nodeNameChangeListener); compositeSnapshotDescriptionProperty.removeListener(descriptionChangeListener); } + + @Override + public void handleWebSocketMessage(SaveAndRestoreWebSocketMessage saveAndRestoreWebSocketMessage) { + if (saveAndRestoreWebSocketMessage.messageType().equals(MessageType.NODE_UPDATED)) { + Node node = (Node) saveAndRestoreWebSocketMessage.payload(); + if (tabIdProperty.get() != null && node.getUniqueId().equals(tabIdProperty.get())) { + loadCompositeSnapshot(node, Collections.emptyList()); + } + } + } } diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/CompositeSnapshotTab.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/CompositeSnapshotTab.java index f6a18175f3..beb39383e6 100644 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/CompositeSnapshotTab.java +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/CompositeSnapshotTab.java @@ -20,14 +20,16 @@ package org.phoebus.applications.saveandrestore.ui.snapshot; import javafx.application.Platform; -import javafx.beans.property.SimpleStringProperty; import javafx.fxml.FXMLLoader; import javafx.scene.image.ImageView; import org.phoebus.applications.saveandrestore.Messages; import org.phoebus.applications.saveandrestore.model.Node; +import org.phoebus.applications.saveandrestore.model.websocket.MessageType; +import org.phoebus.applications.saveandrestore.model.websocket.SaveAndRestoreWebSocketMessage; import org.phoebus.applications.saveandrestore.ui.ImageRepository; import org.phoebus.applications.saveandrestore.ui.SaveAndRestoreController; import org.phoebus.applications.saveandrestore.ui.SaveAndRestoreTab; +import org.phoebus.applications.saveandrestore.ui.WebSocketMessageHandler; import org.phoebus.framework.nls.NLS; import org.phoebus.ui.dialog.ExceptionDetailsErrorDialog; @@ -46,18 +48,11 @@ * {@link SnapshotTab} is used to show actual snapshot data. *

    */ -public class CompositeSnapshotTab extends SaveAndRestoreTab { +public class CompositeSnapshotTab extends SaveAndRestoreTab implements WebSocketMessageHandler { - private final SimpleStringProperty tabTitleProperty = new SimpleStringProperty(Messages.contextMenuNewCompositeSnapshot); - - private final SaveAndRestoreController saveAndRestoreController; public CompositeSnapshotTab(SaveAndRestoreController saveAndRestoreController) { - this.saveAndRestoreController = saveAndRestoreController; - configure(); - } - private void configure() { ResourceBundle resourceBundle = NLS.getMessages(Messages.class); FXMLLoader loader = new FXMLLoader(); loader.setResources(resourceBundle); @@ -89,28 +84,10 @@ private void configure() { setContent(rootNode); setGraphic(new ImageView(ImageRepository.COMPOSITE_SNAPSHOT)); - textProperty().bind(tabTitleProperty); - - setOnCloseRequest(event -> { - if (!((CompositeSnapshotController) controller).handleCompositeSnapshotTabClosed()) { - event.consume(); - } - }); } - public void setNodeName(String nodeName) { - Platform.runLater(() -> tabTitleProperty.set("[" + Messages.Edit + "] " + nodeName)); - } - - public void annotateDirty(boolean dirty) { - String tabTitle = tabTitleProperty.get(); - if (dirty) { - Platform.runLater(() -> tabTitleProperty.set("* " + tabTitle)); - } - } public void configureForNewCompositeSnapshot(Node parentNode, List snapshotNodes) { - tabTitleProperty.set(Messages.contextMenuNewCompositeSnapshot); ((CompositeSnapshotController) controller).newCompositeSnapshot(parentNode, snapshotNodes); } @@ -122,8 +99,6 @@ public void configureForNewCompositeSnapshot(Node parentNode, List snapsho * be added to the list of references snapshots. */ public void editCompositeSnapshot(Node compositeSnapshotNode, List snapshotNodes) { - setId("edit_" + compositeSnapshotNode.getUniqueId()); - setNodeName(compositeSnapshotNode.getName()); ((CompositeSnapshotController) controller).loadCompositeSnapshot(compositeSnapshotNode, snapshotNodes); } @@ -138,7 +113,13 @@ public void addToCompositeSnapshot(List snapshotNodes) { } @Override - public void nodeChanged(Node node) { - + public void handleWebSocketMessage(SaveAndRestoreWebSocketMessage saveAndRestoreWebSocketMessage) { + if (saveAndRestoreWebSocketMessage.messageType().equals(MessageType.NODE_REMOVED)) { + String nodeId = (String) saveAndRestoreWebSocketMessage.payload(); + if (getId() != null && nodeId.equals(getId())) { + Platform.runLater(() -> getTabPane().getTabs().remove(this)); + } + } } + } diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/SnapshotController.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/SnapshotController.java index bd47d6b792..ef725bda58 100644 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/SnapshotController.java +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/SnapshotController.java @@ -6,10 +6,13 @@ import javafx.application.Platform; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.scene.control.Alert; import javafx.scene.control.ButtonType; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; import javafx.scene.layout.BorderPane; import javafx.scene.layout.VBox; import org.epics.vtype.Alarm; @@ -30,12 +33,17 @@ import org.phoebus.applications.saveandrestore.model.Snapshot; import org.phoebus.applications.saveandrestore.model.SnapshotData; import org.phoebus.applications.saveandrestore.model.SnapshotItem; +import org.phoebus.applications.saveandrestore.model.Tag; import org.phoebus.applications.saveandrestore.model.event.SaveAndRestoreEventReceiver; +import org.phoebus.applications.saveandrestore.model.websocket.MessageType; +import org.phoebus.applications.saveandrestore.model.websocket.SaveAndRestoreWebSocketMessage; +import org.phoebus.applications.saveandrestore.ui.ImageRepository; import org.phoebus.applications.saveandrestore.ui.SaveAndRestoreBaseController; import org.phoebus.applications.saveandrestore.ui.SaveAndRestoreService; import org.phoebus.applications.saveandrestore.ui.SnapshotMode; -import org.phoebus.saveandrestore.util.VNoData; +import org.phoebus.applications.saveandrestore.ui.WebSocketMessageHandler; import org.phoebus.framework.jobs.JobManager; +import org.phoebus.saveandrestore.util.VNoData; import org.phoebus.security.tokens.ScopedAuthenticationToken; import org.phoebus.ui.dialog.DialogHelper; import org.phoebus.ui.dialog.ExceptionDetailsErrorDialog; @@ -56,7 +64,7 @@ * Once the snapshot has been saved, this controller calls the {@link SnapshotTab} API to load * the view associated with restore actions. */ -public class SnapshotController extends SaveAndRestoreBaseController { +public class SnapshotController extends SaveAndRestoreBaseController implements WebSocketMessageHandler { @SuppressWarnings("unused") @@ -71,6 +79,11 @@ public class SnapshotController extends SaveAndRestoreBaseController { protected ServiceLoader eventReceivers; + private final SimpleStringProperty tabTitleProperty = new SimpleStringProperty(); + private final SimpleStringProperty tabIdProperty = new SimpleStringProperty(); + + private final SimpleObjectProperty tabGraphicImageProperty = new SimpleObjectProperty<>(); + @FXML protected VBox progressIndicator; @@ -78,6 +91,11 @@ public class SnapshotController extends SaveAndRestoreBaseController { public SnapshotController(SnapshotTab snapshotTab) { this.snapshotTab = snapshotTab; + snapshotTab.textProperty().bind(tabTitleProperty); + snapshotTab.idProperty().bind(tabIdProperty); + ImageView imageView = new ImageView(); + imageView.imageProperty().bind(tabGraphicImageProperty); + snapshotTab.setGraphic(imageView); } /** @@ -111,6 +129,16 @@ public void initialize() { snapshotTableViewController.showSnapshotInTable(n); } }); + + snapshotControlsViewController.snapshotDataDirty.addListener((obs, o, n) -> { + if (n && !tabTitleProperty.get().startsWith("* ")) { + Platform.runLater(() -> tabTitleProperty.setValue("* " + tabTitleProperty.get())); + } else if (!n && tabTitleProperty.get().startsWith("* ")) { + Platform.runLater(() -> tabTitleProperty.setValue(tabTitleProperty.get().substring(2))); + } + }); + + webSocketClientService.addWebSocketMessageHandler(this); } /** @@ -121,7 +149,8 @@ public void initialize() { */ public void initializeViewForNewSnapshot(Node configurationNode) { this.configurationNode = configurationNode; - snapshotTab.updateTabTitle(Messages.unnamedSnapshot); + tabTitleProperty.setValue(Messages.unnamedSnapshot); + tabIdProperty.setValue(null); JobManager.schedule("Get configuration", monitor -> { ConfigurationData configuration; try { @@ -138,6 +167,7 @@ public void initializeViewForNewSnapshot(Node configurationNode) { snapshotData.setSnapshotItems(configurationToSnapshotItems(configPvs)); snapshot.setSnapshotData(snapshotData); snapshotProperty.set(snapshot); + setTabImage(snapshot.getSnapshotNode()); Platform.runLater(() -> snapshotTableViewController.showSnapshotInTable(snapshot)); }); } @@ -146,7 +176,7 @@ public void initializeViewForNewSnapshot(Node configurationNode) { @SuppressWarnings("unused") public void takeSnapshot() { disabledUi.set(true); - snapshotTab.setText(Messages.unnamedSnapshot); + tabTitleProperty.setValue(Messages.unnamedSnapshot); snapshotTableViewController.takeSnapshot(snapshotControlsViewController.getDefaultSnapshotMode(), snapshot -> { disabledUi.set(false); if (snapshot.isPresent()) { @@ -165,30 +195,24 @@ public void saveSnapshot(ActionEvent actionEvent) { SnapshotData snapshotData = new SnapshotData(); snapshotData.setSnapshotItems(snapshotItems); Snapshot snapshot = snapshotProperty.get(); - // Creating new or updating existing (e.g. name change)? - if (snapshot == null) { - snapshot = new Snapshot(); - snapshot.setSnapshotNode(Node.builder().nodeType(NodeType.SNAPSHOT) - .name(snapshotControlsViewController.getSnapshotNameProperty().get()) - .description(snapshotControlsViewController.getSnapshotCommentProperty().get()).build()); - } else { - snapshot.getSnapshotNode().setName(snapshotControlsViewController.getSnapshotNameProperty().get()); - snapshot.getSnapshotNode().setDescription(snapshotControlsViewController.getSnapshotCommentProperty().get()); - } - snapshot.setSnapshotData(snapshotData); + Node snapshotNode = + Node.builder() + .nodeType(NodeType.SNAPSHOT) + .name(snapshotControlsViewController.getSnapshotNameProperty().get()) + .description(snapshotControlsViewController.getSnapshotCommentProperty().get()) + .uniqueId(tabIdProperty.get()) + .build(); + snapshot.setSnapshotNode(snapshotNode); try { - snapshot = SaveAndRestoreService.getInstance().saveSnapshot(configurationNode, snapshot); + Snapshot _snapshot = SaveAndRestoreService.getInstance().saveSnapshot(configurationNode, snapshot); snapshotProperty.set(snapshot); Node _snapshotNode = snapshot.getSnapshotNode(); if (snapshotControlsViewController.logAction()) { eventReceivers.forEach(r -> r.snapshotSaved(_snapshotNode, this::showLoggingError)); } snapshotControlsViewController.snapshotDataDirty.set(false); - Platform.runLater(() -> { - // Load snapshot via the tab as that will also update the tab title and id. - snapshotTab.loadSnapshot(_snapshotNode); - }); + Platform.runLater(() -> loadSnapshot(_snapshot.getSnapshotNode())); } catch (Exception e) { LOGGER.log(Level.SEVERE, "Failed to save snapshot", e); Platform.runLater(() -> { @@ -249,6 +273,13 @@ public void updateLoadedSnapshot(TableEntry rowValue, VType newValue) { }); } + /** + * Handles clean-up when the associated {@link SnapshotTab} is closed. + * A check is made if content is dirty, in which case user is prompted to cancel or close anyway. + * + * @return true if content is not dirty or user chooses to close anyway, + * otherwise false. + */ public boolean handleSnapshotTabClosed() { if (snapshotControlsViewController.snapshotDataDirty.get()) { Alert alert = new Alert(Alert.AlertType.CONFIRMATION); @@ -256,12 +287,12 @@ public boolean handleSnapshotTabClosed() { alert.setContentText(Messages.promptCloseSnapshotTabContent); DialogHelper.positionDialog(alert, borderPane, -150, -150); Optional result = alert.showAndWait(); - if (result.isPresent() && result.get().equals(ButtonType.CANCEL)) { - return false; - } + return result.isPresent() && result.get().equals(ButtonType.OK); + } else { + webSocketClientService.removeWebSocketMessageHandler(this); + dispose(); + return true; } - dispose(); - return true; } /** @@ -295,10 +326,6 @@ public Node getConfigurationNode() { return configurationNode; } - public void setSnapshotNameProperty(String name) { - snapshotControlsViewController.getSnapshotNameProperty().set(name); - } - /** * Updates snapshot set-point values with user defined multiplier. Note that the stored snapshot * is not affected, only the values shown in the snapshot view. The updated value is used when @@ -332,17 +359,19 @@ public void applyHideEqualItems() { private void loadSnapshotInternal(Node snapshotNode) { - Platform.runLater(() -> disabledUi.set(true)); + disabledUi.set(true); JobManager.schedule("Load snapshot items", monitor -> { try { Snapshot snapshot = getSnapshotFromService(snapshotNode); snapshotProperty.set(snapshot); Platform.runLater(() -> { - //snapshotTableViewController.showSnapshotInTable(snapshot); + tabTitleProperty.setValue(snapshotNode.getName()); + tabIdProperty.setValue(snapshotNode.getUniqueId()); snapshotControlsViewController.getSnapshotRestorableProperty().set(true); + setTabImage(snapshotNode); }); } finally { - Platform.runLater(() -> disabledUi.set(false)); + disabledUi.set(false); } }); } @@ -360,15 +389,14 @@ public void loadSnapshot(Node snapshotNode) { loadSnapshotInternal(snapshotNode); } - public void restore(ActionEvent actionEvent) { + public void restore() { snapshotTableViewController.restore(snapshotControlsViewController.getRestoreMode(), snapshotProperty.get(), restoreResultList -> { if (snapshotControlsViewController.logAction()) { eventReceivers.forEach(r -> r.snapshotRestored(snapshotProperty.get().getSnapshotNode(), restoreResultList, this::showLoggingError)); } if (restoreResultList != null && !restoreResultList.isEmpty()) { showAndLogFailedRestoreResult(snapshotProperty.get(), restoreResultList); - } - else{ + } else { LOGGER.log(Level.INFO, "Successfully restored snapshot \"" + snapshotProperty.get().getSnapshotNode().getName() + "\""); } }); @@ -441,7 +469,7 @@ private Snapshot getSnapshotFromService(Node snapshotNode) throws Exception { snapshotData.setSnapshotItems(snapshotItems); } } catch (Exception e) { - ExceptionDetailsErrorDialog.openError(snapshotTab.getContent(), Messages.errorGeneric, Messages.errorUnableToRetrieveData, e); + ExceptionDetailsErrorDialog.openError(borderPane, Messages.errorGeneric, Messages.errorUnableToRetrieveData, e); LOGGER.log(Level.INFO, "Error loading snapshot", e); throw e; } @@ -455,4 +483,32 @@ private Snapshot getSnapshotFromService(Node snapshotNode) throws Exception { public void secureStoreChanged(List validTokens) { snapshotControlsViewController.secureStoreChanged(validTokens); } + + @Override + public void handleWebSocketMessage(SaveAndRestoreWebSocketMessage saveAndRestoreWebSocketMessage) { + if (saveAndRestoreWebSocketMessage.messageType().equals(MessageType.NODE_UPDATED)) { + Node node = (Node) saveAndRestoreWebSocketMessage.payload(); + if (tabIdProperty.get() != null && node.getUniqueId().equals(tabIdProperty.get())) { + loadSnapshot(node); + } + } + } + + /** + * Set tab image based on node type, and optionally golden tag + * + * @param node A snapshot {@link Node} + */ + private void setTabImage(Node node) { + if (node.getNodeType().equals(NodeType.COMPOSITE_SNAPSHOT)) { + tabGraphicImageProperty.set(ImageRepository.COMPOSITE_SNAPSHOT); + } else { + boolean golden = node.getTags() != null && node.getTags().stream().anyMatch(t -> t.getName().equals(Tag.GOLDEN)); + if (golden) { + tabGraphicImageProperty.set(ImageRepository.GOLDEN_SNAPSHOT); + } else { + tabGraphicImageProperty.set(ImageRepository.SNAPSHOT); + } + } + } } \ No newline at end of file diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/SnapshotControlsViewController.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/SnapshotControlsViewController.java index 0c9b1d2776..f4e3ba338a 100644 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/SnapshotControlsViewController.java +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/SnapshotControlsViewController.java @@ -193,7 +193,6 @@ public void initialize() { saveSnapshotButton.disableProperty().bind(Bindings.createBooleanBinding(() -> // TODO: support save (=update) a composite snapshot from the snapshot view. In the meanwhile, disable save button. snapshotNodeProperty.isNull().get() || - snapshotNodeProperty.get().getNodeType().equals(NodeType.COMPOSITE_SNAPSHOT) || snapshotDataDirty.not().get() || snapshotNameProperty.isEmpty().get() || snapshotCommentProperty.isEmpty().get() || @@ -368,8 +367,8 @@ public void saveSnapshot(ActionEvent event) { } @FXML - public void restore(ActionEvent event) { - snapshotController.restore(event); + public void restore() { + snapshotController.restore(); } public SimpleBooleanProperty getSnapshotRestorableProperty() { diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/SnapshotTab.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/SnapshotTab.java index cf2d8e794d..948a7c2603 100644 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/SnapshotTab.java +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/SnapshotTab.java @@ -18,7 +18,6 @@ package org.phoebus.applications.saveandrestore.ui.snapshot; import javafx.application.Platform; -import javafx.beans.property.SimpleObjectProperty; import javafx.fxml.FXMLLoader; import javafx.scene.control.MenuItem; import javafx.scene.control.Tab; @@ -28,11 +27,12 @@ import org.phoebus.applications.saveandrestore.model.Node; import org.phoebus.applications.saveandrestore.model.NodeType; import org.phoebus.applications.saveandrestore.model.Snapshot; -import org.phoebus.applications.saveandrestore.model.Tag; -import org.phoebus.applications.saveandrestore.ui.ImageRepository; +import org.phoebus.applications.saveandrestore.model.websocket.MessageType; +import org.phoebus.applications.saveandrestore.model.websocket.SaveAndRestoreWebSocketMessage; import org.phoebus.applications.saveandrestore.ui.SaveAndRestoreController; import org.phoebus.applications.saveandrestore.ui.SaveAndRestoreService; import org.phoebus.applications.saveandrestore.ui.SaveAndRestoreTab; +import org.phoebus.applications.saveandrestore.ui.WebSocketMessageHandler; import org.phoebus.framework.nls.NLS; import org.phoebus.ui.dialog.ExceptionDetailsErrorDialog; import org.phoebus.ui.javafx.ImageCache; @@ -51,12 +51,10 @@ * Note that this class is used also to show the snapshot view for {@link Node}s of type {@link NodeType#COMPOSITE_SNAPSHOT}. *

    */ -public class SnapshotTab extends SaveAndRestoreTab { +public class SnapshotTab extends SaveAndRestoreTab implements WebSocketMessageHandler { public SaveAndRestoreService saveAndRestoreService; - private final SimpleObjectProperty tabGraphicImageProperty = new SimpleObjectProperty<>(); - protected Image compareSnapshotIcon = ImageCache.getImage(SaveAndRestoreController.class, "/icons/save-and-restore/compare.png"); public SnapshotTab(org.phoebus.applications.saveandrestore.model.Node node, SaveAndRestoreService saveAndRestoreService) { @@ -98,22 +96,6 @@ public SnapshotTab(org.phoebus.applications.saveandrestore.model.Node node, Save return; } - ImageView imageView = new ImageView(); - imageView.imageProperty().bind(tabGraphicImageProperty); - - setGraphic(imageView); - - textProperty().set(node.getNodeType().equals(NodeType.SNAPSHOT) ? node.getName() : Messages.unnamedSnapshot); - setTabImage(node); - - setOnCloseRequest(event -> { - if (controller != null && !((SnapshotController) controller).handleSnapshotTabClosed()) { - event.consume(); - } else { - SaveAndRestoreService.getInstance().removeNodeChangeListener(this); - } - }); - MenuItem compareSnapshotToArchiverDataMenuItem = new MenuItem(Messages.contextMenuCompareSnapshotWithArchiverData, new ImageView(compareSnapshotIcon)); compareSnapshotToArchiverDataMenuItem.setOnAction(ae -> addSnapshotFromArchive()); @@ -125,30 +107,6 @@ public SnapshotTab(org.phoebus.applications.saveandrestore.model.Node node, Save compareSnapshotToArchiverDataMenuItem.disableProperty().set(snapshot.getSnapshotNode().getUniqueId() == null); }); getContextMenu().getItems().add(compareSnapshotToArchiverDataMenuItem); - - SaveAndRestoreService.getInstance().addNodeChangeListener(this); - } - - public void updateTabTitle(String name) { - Platform.runLater(() -> textProperty().set(name)); - } - - /** - * Set tab image based on node type, and optionally golden tag - * - * @param node A snapshot {@link Node} - */ - private void setTabImage(Node node) { - if (node.getNodeType().equals(NodeType.COMPOSITE_SNAPSHOT)) { - tabGraphicImageProperty.set(ImageRepository.COMPOSITE_SNAPSHOT); - } else { - boolean golden = node.getTags() != null && node.getTags().stream().anyMatch(t -> t.getName().equals(Tag.GOLDEN)); - if (golden) { - tabGraphicImageProperty.set(ImageRepository.GOLDEN_SNAPSHOT); - } else { - tabGraphicImageProperty.set(ImageRepository.SNAPSHOT); - } - } } /** @@ -158,7 +116,6 @@ private void setTabImage(Node node) { * a snapshot will be created. */ public void newSnapshot(org.phoebus.applications.saveandrestore.model.Node configurationNode) { - setId(null); ((SnapshotController) controller).initializeViewForNewSnapshot(configurationNode); } @@ -168,8 +125,6 @@ public void newSnapshot(org.phoebus.applications.saveandrestore.model.Node confi * @param snapshotNode The {@link Node} of type {@link NodeType#SNAPSHOT} containing snapshot data. */ public void loadSnapshot(Node snapshotNode) { - updateTabTitle(snapshotNode.getName()); - setId(snapshotNode.getUniqueId()); ((SnapshotController) controller).loadSnapshot(snapshotNode); } @@ -181,16 +136,6 @@ private void addSnapshotFromArchive() { ((SnapshotController) controller).addSnapshotFromArchiver(); } - @Override - public void nodeChanged(Node node) { - if (node.getUniqueId().equals(getId())) { - Platform.runLater(() -> { - ((SnapshotController) controller).setSnapshotNameProperty(node.getName()); - setTabImage(node); - }); - } - } - public Node getSnapshotNode() { return ((SnapshotController) controller).getSnapshot().getSnapshotNode(); } @@ -199,4 +144,13 @@ public Node getConfigNode() { return ((SnapshotController) controller).getConfigurationNode(); } + @Override + public void handleWebSocketMessage(SaveAndRestoreWebSocketMessage saveAndRestoreWebSocketMessage) { + if (saveAndRestoreWebSocketMessage.messageType().equals(MessageType.NODE_REMOVED)) { + String nodeId = (String) saveAndRestoreWebSocketMessage.payload(); + if (getId() != null && nodeId.equals(getId())) { + Platform.runLater(() -> getTabPane().getTabs().remove(this)); + } + } + } } diff --git a/app/save-and-restore/app/src/main/resources/org/phoebus/applications/saveandrestore/messages.properties b/app/save-and-restore/app/src/main/resources/org/phoebus/applications/saveandrestore/messages.properties index 4936a88f4c..18ef48875a 100644 --- a/app/save-and-restore/app/src/main/resources/org/phoebus/applications/saveandrestore/messages.properties +++ b/app/save-and-restore/app/src/main/resources/org/phoebus/applications/saveandrestore/messages.properties @@ -262,10 +262,14 @@ toolTipConfigurationExists=Configuration name already exists in the folder! toolTipConfigurationExistsOption=Choose it with Browse if you want to add PVs in existing configuration. toolTipMultiplierSpinner=This field only takes number. UniqueId=Unique ID +unnamedConfiguration= +unnamedCompositeSnapshot= unnamedSnapshot= updateConfigurationFailed=Failed to update configuration updateCompositeSnapshotFailed=Failed to update composite snapshot updateNodeFailed=Failed to update node +webSocketConnected=Web Socket Connected +webSocketDisconnected=Web Socket Disconnected diff --git a/app/save-and-restore/app/src/main/resources/org/phoebus/applications/saveandrestore/ui/SaveAndRestoreUI.fxml b/app/save-and-restore/app/src/main/resources/org/phoebus/applications/saveandrestore/ui/SaveAndRestoreUI.fxml index 2de20f3770..3608b13d1f 100644 --- a/app/save-and-restore/app/src/main/resources/org/phoebus/applications/saveandrestore/ui/SaveAndRestoreUI.fxml +++ b/app/save-and-restore/app/src/main/resources/org/phoebus/applications/saveandrestore/ui/SaveAndRestoreUI.fxml @@ -39,14 +39,9 @@ - - - - - diff --git a/app/save-and-restore/model/src/main/java/org/phoebus/applications/saveandrestore/model/websocket/MessageType.java b/app/save-and-restore/model/src/main/java/org/phoebus/applications/saveandrestore/model/websocket/MessageType.java new file mode 100644 index 0000000000..9cc99875bd --- /dev/null +++ b/app/save-and-restore/model/src/main/java/org/phoebus/applications/saveandrestore/model/websocket/MessageType.java @@ -0,0 +1,16 @@ +/* + * Copyright (C) 2024 European Spallation Source ERIC. + */ + +package org.phoebus.applications.saveandrestore.model.websocket; + +/** + * Enum to indicate what type of web socket message the service is sending to clients. + */ +public enum MessageType { + NODE_ADDED, + NODE_UPDATED, + NODE_REMOVED, + FILTER_ADDED_OR_UPDATED, + FILTER_REMOVED +} diff --git a/app/save-and-restore/model/src/main/java/org/phoebus/applications/saveandrestore/model/websocket/SaveAndRestoreWebSocketMessage.java b/app/save-and-restore/model/src/main/java/org/phoebus/applications/saveandrestore/model/websocket/SaveAndRestoreWebSocketMessage.java new file mode 100644 index 0000000000..30e6a7d6d7 --- /dev/null +++ b/app/save-and-restore/model/src/main/java/org/phoebus/applications/saveandrestore/model/websocket/SaveAndRestoreWebSocketMessage.java @@ -0,0 +1,13 @@ +/* + * Copyright (C) 2024 European Spallation Source ERIC. + */ + +package org.phoebus.applications.saveandrestore.model.websocket; + +/** + * Record encapsulating a {@link MessageType} and a payload. + * @param messageType The {@link MessageType} of a web socket message + * @param payload The payload, e.g. {@link String} or {@link org.phoebus.applications.saveandrestore.model.Node} + */ +public record SaveAndRestoreWebSocketMessage(MessageType messageType, T payload) { +} diff --git a/app/save-and-restore/model/src/main/java/org/phoebus/applications/saveandrestore/model/websocket/WebMessageDeserializer.java b/app/save-and-restore/model/src/main/java/org/phoebus/applications/saveandrestore/model/websocket/WebMessageDeserializer.java new file mode 100644 index 0000000000..3e636cbf5a --- /dev/null +++ b/app/save-and-restore/model/src/main/java/org/phoebus/applications/saveandrestore/model/websocket/WebMessageDeserializer.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2025 European Spallation Source ERIC. + */ + +package org.phoebus.applications.saveandrestore.model.websocket; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import org.phoebus.applications.saveandrestore.model.Node; +import org.phoebus.applications.saveandrestore.model.search.Filter; + +/** + * Custom JSON deserializer of {@link SaveAndRestoreWebSocketMessage}s. + */ +public class WebMessageDeserializer extends StdDeserializer { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + public WebMessageDeserializer(Class clazz) { + super(clazz); + } + + /** + * Deserializes a {@link SaveAndRestoreWebSocketMessage}- + * + * @param jsonParser Parsed used for reading JSON content + * @param context Context that can be used to access information about + * this deserialization activity. + * @return A {@link SaveAndRestoreWebSocketMessage} object, or null if deserialization fails. + */ + @Override + public SaveAndRestoreWebSocketMessage deserialize(JsonParser jsonParser, + DeserializationContext context) { + try { + JsonNode rootNode = jsonParser.getCodec().readTree(jsonParser); + String messageType = rootNode.get("messageType").asText(); + switch (MessageType.valueOf(messageType)) { + case NODE_ADDED, NODE_REMOVED, FILTER_REMOVED-> { + SaveAndRestoreWebSocketMessage saveAndRestoreWebSocketMessage = + objectMapper.readValue(rootNode.toString(), SaveAndRestoreWebSocketMessage.class); + return saveAndRestoreWebSocketMessage; + } + case NODE_UPDATED -> { + SaveAndRestoreWebSocketMessage saveAndRestoreWebSocketMessage = objectMapper.readValue(rootNode.toString(), new TypeReference<>() { + }); + return saveAndRestoreWebSocketMessage; + } + case FILTER_ADDED_OR_UPDATED -> { + SaveAndRestoreWebSocketMessage saveAndRestoreWebSocketMessage = objectMapper.readValue(rootNode.toString(), new TypeReference<>() { + }); + return saveAndRestoreWebSocketMessage; + } + } + } catch (Exception e) { + return null; + } + return null; + } + +} + diff --git a/app/save-and-restore/model/src/test/java/org/phoebus/applications/saveandrestore/model/websocket/SaveAndRestoreWebSocketMessageDeserializerTest.java b/app/save-and-restore/model/src/test/java/org/phoebus/applications/saveandrestore/model/websocket/SaveAndRestoreWebSocketMessageDeserializerTest.java new file mode 100644 index 0000000000..0e60a5eb07 --- /dev/null +++ b/app/save-and-restore/model/src/test/java/org/phoebus/applications/saveandrestore/model/websocket/SaveAndRestoreWebSocketMessageDeserializerTest.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2025 European Spallation Source ERIC. + */ + +package org.phoebus.applications.saveandrestore.model.websocket; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import org.junit.jupiter.api.Test; +import org.phoebus.applications.saveandrestore.model.Node; +import org.phoebus.applications.saveandrestore.model.search.Filter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class SaveAndRestoreWebSocketMessageDeserializerTest { + + private ObjectMapper mapper = new ObjectMapper(); + + public SaveAndRestoreWebSocketMessageDeserializerTest(){ + SimpleModule module = new SimpleModule(); + module.addDeserializer(SaveAndRestoreWebSocketMessage.class, + new WebMessageDeserializer(SaveAndRestoreWebSocketMessage.class)); + mapper.registerModule(module); + } + + @Test + public void test1() { + try { + SaveAndRestoreWebSocketMessage webSocketMessage = + mapper.readValue(getClass().getResourceAsStream("/websocketexample2.json"), new TypeReference<>() { + }); + assertEquals(MessageType.NODE_UPDATED, webSocketMessage.messageType()); + assertEquals("a", webSocketMessage.payload().getUniqueId()); + } catch (Exception e) { + throw new RuntimeException(e); + } + + } + + @Test + public void test2() { + try { + SaveAndRestoreWebSocketMessage webSocketMessage = + mapper.readValue(getClass().getResourceAsStream("/websocketexample1.json"), new TypeReference<>() { + }); + assertEquals(MessageType.NODE_ADDED, webSocketMessage.messageType()); + assertEquals("parentNodeId", webSocketMessage.payload()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + public void test3() { + try { + SaveAndRestoreWebSocketMessage webSocketMessage = + mapper.readValue(getClass().getResourceAsStream("/websocketexample3.json"), new TypeReference<>() { + }); + assertEquals(MessageType.FILTER_ADDED_OR_UPDATED, webSocketMessage.messageType()); + assertEquals("myFilter", webSocketMessage.payload().getName()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + public void test4() { + try { + SaveAndRestoreWebSocketMessage webSocketMessage = + mapper.readValue(getClass().getResourceAsStream("/websocketexample4.json"), new TypeReference<>() { + }); + assertNull(webSocketMessage); + } + catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + public void test5() { + try { + SaveAndRestoreWebSocketMessage webSocketMessage = + mapper.readValue(getClass().getResourceAsStream("/websocketexample5.json"), new TypeReference<>() { + }); + assertNull(webSocketMessage.payload()); + } + catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/app/save-and-restore/model/src/test/resources/websocketexample1.json b/app/save-and-restore/model/src/test/resources/websocketexample1.json new file mode 100644 index 0000000000..ed9ef5460b --- /dev/null +++ b/app/save-and-restore/model/src/test/resources/websocketexample1.json @@ -0,0 +1,4 @@ +{ + "messageType": "NODE_ADDED", + "payload": "parentNodeId" +} \ No newline at end of file diff --git a/app/save-and-restore/model/src/test/resources/websocketexample2.json b/app/save-and-restore/model/src/test/resources/websocketexample2.json new file mode 100644 index 0000000000..edee801d5d --- /dev/null +++ b/app/save-and-restore/model/src/test/resources/websocketexample2.json @@ -0,0 +1,9 @@ +{ + "messageType": "NODE_UPDATED", + "payload": { + "uniqueId": "a", + "name" : "myNode", + "nodeType": "CONFIGURATION", + "created": 1654690762473 + } +} \ No newline at end of file diff --git a/app/save-and-restore/model/src/test/resources/websocketexample3.json b/app/save-and-restore/model/src/test/resources/websocketexample3.json new file mode 100644 index 0000000000..698641f01e --- /dev/null +++ b/app/save-and-restore/model/src/test/resources/websocketexample3.json @@ -0,0 +1,8 @@ +{ + "messageType": "FILTER_ADDED_OR_UPDATED", + "payload": { + "name": "myFilter", + "queryString": "a=b&c=d", + "user": "John Doe" + } +} \ No newline at end of file diff --git a/app/save-and-restore/model/src/test/resources/websocketexample4.json b/app/save-and-restore/model/src/test/resources/websocketexample4.json new file mode 100644 index 0000000000..b0ec835bdc --- /dev/null +++ b/app/save-and-restore/model/src/test/resources/websocketexample4.json @@ -0,0 +1,4 @@ +{ + "messageType": "INVALID", + "payload": "parentNodeId" +} \ No newline at end of file diff --git a/app/save-and-restore/model/src/test/resources/websocketexample5.json b/app/save-and-restore/model/src/test/resources/websocketexample5.json new file mode 100644 index 0000000000..0e09667bf6 --- /dev/null +++ b/app/save-and-restore/model/src/test/resources/websocketexample5.json @@ -0,0 +1,3 @@ +{ + "messageType": "NODE_UPDATED" +} \ No newline at end of file diff --git a/core/pom.xml b/core/pom.xml index d5d3fe851b..4c3f6a6196 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -21,6 +21,7 @@ email launcher vtype + websocket org.phoebus diff --git a/core/websocket/pom.xml b/core/websocket/pom.xml new file mode 100644 index 0000000000..df4f7f6d93 --- /dev/null +++ b/core/websocket/pom.xml @@ -0,0 +1,20 @@ + + + + + 4.0.0 + core-websocket + + org.phoebus + core + 5.0.1-SNAPSHOT + + + + + + \ No newline at end of file diff --git a/core/websocket/src/main/java/org/phoebus/core/websocket/WebSocketClient.java b/core/websocket/src/main/java/org/phoebus/core/websocket/WebSocketClient.java new file mode 100644 index 0000000000..d196e1a503 --- /dev/null +++ b/core/websocket/src/main/java/org/phoebus/core/websocket/WebSocketClient.java @@ -0,0 +1,221 @@ +/* + * Copyright (C) 2025 European Spallation Source ERIC. + */ + +package org.phoebus.core.websocket; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.nio.ByteBuffer; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * A web socket client implementation supporting pong and text messages. + * + *

    + * Once connection is established, a ping/pong thread is set up to check peer availability. This should be + * able to handle both remote peer being shut down and network issues. Ping messages are dispatched once + * per minute. A reconnection loop is started if a pong message is not received from peer within three seconds. + *

    + */ +public class WebSocketClient implements WebSocket.Listener { + + private WebSocket webSocket; + private final Logger logger = Logger.getLogger(WebSocketClient.class.getName()); + private Runnable connectCallback; + private Runnable disconnectCallback; + private final URI uri; + private final Consumer onTextCallback; + + private final AtomicBoolean attemptReconnect = new AtomicBoolean(); + private CountDownLatch pingCountdownLatch; + + /** + * @param uri The URI of the web socket peer. + * @param onTextCallback A callback method the API client will use to process web socket messages. + */ + public WebSocketClient(URI uri, Consumer onTextCallback) { + this.uri = uri; + this.onTextCallback = onTextCallback; + } + + /** + * Attempts to connect to the remote web socket. + */ + public void connect() { + doConnect(); + } + + /** + * Internal connect implementation. This is done in a loop with 10 s intervals until + * connection is established. + */ + private void doConnect() { + attemptReconnect.set(true); + new Thread(() -> { + while (attemptReconnect.get()) { + logger.log(Level.INFO, "Attempting web socket connection to " + uri); + HttpClient.newBuilder() + .build() + .newWebSocketBuilder() + .buildAsync(uri, this); + try { + Thread.sleep(10000); + } catch (InterruptedException e) { + logger.log(Level.WARNING, "Got interrupted exception"); + } + } + }).start(); + } + + /** + * Called when connection has been established. An API client may optionally register a + * {@link #connectCallback} which is called when connection is opened. + * + * @param webSocket the WebSocket that has been connected + */ + @Override + public void onOpen(WebSocket webSocket) { + WebSocket.Listener.super.onOpen(webSocket); + attemptReconnect.set(false); + this.webSocket = webSocket; + if (connectCallback != null) { + connectCallback.run(); + } + logger.log(Level.INFO, "Connected to " + uri); + new Thread(new PingRunnable()).start(); + } + + /** + * Send a text message to peer. + * + * @param message The actual message. In practice a JSON formatted string that peer can evaluate + * to take proper action. + */ + public void sendText(String message) { + try { + webSocket.sendText(message, true).get(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + + /** + * Called when connection has been closed, e.g. by remote peer. An API client may optionally register a + * {@link #disconnectCallback} which is called when connection is opened. + * + *

    + * Note that reconnection will be attempted immediately. + *

    + * + * @param webSocket the WebSocket that has been connected + */ + @Override + public CompletionStage onClose(WebSocket webSocket, + int statusCode, + String reason) { + logger.log(Level.INFO, "Web socket closed, status code=" + statusCode + ", reason: " + reason); + if (disconnectCallback != null) { + disconnectCallback.run(); + } + return null; + } + + /** + * Utility method to check connectivity. Peer should respond such that {@link #onPong(WebSocket, ByteBuffer)} + * is called. + */ + public void sendPing() { + logger.log(Level.FINE, "Sending ping"); + webSocket.sendPing(ByteBuffer.allocate(0)); + } + + @Override + public CompletionStage onPong(WebSocket webSocket, ByteBuffer message) { + pingCountdownLatch.countDown(); + logger.log(Level.FINE, "Got pong"); + return WebSocket.Listener.super.onPong(webSocket, message); + } + + @Override + public void onError(WebSocket webSocket, Throwable error) { + logger.log(Level.WARNING, "Got web socket error", error); + WebSocket.Listener.super.onError(webSocket, error); + } + + @Override + public CompletionStage onText(WebSocket webSocket, + CharSequence data, + boolean last) { + webSocket.request(1); + if (onTextCallback != null) { + onTextCallback.accept(data); + } + return WebSocket.Listener.super.onText(webSocket, data, last); + } + + /** + * NOTE: this must be called by the API client when web socket messages are no longer + * needed, otherwise reconnect attempts will continue as these run on a separate thread. + * + *

    + * The status code 1000 is used when calling the {@link WebSocket#sendClose(int, String)} method. See + * list of common web socket status codes + * here. + *

    + * + * @param reason Custom reason text. + */ + public void close(String reason) { + webSocket.sendClose(1000, reason); + } + + /** + * @param connectCallback A {@link Runnable} invoked when web socket connects successfully. + */ + public void setConnectCallback(Runnable connectCallback) { + this.connectCallback = connectCallback; + } + + /** + * @param disconnectCallback A {@link Runnable} invoked when web socket disconnects, either + * when closed explicitly, or if remote peer goes away. + */ + public void setDisconnectCallback(Runnable disconnectCallback) { + this.disconnectCallback = disconnectCallback; + } + + private class PingRunnable implements Runnable { + + @Override + public void run() { + while (true) { + pingCountdownLatch = new CountDownLatch(1); + sendPing(); + try { + if (!pingCountdownLatch.await(3, TimeUnit.SECONDS)) { + if (disconnectCallback != null) { + disconnectCallback.run(); + } + logger.log(Level.WARNING, "No pong response within three seconds"); + doConnect(); + return; + } else { + Thread.sleep(60000); + } + } catch (InterruptedException e) { + logger.log(Level.WARNING, "Got interrupted exception"); + return; + } + } + } + } +} diff --git a/services/save-and-restore/pom.xml b/services/save-and-restore/pom.xml index dc6fe66f07..e87b61d6fa 100644 --- a/services/save-and-restore/pom.xml +++ b/services/save-and-restore/pom.xml @@ -46,6 +46,11 @@ spring-boot-starter-data-elasticsearch + + org.springframework.boot + spring-boot-starter-websocket + + co.elastic.clients elasticsearch-java @@ -81,6 +86,24 @@ 5.0.1-SNAPSHOT + + org.phoebus + core-websocket + 5.0.1-SNAPSHOT + test + + + + org.apache.logging.log4j + log4j-api + 2.23.1 + + + org.apache.logging.log4j + log4j-core + 2.23.1 + + org.springframework.boot spring-boot-starter diff --git a/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/persistence/dao/NodeDAO.java b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/persistence/dao/NodeDAO.java index cf4601bcbd..a8dc71c7f6 100644 --- a/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/persistence/dao/NodeDAO.java +++ b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/persistence/dao/NodeDAO.java @@ -57,6 +57,7 @@ public interface NodeDAO { /** * Retrieve the nodes identified by the list of unique node ids + * * @param uniqueNodeIds List of unique node ids * @return List of matching nodes */ @@ -72,7 +73,9 @@ public interface NodeDAO { /** * Checks that each of the node ids passed to this method exist, and that none of them * is the root node. If check passes all nodes are deleted. + * * @param nodeIds List of (existing) node ids. + * {@link Node}s. Client may use this to trigger a refresh of the UI. */ void deleteNodes(List nodeIds); @@ -87,7 +90,6 @@ public interface NodeDAO { /** - * * @param uniqueNodeId Valid {@link Node} id. * @return The parent {@link Node} */ @@ -109,9 +111,9 @@ public interface NodeDAO { * @param nodeIds Non-null and non-empty list of unique node ids subject to move * @param targetId Non-null and non-empty unique id of target node * @param userName The (account) name of the user performing the operation. - * @return The target {@link Node} object that is the new parent of the moved source {@link Node} + * @return A list of new {@link Node}s */ - Node copyNodes(List nodeIds, String targetId, String userName); + List copyNodes(List nodeIds, String targetId, String userName); /** @@ -134,8 +136,9 @@ public interface NodeDAO { /** * Saves the {@link org.phoebus.applications.saveandrestore.model.Snapshot} to the persistence layer. + * * @param parentNodeId The unique id of the parent {@link Node} for the new {@link Snapshot}. - * @param snapshot The {@link Snapshot} data. + * @param snapshot The {@link Snapshot} data. * @return The persisted {@link Snapshot} data. */ Snapshot createSnapshot(String parentNodeId, Snapshot snapshot); @@ -196,7 +199,8 @@ public interface NodeDAO { /** * Saves the {@link org.phoebus.applications.saveandrestore.model.Configuration} to the persistence layer. - * @param parentNodeId The unique id of the parent {@link Node} for the new {@link Configuration}. + * + * @param parentNodeId The unique id of the parent {@link Node} for the new {@link Configuration}. * @param configuration The {@link Configuration} data. * @return The persisted {@link Configuration} data. */ @@ -204,6 +208,7 @@ public interface NodeDAO { /** * Retrieves the {@link ConfigurationData} for the specified (unique) id. + * * @param uniqueId Id of the configuration {@link Node} * @return A {@link ConfigurationData} object. */ @@ -221,6 +226,7 @@ public interface NodeDAO { /** * Retrieves the {@link SnapshotData} for the specified (unique) id. + * * @param uniqueId Id of the snapshot {@link Node} * @return A {@link SnapshotData} object. */ @@ -244,7 +250,8 @@ public interface NodeDAO { /** * Saves the {@link org.phoebus.applications.saveandrestore.model.CompositeSnapshot} to the persistence layer. - * @param parentNodeId The unique id of the parent {@link Node} for the new {@link CompositeSnapshot}. + * + * @param parentNodeId The unique id of the parent {@link Node} for the new {@link CompositeSnapshot}. * @param compositeSnapshot The {@link CompositeSnapshot} data. * @return The persisted {@link CompositeSnapshot} data. */ @@ -263,6 +270,7 @@ public interface NodeDAO { /** * Checks for duplicate PV names in the specified list of snapshot or composite snapshot {@link Node}s. + * * @param snapshotIds list of snapshot or composite snapshot {@link Node}s * @return A list if PV names that occur multiple times in the specified snapshot nodes or snapshot nodes * referenced in composite snapshots. If no duplicates are found, an empty list is returned. @@ -283,6 +291,7 @@ public interface NodeDAO { * Aggregates a list of {@link SnapshotItem}s from a composite snapshot node. Note that since a * composite snapshot may reference other composite snapshots, the implementation may need to recursively * locate all referenced single snapshots. + * * @param compositeSnapshotNodeId The if of an existing composite snapshot {@link Node} * @return A list of {@link SnapshotItem}s. */ @@ -292,14 +301,16 @@ public interface NodeDAO { * Checks if all the referenced snapshot {@link Node}s in a {@link CompositeSnapshot} are * of the supported type, i.e. {@link org.phoebus.applications.saveandrestore.model.NodeType#COMPOSITE_SNAPSHOT} * or {@link org.phoebus.applications.saveandrestore.model.NodeType#SNAPSHOT} + * * @param compositeSnapshot An existing {@link CompositeSnapshot}. * @return true if all referenced snapshot {@link Node}s in a {@link CompositeSnapshot} are all - * * of the supported type. + * * of the supported type. */ boolean checkCompositeSnapshotReferencedNodeTypes(CompositeSnapshot compositeSnapshot); /** * Performs a search based on the provided search parameters. + * * @param searchParameters Map of keyword/value pairs defining the search criteria. * @return A {@link SearchResult} object with a potentially empty list of {@link Node}s. */ @@ -307,6 +318,7 @@ public interface NodeDAO { /** * Save a new or updated {@link Filter} + * * @param filter The {@link Filter} to save * @return The saved {@link Filter} */ @@ -319,6 +331,7 @@ public interface NodeDAO { /** * Deletes a {@link Filter} based on its name. + * * @param name Unique name of the {@link Filter} */ void deleteFilter(String name); @@ -330,6 +343,7 @@ public interface NodeDAO { /** * Adds a {@link Tag} to specified list of target {@link Node}s + * * @param tagData See {@link TagData} * @return The list of updated {@link Node}s */ @@ -337,6 +351,7 @@ public interface NodeDAO { /** * Removes a {@link Tag} from specified list of target {@link Node}s + * * @param tagData See {@link TagData} * @return The list of updated {@link Node}s */ diff --git a/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/persistence/dao/impl/elasticsearch/ElasticsearchDAO.java b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/persistence/dao/impl/elasticsearch/ElasticsearchDAO.java index df239e71cc..083c397d9b 100644 --- a/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/persistence/dao/impl/elasticsearch/ElasticsearchDAO.java +++ b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/persistence/dao/impl/elasticsearch/ElasticsearchDAO.java @@ -141,7 +141,7 @@ public void deleteNode(String nodeId) { */ @Override public void deleteNodes(List nodeIds) { - List nodes = new ArrayList<>(); + List nodesToDelete = new ArrayList<>(); for (String nodeId : nodeIds) { Node nodeToDelete = getNode(nodeId); if (nodeToDelete == null) { @@ -149,9 +149,11 @@ public void deleteNodes(List nodeIds) { } else if (nodeToDelete.getUniqueId().equals(ROOT_FOLDER_UNIQUE_ID)) { throw new IllegalArgumentException("Root node cannot be deleted"); } - nodes.add(nodeToDelete); + nodesToDelete.add(nodeToDelete); + } + for (Node node : nodesToDelete) { + deleteNode(node); } - nodes.forEach(this::deleteNode); } @Override @@ -336,7 +338,7 @@ public Node moveNodes(List nodeIds, String targetId, String userName) { } // Remove source nodes from the list of child nodes in parent node. - parentNode.getChildNodes().removeAll(sourceNodes.stream().map(Node::getUniqueId).collect(Collectors.toList())); + parentNode.getChildNodes().removeAll(sourceNodes.stream().map(Node::getUniqueId).toList()); elasticsearchTreeRepository.save(parentNode); // Update the target node to include the source nodes in its list of child nodes @@ -344,14 +346,14 @@ public Node moveNodes(List nodeIds, String targetId, String userName) { targetNode.setChildNodes(new ArrayList<>()); } - targetNode.getChildNodes().addAll(sourceNodes.stream().map(Node::getUniqueId).collect(Collectors.toList())); + targetNode.getChildNodes().addAll(sourceNodes.stream().map(Node::getUniqueId).toList()); ESTreeNode updatedTargetNode = elasticsearchTreeRepository.save(targetNode); return updatedTargetNode.getNode(); } @Override - public Node copyNodes(List nodeIds, String targetId, String userName) { + public List copyNodes(List nodeIds, String targetId, String userName) { // Copy to root node not allowed, neither is copying of root folder itself if (targetId.equals(ROOT_FOLDER_UNIQUE_ID) || nodeIds.contains(ROOT_FOLDER_UNIQUE_ID)) { throw new IllegalArgumentException("Copy to root node or copy root node not supported"); @@ -424,9 +426,11 @@ public Node copyNodes(List nodeIds, String targetId, String userName) { } } - sourceNodes.forEach(sourceNode -> copyNode(sourceNode, targetNode, userName)); + List newNodes = new ArrayList<>(); - return targetNode; + sourceNodes.forEach(sourceNode -> newNodes.add(copyNode(sourceNode, targetNode, userName))); + + return newNodes; } /** @@ -440,8 +444,9 @@ public Node copyNodes(List nodeIds, String targetId, String userName) { * @param sourceNode The source {@link Node} to be copied (cloned). * @param targetParentNode The parent {@link Node} of the copy. * @param userName Username of the individual performing the action. + * @return The new {@link Node} */ - private void copyNode(Node sourceNode, Node targetParentNode, String userName) { + private Node copyNode(Node sourceNode, Node targetParentNode, String userName) { List targetsChildNodes = getChildNodes(targetParentNode.getUniqueId()); String newNodeName = determineNewNodeName(sourceNode, targetsChildNodes); // First create a clone of the source Node object @@ -466,6 +471,7 @@ private void copyNode(Node sourceNode, Node targetParentNode, String userName) { getCompositeSnapshotData(sourceNode.getUniqueId()); copyCompositeSnapshotData(newSourceNode, compositeSnapshotData); } + return newSourceNode; } protected boolean mayMoveOrCopySnapshot(Node sourceNode, Node targetParentNode) { @@ -635,7 +641,6 @@ private void resolvePath(String nodeId, List pathElements) { resolvePath(parent.getUniqueId(), pathElements); } - private void deleteNode(Node nodeToDelete) { for (Node node : getChildNodes(nodeToDelete.getUniqueId())) { deleteNode(node); @@ -662,7 +667,6 @@ private void deleteNode(Node nodeToDelete) { // Delete the node elasticsearchTreeRepository.deleteById(nodeToDelete.getUniqueId()); - } @Override @@ -699,12 +703,14 @@ public Configuration updateConfiguration(Configuration configuration) { Node existingConfigurationNode = getNode(configuration.getConfigurationNode().getUniqueId()); + // Make sure node id is set on ConfigurationData, client may have omitted it. + configuration.getConfigurationNode().setUniqueId(existingConfigurationNode.getUniqueId()); + // Set name, description and user even if unchanged. existingConfigurationNode.setName(configuration.getConfigurationNode().getName()); existingConfigurationNode.setDescription(configuration.getConfigurationNode().getDescription()); existingConfigurationNode.setUserName(configuration.getConfigurationNode().getUserName()); // Update last modified date - existingConfigurationNode.setLastModified(new Date()); existingConfigurationNode = updateNode(existingConfigurationNode, false); ConfigurationData updatedConfigurationData = configurationDataRepository.save(configuration.getConfigurationData()); @@ -755,12 +761,18 @@ public Snapshot updateSnapshot(Snapshot snapshot) { SnapshotData sanitizedSnapshotData = removeDuplicateSnapshotItems(snapshot.getSnapshotData()); snapshot.setSnapshotData(sanitizedSnapshotData); - snapshot.getSnapshotNode().setNodeType(NodeType.SNAPSHOT); // Force node type + Node existingSnapshotNode = getNode(snapshot.getSnapshotNode().getUniqueId()); + // Make sure node id is set on ConfigurationData, client may have omitted it. + snapshot.getSnapshotNode().setUniqueId(existingSnapshotNode.getUniqueId()); + existingSnapshotNode.setName(snapshot.getSnapshotNode().getName()); + existingSnapshotNode.setDescription(snapshot.getSnapshotNode().getDescription()); + existingSnapshotNode.setUserName(snapshot.getSnapshotNode().getUserName()); + SnapshotData newSnapshotData; Snapshot newSnapshot = new Snapshot(); try { newSnapshotData = snapshotDataRepository.save(snapshot.getSnapshotData()); - Node updatedNode = updateNode(snapshot.getSnapshotNode(), false); + Node updatedNode = updateNode(existingSnapshotNode, false); newSnapshot.setSnapshotData(newSnapshotData); newSnapshot.setSnapshotNode(updatedNode); } catch (Exception e) { @@ -1223,7 +1235,7 @@ public List deleteTag(TagData tagData) { protected String determineNewNodeName(Node sourceNode, List targetParentChildNodes) { // Filter to make sure only nodes of same type are considered. targetParentChildNodes = targetParentChildNodes.stream().filter(n -> n.getNodeType().equals(sourceNode.getNodeType())).collect(Collectors.toList()); - List targetParentChildNodeNames = targetParentChildNodes.stream().map(Node::getName).collect(Collectors.toList()); + List targetParentChildNodeNames = targetParentChildNodes.stream().map(Node::getName).toList(); if (!targetParentChildNodeNames.contains(sourceNode.getName())) { return sourceNode.getName(); } @@ -1244,7 +1256,7 @@ protected String determineNewNodeName(Node sourceNode, List targetParentCh if (nodeNameCopies.isEmpty()) { return newNodeBaseName + " copy"; } else { - Collections.sort(nodeNameCopies, new NodeNameComparator()); + nodeNameCopies.sort(new NodeNameComparator()); try { String lastCopyName = nodeNameCopies.get(nodeNameCopies.size() - 1); if (lastCopyName.equals(newNodeBaseName + " copy")) { diff --git a/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/config/WebConfiguration.java b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/config/WebConfiguration.java index dc7b2eeab6..864977d16c 100644 --- a/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/config/WebConfiguration.java +++ b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/config/WebConfiguration.java @@ -20,10 +20,13 @@ import org.phoebus.saveandrestore.util.SnapshotUtil; import org.phoebus.service.saveandrestore.persistence.dao.NodeDAO; import org.phoebus.service.saveandrestore.persistence.dao.impl.elasticsearch.ElasticsearchDAO; +import org.phoebus.service.saveandrestore.websocket.WebSocket; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Scope; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -64,4 +67,10 @@ public SnapshotUtil snapshotRestorer(){ public ExecutorService executorService(){ return Executors.newCachedThreadPool(); } + + @Bean(name = "sockets") + @Scope("singleton") + public List getSockets() { + return new CopyOnWriteArrayList<>(); + } } diff --git a/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/CompositeSnapshotController.java b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/CompositeSnapshotController.java index 0b73c05108..5fd64d7e46 100644 --- a/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/CompositeSnapshotController.java +++ b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/CompositeSnapshotController.java @@ -20,7 +20,10 @@ package org.phoebus.service.saveandrestore.web.controllers; import org.phoebus.applications.saveandrestore.model.*; +import org.phoebus.applications.saveandrestore.model.websocket.MessageType; +import org.phoebus.applications.saveandrestore.model.websocket.SaveAndRestoreWebSocketMessage; import org.phoebus.service.saveandrestore.persistence.dao.NodeDAO; +import org.phoebus.service.saveandrestore.websocket.WebSocketHandler; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; @@ -44,6 +47,9 @@ public class CompositeSnapshotController extends BaseController { @Autowired private NodeDAO nodeDAO; + @Autowired + private WebSocketHandler webSocketHandler; + /** * Creates a new {@link CompositeSnapshot} {@link Node}. * @param parentNodeId Valid id of the {@link Node}s intended parent. @@ -60,7 +66,9 @@ public CompositeSnapshot createCompositeSnapshot(@RequestParam(value = "parentNo throw new IllegalArgumentException("Composite snapshot node of wrong type"); } compositeSnapshot.getCompositeSnapshotNode().setUserName(principal.getName()); - return nodeDAO.createCompositeSnapshot(parentNodeId, compositeSnapshot); + CompositeSnapshot newCompositeSnapshot = nodeDAO.createCompositeSnapshot(parentNodeId, compositeSnapshot); + webSocketHandler.sendMessage(new SaveAndRestoreWebSocketMessage(MessageType.NODE_ADDED, newCompositeSnapshot.getCompositeSnapshotNode().getUniqueId())); + return newCompositeSnapshot; } /** @@ -77,7 +85,9 @@ public CompositeSnapshot updateCompositeSnapshot(@RequestBody CompositeSnapshot throw new IllegalArgumentException("Composite snapshot node of wrong type"); } compositeSnapshot.getCompositeSnapshotNode().setUserName(principal.getName()); - return nodeDAO.updateCompositeSnapshot(compositeSnapshot); + CompositeSnapshot updatedCompositeSnapshot = nodeDAO.updateCompositeSnapshot(compositeSnapshot); + webSocketHandler.sendMessage(new SaveAndRestoreWebSocketMessage(MessageType.NODE_UPDATED, updatedCompositeSnapshot.getCompositeSnapshotNode())); + return updatedCompositeSnapshot; } /** diff --git a/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/ConfigurationController.java b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/ConfigurationController.java index c769e99fbd..45a0bc52d3 100644 --- a/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/ConfigurationController.java +++ b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/ConfigurationController.java @@ -21,7 +21,10 @@ import org.phoebus.applications.saveandrestore.model.Configuration; import org.phoebus.applications.saveandrestore.model.ConfigurationData; import org.phoebus.applications.saveandrestore.model.Node; +import org.phoebus.applications.saveandrestore.model.websocket.MessageType; +import org.phoebus.applications.saveandrestore.model.websocket.SaveAndRestoreWebSocketMessage; import org.phoebus.service.saveandrestore.persistence.dao.NodeDAO; +import org.phoebus.service.saveandrestore.websocket.WebSocketHandler; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; @@ -46,6 +49,10 @@ public class ConfigurationController extends BaseController { @Autowired private NodeDAO nodeDAO; + @SuppressWarnings("unused") + @Autowired + private WebSocketHandler webSocketHandler; + /** * Creates new {@link Configuration} {@link Node}. * @param parentNodeId Valid id of the {@link Node}s intended parent. @@ -71,7 +78,9 @@ public Configuration createConfiguration(@RequestParam(value = "parentNodeId") S } } configuration.getConfigurationNode().setUserName(principal.getName()); - return nodeDAO.createConfiguration(parentNodeId, configuration); + Configuration newConfiguration = nodeDAO.createConfiguration(parentNodeId, configuration); + webSocketHandler.sendMessage(new SaveAndRestoreWebSocketMessage(MessageType.NODE_ADDED, newConfiguration.getConfigurationNode().getUniqueId())); + return newConfiguration; } /** @@ -97,6 +106,8 @@ public ConfigurationData getConfigurationData(@PathVariable String uniqueId) { public Configuration updateConfiguration(@RequestBody Configuration configuration, Principal principal) { configuration.getConfigurationNode().setUserName(principal.getName()); - return nodeDAO.updateConfiguration(configuration); + Configuration updatedConfiguration = nodeDAO.updateConfiguration(configuration); + webSocketHandler.sendMessage(new SaveAndRestoreWebSocketMessage(MessageType.NODE_UPDATED, updatedConfiguration.getConfigurationNode())); + return updatedConfiguration; } } diff --git a/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/FilterController.java b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/FilterController.java index cac134bfad..57e34face3 100644 --- a/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/FilterController.java +++ b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/FilterController.java @@ -20,7 +20,10 @@ package org.phoebus.service.saveandrestore.web.controllers; import org.phoebus.applications.saveandrestore.model.search.Filter; +import org.phoebus.applications.saveandrestore.model.websocket.MessageType; +import org.phoebus.applications.saveandrestore.model.websocket.SaveAndRestoreWebSocketMessage; import org.phoebus.service.saveandrestore.persistence.dao.NodeDAO; +import org.phoebus.service.saveandrestore.websocket.WebSocketHandler; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; @@ -39,6 +42,9 @@ public class FilterController extends BaseController { @Autowired private NodeDAO nodeDAO; + @Autowired + private WebSocketHandler webSocketHandler; + /** * Saves a new or updated {@link Filter}. * @@ -52,7 +58,9 @@ public class FilterController extends BaseController { public Filter saveFilter(@RequestBody final Filter filter, Principal principal) { filter.setUser(principal.getName()); - return nodeDAO.saveFilter(filter); + Filter savedFilter = nodeDAO.saveFilter(filter); + webSocketHandler.sendMessage(new SaveAndRestoreWebSocketMessage(MessageType.FILTER_ADDED_OR_UPDATED, filter)); + return savedFilter; } /** @@ -75,5 +83,6 @@ public List getAllFilters() { @PreAuthorize("@authorizationHelper.maySaveOrDeleteFilter(#name, #root)") public void deleteFilter(@PathVariable final String name, Principal principal) { nodeDAO.deleteFilter(name); + webSocketHandler.sendMessage(new SaveAndRestoreWebSocketMessage(MessageType.FILTER_REMOVED, name)); } } diff --git a/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/NodeController.java b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/NodeController.java index cf385aaae4..41005b1185 100644 --- a/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/NodeController.java +++ b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/NodeController.java @@ -20,7 +20,10 @@ import org.phoebus.applications.saveandrestore.model.Node; import org.phoebus.applications.saveandrestore.model.NodeType; import org.phoebus.applications.saveandrestore.model.Tag; +import org.phoebus.applications.saveandrestore.model.websocket.MessageType; +import org.phoebus.applications.saveandrestore.model.websocket.SaveAndRestoreWebSocketMessage; import org.phoebus.service.saveandrestore.persistence.dao.NodeDAO; +import org.phoebus.service.saveandrestore.websocket.WebSocketHandler; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; @@ -47,6 +50,9 @@ public class NodeController extends BaseController { @Autowired private NodeDAO nodeDAO; + @Autowired + private WebSocketHandler webSocketHandler; + /** * Create a new folder in the tree structure. @@ -77,7 +83,10 @@ public Node createNode(@RequestParam(name = "parentNodeId") String parentsUnique throw new IllegalArgumentException("Node may not contain golden tag"); } node.setUserName(principal.getName()); - return nodeDAO.createNode(parentsUniqueId, node); + Node savedNode = nodeDAO.createNode(parentsUniqueId, node); + webSocketHandler.sendMessage(new SaveAndRestoreWebSocketMessage<>(MessageType.NODE_ADDED, + savedNode.getUniqueId())); + return savedNode; } /** @@ -149,6 +158,8 @@ public List getChildNodes(@PathVariable final String uniqueNodeId) { @PreAuthorize("@authorizationHelper.mayDelete(#nodeIds, #root)") public void deleteNodes(@RequestBody List nodeIds) { nodeDAO.deleteNodes(nodeIds); + nodeIds.forEach(id -> + webSocketHandler.sendMessage(new SaveAndRestoreWebSocketMessage(MessageType.NODE_REMOVED, id))); } /** @@ -209,7 +220,9 @@ public Node updateNode(@RequestParam(value = "customTimeForMigration", required throw new IllegalArgumentException("Node may not contain golden tag"); } nodeToUpdate.setUserName(principal.getName()); - return nodeDAO.updateNode(nodeToUpdate, Boolean.parseBoolean(customTimeForMigration)); + Node updatedNode = nodeDAO.updateNode(nodeToUpdate, Boolean.parseBoolean(customTimeForMigration)); + webSocketHandler.sendMessage(new SaveAndRestoreWebSocketMessage(MessageType.NODE_UPDATED, updatedNode)); + return updatedNode; } /** diff --git a/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/SnapshotController.java b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/SnapshotController.java index 68906377de..5f9ed63217 100644 --- a/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/SnapshotController.java +++ b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/SnapshotController.java @@ -21,7 +21,10 @@ import org.phoebus.applications.saveandrestore.model.NodeType; import org.phoebus.applications.saveandrestore.model.Snapshot; import org.phoebus.applications.saveandrestore.model.SnapshotData; +import org.phoebus.applications.saveandrestore.model.websocket.MessageType; +import org.phoebus.applications.saveandrestore.model.websocket.SaveAndRestoreWebSocketMessage; import org.phoebus.service.saveandrestore.persistence.dao.NodeDAO; +import org.phoebus.service.saveandrestore.websocket.WebSocketHandler; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; @@ -39,6 +42,8 @@ public class SnapshotController extends BaseController { @Autowired private NodeDAO nodeDAO; + @Autowired + private WebSocketHandler webSocketHandler; /** * * @param uniqueId Unique {@link Node} id of a snapshot. @@ -74,7 +79,9 @@ public Snapshot createSnapshot(@RequestParam(value = "parentNodeId") String pare throw new IllegalArgumentException("Snapshot node of wrong type"); } snapshot.getSnapshotNode().setUserName(principal.getName()); - return nodeDAO.createSnapshot(parentNodeId, snapshot); + Snapshot newSnapshot = nodeDAO.createSnapshot(parentNodeId, snapshot); + webSocketHandler.sendMessage(new SaveAndRestoreWebSocketMessage(MessageType.NODE_ADDED, newSnapshot.getSnapshotNode().getUniqueId())); + return newSnapshot; } /** @@ -91,6 +98,8 @@ public Snapshot updateSnapshot(@RequestBody Snapshot snapshot, throw new IllegalArgumentException("Snapshot node of wrong type"); } snapshot.getSnapshotNode().setUserName(principal.getName()); - return nodeDAO.updateSnapshot(snapshot); + Snapshot updatedSnapshot = nodeDAO.updateSnapshot(snapshot); + webSocketHandler.sendMessage(new SaveAndRestoreWebSocketMessage(MessageType.NODE_UPDATED, updatedSnapshot.getSnapshotNode())); + return updatedSnapshot; } } diff --git a/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/StructureController.java b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/StructureController.java index 4bfc3e1cc8..cf90c4714a 100644 --- a/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/StructureController.java +++ b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/StructureController.java @@ -18,7 +18,10 @@ package org.phoebus.service.saveandrestore.web.controllers; import org.phoebus.applications.saveandrestore.model.Node; +import org.phoebus.applications.saveandrestore.model.websocket.MessageType; +import org.phoebus.applications.saveandrestore.model.websocket.SaveAndRestoreWebSocketMessage; import org.phoebus.service.saveandrestore.persistence.dao.NodeDAO; +import org.phoebus.service.saveandrestore.websocket.WebSocketHandler; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; @@ -41,6 +44,9 @@ public class StructureController extends BaseController { @Autowired private NodeDAO nodeDAO; + @Autowired + private WebSocketHandler webSocketHandler; + /** * Moves a list of source nodes to a new target (parent) node. * @@ -60,8 +66,16 @@ public Node moveNodes(@RequestParam(value = "to") String to, if (to.isEmpty() || nodes.isEmpty()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Target node and list of source nodes must all be non-empty."); } + // Get parent node before move + Node sourceParentNode = nodeDAO.getParentNode(nodes.get(0)); Logger.getLogger(StructureController.class.getName()).info(Thread.currentThread().getName() + " " + (new Date()) + " move"); - return nodeDAO.moveNodes(nodes, to, principal.getName()); + Node targetNode = nodeDAO.moveNodes(nodes, to, principal.getName()); + // Update clients + webSocketHandler.sendMessage(new SaveAndRestoreWebSocketMessage<>(MessageType.NODE_UPDATED, + targetNode)); + webSocketHandler.sendMessage(new SaveAndRestoreWebSocketMessage<>(MessageType.NODE_UPDATED, + sourceParentNode)); + return targetNode; } /** @@ -84,8 +98,11 @@ public Node copyNodes(@RequestParam(value = "to") String to, if (to.isEmpty() || nodes.isEmpty()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Target node and list of source nodes must all be non-empty."); } - Logger.getLogger(StructureController.class.getName()).info(Thread.currentThread().getName() + " " + (new Date()) + " move"); - return nodeDAO.copyNodes(nodes, to, principal.getName()); + Logger.getLogger(StructureController.class.getName()).info(Thread.currentThread().getName() + " " + (new Date()) + " copy"); + Node targetNode = nodeDAO.getNode(to); + List newNodes = nodeDAO.copyNodes(nodes, to, principal.getName()); + newNodes.forEach(n -> webSocketHandler.sendMessage(new SaveAndRestoreWebSocketMessage<>(MessageType.NODE_ADDED, n.getUniqueId()))); + return targetNode; } /** diff --git a/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/TagController.java b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/TagController.java index 56c9b2057a..cb4b306a20 100644 --- a/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/TagController.java +++ b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/TagController.java @@ -24,7 +24,10 @@ import org.phoebus.applications.saveandrestore.model.Node; import org.phoebus.applications.saveandrestore.model.Tag; import org.phoebus.applications.saveandrestore.model.TagData; +import org.phoebus.applications.saveandrestore.model.websocket.MessageType; +import org.phoebus.applications.saveandrestore.model.websocket.SaveAndRestoreWebSocketMessage; import org.phoebus.service.saveandrestore.persistence.dao.NodeDAO; +import org.phoebus.service.saveandrestore.websocket.WebSocketHandler; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; @@ -32,6 +35,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.socket.WebSocketMessage; import java.security.Principal; import java.util.List; @@ -49,6 +53,9 @@ public class TagController extends BaseController { @Autowired private NodeDAO nodeDAO; + @Autowired + private WebSocketHandler webSocketHandler; + /** * @return A {@link List} of all {@link Tag}s. */ @@ -70,7 +77,9 @@ public List getTags() { public List addTag(@RequestBody TagData tagData, Principal principal) { tagData.getTag().setUserName(principal.getName()); - return nodeDAO.addTag(tagData); + List taggedNodes = nodeDAO.addTag(tagData); + taggedNodes.forEach(n -> webSocketHandler.sendMessage(new SaveAndRestoreWebSocketMessage(MessageType.NODE_UPDATED, n))); + return taggedNodes; } /** @@ -83,6 +92,8 @@ public List addTag(@RequestBody TagData tagData, @DeleteMapping("/tags") @PreAuthorize("@authorizationHelper.mayAddOrDeleteTag(#tagData, #root)") public List deleteTag(@RequestBody TagData tagData) { - return nodeDAO.deleteTag(tagData); + List untaggedNodes = nodeDAO.deleteTag(tagData); + untaggedNodes.forEach(n -> webSocketHandler.sendMessage(new SaveAndRestoreWebSocketMessage(MessageType.NODE_UPDATED, n))); + return untaggedNodes; } } diff --git a/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/websocket/WebSocket.java b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/websocket/WebSocket.java new file mode 100644 index 0000000000..4d1458aaf0 --- /dev/null +++ b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/websocket/WebSocket.java @@ -0,0 +1,173 @@ +/******************************************************************************* + * Copyright (c) 2019-2022 UT-Battelle, LLC. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the LICENSE + * which accompanies this distribution + ******************************************************************************/ +package org.phoebus.service.saveandrestore.websocket; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Utility class for handling web socket messages. In the context of the save-and-restore service, + * only messages from server are expected. Client messages are logged, but do not invoke any behavior. + */ +@SuppressWarnings("nls") +public class WebSocket { + + /** + * Is the queue full? + */ + private final AtomicBoolean stuffed = new AtomicBoolean(); + + /** + * Queue of messages for the client. + * + *

    Multiple threads concurrently writing to the socket results in + * IllegalStateException "remote endpoint was in state [TEXT_FULL_WRITING]" + * All writes are thus performed by just one thread off this queue. + */ + private final ArrayBlockingQueue writeQueue = new ArrayBlockingQueue<>(2048); + + private static final String EXIT_MESSAGE = "EXIT"; + + private final WebSocketSession session; + private final String id; + + private final Logger logger = Logger.getLogger(WebSocket.class.getName()); + + private final ObjectMapper objectMapper; + + /** + * Constructor + */ + public WebSocket(ObjectMapper objectMapper, WebSocketSession webSocketSession) { + this.session = webSocketSession; + logger.log(Level.INFO, () -> "Creating web socket " + session.getUri() + " ID " + session.getId()); + this.objectMapper = objectMapper; + this.id = webSocketSession.getId(); + Thread writeThread = new Thread(this::writeQueuedMessages, "Web Socket Write Thread"); + writeThread.setName("Web Socket Write Thread " + this.id); + writeThread.setDaemon(true); + writeThread.start(); + } + + /** + * @return Session ID + */ + public String getId() { + if (session == null) + return "(" + id + ")"; + else + return id; + } + + /** + * @param message Potentially long message + * @return Message shorted to 200 chars + */ + private String shorten(final String message) { + if (message == null || message.length() < 200) + return message; + return message.substring(0, 200) + " ..."; + } + + public void queueMessage(final String message) { + // Ignore messages after 'dispose' + if (session == null) + return; + + if (writeQueue.offer(message)) { // Queued OK. Is this a recovery from stuffed queue? + if (stuffed.getAndSet(false)) + logger.log(Level.WARNING, () -> "Un-stuffed message queue for " + id); + } else { // Log, but only for the first message to prevent flooding the log + if (!stuffed.getAndSet(true)) + logger.log(Level.WARNING, () -> "Cannot queue message '" + shorten(message) + "' for " + id); + } + } + + private void writeQueuedMessages() { + try { + while (true) { + final String message; + try { + message = writeQueue.take(); + } catch (final InterruptedException ex) { + return; + } + + // Check if we should exit the thread + if (message.equals(EXIT_MESSAGE)) { + logger.log(Level.FINE, () -> "Exiting write thread " + id); + return; + } + + final WebSocketSession safeSession = session; + try { + if (safeSession == null) + throw new Exception("No session"); + if (!safeSession.isOpen()) + throw new Exception("Session closed"); + safeSession.sendMessage(new TextMessage(message)); + } catch (final Exception ex) { + logger.log(Level.WARNING, ex, () -> "Cannot write '" + shorten(message) + "' for " + id); + + // Clear queue + String drop = writeQueue.take(); + while (drop != null) { + if (drop.equals(EXIT_MESSAGE)) { + logger.log(Level.FINE, () -> "Exiting write thread " + id); + return; + } + drop = writeQueue.take(); + } + } + } + } catch (Throwable ex) { + logger.log(Level.WARNING, "Write thread error for " + id, ex); + } + } + + /** + * Called when client sends a general message + * + * @param message {@link TextMessage}, its payload is expected to be JSON. + */ + public void handleTextMessage(TextMessage message) throws Exception { + final JsonNode json = objectMapper.readTree(message.getPayload()); + final JsonNode node = json.path("type"); + if (node.isMissingNode()) + throw new Exception("Missing 'type' in " + shorten(message.getPayload())); + final String type = node.asText(); + logger.log(Level.INFO, "Client message type: " + type); + } + + /** + * Clears all PVs + * + *

    Web socket calls this onClose(), + * but context may also call this again just in case + */ + public void dispose() { + // Exit write thread + try { + // Drop queued messages (which might be stuffed): + // We're closing and just need the EXIT_MESSAGE + writeQueue.clear(); + queueMessage(EXIT_MESSAGE); + // TODO: is this needed? + session.close(); + } catch (Throwable ex) { + logger.log(Level.WARNING, "Error disposing " + getId(), ex); + } + logger.log(Level.INFO, () -> "Web socket " + session.getId() + " closed"); + } +} diff --git a/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/websocket/WebSocketConfig.java b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/websocket/WebSocketConfig.java new file mode 100644 index 0000000000..f0e89df46d --- /dev/null +++ b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/websocket/WebSocketConfig.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2023 European Spallation Source ERIC. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ +package org.phoebus.service.saveandrestore.websocket; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +import java.util.List; +import java.util.logging.Logger; + +@SuppressWarnings("unused") +@Configuration +@EnableWebSocket +public class WebSocketConfig implements WebSocketConfigurer { + + @Autowired + public ObjectMapper objectMapper; + + @Autowired + private List sockets; + + @Autowired + private WebSocketHandler webSocketHandler; + + private final Logger logger = Logger.getLogger(WebSocketConfig.class.getName()); + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + registry.addHandler(webSocketHandler, "/web-socket"); + } +} diff --git a/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/websocket/WebSocketHandler.java b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/websocket/WebSocketHandler.java new file mode 100644 index 0000000000..2bcb98a0da --- /dev/null +++ b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/websocket/WebSocketHandler.java @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2023 European Spallation Source ERIC. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + * + */ + +package org.phoebus.service.saveandrestore.websocket; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.phoebus.applications.saveandrestore.model.websocket.SaveAndRestoreWebSocketMessage; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.PongMessage; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +import javax.annotation.PreDestroy; +import java.io.EOFException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Single web socket end-point routing messages to active {@link WebSocket} instances. + */ +@Component +public class WebSocketHandler extends TextWebSocketHandler { + + /** + * List of active {@link WebSocket} + */ + @SuppressWarnings("unused") + private List sockets = new ArrayList<>(); + + @SuppressWarnings("unused") + @Autowired + private ObjectMapper objectMapper; + + @SuppressWarnings("unused") + + private final Logger logger = Logger.getLogger(WebSocketHandler.class.getName()); + + /** + * Handles text message from web socket client + * + * @param session The {@link WebSocketSession} associated with the remote client. + * @param message Message sent by client + */ + @Override + public void handleTextMessage(@NonNull WebSocketSession session, @NonNull TextMessage message) { + try { + // Find the WebSocket instance associated with the WebSocketSession + Optional webSocketOptional = + sockets.stream().filter(webSocket -> webSocket.getId().equals(session.getId())).findFirst(); + if (webSocketOptional.isEmpty()) { + return; // Should only happen in case of timing issues? + } + webSocketOptional.get().handleTextMessage(message); + } catch (final Exception ex) { + logger.log(Level.WARNING, ex, () -> "Error for message " + shorten(message.getPayload())); + } + } + + /** + * Called when client connects. + * + * @param session Associated {@link WebSocketSession} + */ + @Override + public void afterConnectionEstablished(@NonNull WebSocketSession session) { + logger.log(Level.INFO, "Opening web socket session from remote " + session.getRemoteAddress().getAddress()); + WebSocket webSocket = new WebSocket(objectMapper, session); + sockets.add(webSocket); + } + + /** + * Called when web socket is closed. Depending on the web browser, {@link #handleTransportError(WebSocketSession, Throwable)} + * may be called first. + * + * @param session Associated {@link WebSocketSession} + * @param status See {@link CloseStatus} + */ + @Override + public void afterConnectionClosed(@NonNull WebSocketSession session, @NonNull CloseStatus status) { + Optional webSocketOptional = + sockets.stream().filter(webSocket -> webSocket.getId().equals(session.getId())).findFirst(); + if (webSocketOptional.isPresent()) { + logger.log(Level.INFO, "Closing web socket session from remote " + session.getRemoteAddress().getAddress()); + webSocketOptional.get().dispose(); + sockets.remove(webSocketOptional.get()); + } + } + + /** + * Depending on the web browser, this is called before {@link #afterConnectionClosed(WebSocketSession, CloseStatus)} + * when tab or browser is closes. + * + * @param session Associated {@link WebSocketSession} + * @param ex {@link Throwable} that should indicate reason + */ + @Override + public void handleTransportError(@NonNull WebSocketSession session, @NonNull Throwable ex) { + if (ex instanceof EOFException) + logger.log(Level.FINE, "Web Socket closed", ex); + else + logger.log(Level.WARNING, "Web Socket error", ex); + } + + /** + * Called when client sends ping message, i.e. a pong message is sent and time for last message + * in the {@link WebSocket} instance is refreshed. + * + * @param session Associated {@link WebSocketSession} + * @param message See {@link PongMessage} + */ + @Override + protected void handlePongMessage(@NonNull WebSocketSession session, @NonNull PongMessage message) { + logger.log(Level.INFO, "Got pong"); + // Find the WebSocket instance associated with this WebSocketSession + Optional webSocketOptional = + sockets.stream().filter(webSocket -> webSocket.getId().equals(session.getId())).findFirst(); + if (webSocketOptional.isEmpty()) { + return; // Should only happen in case of timing issues? + } + } + + /** + * @param message Potentially long message + * @return Message shorted to 200 chars + */ + private String shorten(final String message) { + if (message == null || message.length() < 200) + return message; + return message.substring(0, 200) + " ..."; + } + + @PreDestroy + public void cleanup() { + sockets.forEach(s -> { + logger.log(Level.INFO, "Disposing socket " + s.getId()); + s.dispose(); + }); + } + + public void sendMessage(SaveAndRestoreWebSocketMessage webSocketMessage) { + sockets.forEach(ws -> { + try { + ws.queueMessage(objectMapper.writeValueAsString(webSocketMessage)); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + }); + } +} diff --git a/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/persistence/dao/impl/DAOTestIT.java b/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/persistence/dao/impl/DAOTestIT.java index c16a335dcd..63a21f75d9 100644 --- a/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/persistence/dao/impl/DAOTestIT.java +++ b/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/persistence/dao/impl/DAOTestIT.java @@ -22,18 +22,35 @@ import co.elastic.clients.elasticsearch.indices.DeleteIndexRequest; import co.elastic.clients.elasticsearch.indices.ExistsRequest; import co.elastic.clients.transport.endpoints.BooleanResponse; -import org.epics.vtype.*; +import org.epics.vtype.Alarm; +import org.epics.vtype.AlarmSeverity; +import org.epics.vtype.AlarmStatus; +import org.epics.vtype.Display; +import org.epics.vtype.Time; +import org.epics.vtype.VDouble; +import org.epics.vtype.VInt; import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.TestInstance.Lifecycle; -import org.phoebus.applications.saveandrestore.model.*; +import org.phoebus.applications.saveandrestore.model.CompositeSnapshot; +import org.phoebus.applications.saveandrestore.model.CompositeSnapshotData; +import org.phoebus.applications.saveandrestore.model.ConfigPv; +import org.phoebus.applications.saveandrestore.model.Configuration; +import org.phoebus.applications.saveandrestore.model.ConfigurationData; +import org.phoebus.applications.saveandrestore.model.Node; +import org.phoebus.applications.saveandrestore.model.NodeType; +import org.phoebus.applications.saveandrestore.model.Snapshot; +import org.phoebus.applications.saveandrestore.model.SnapshotData; +import org.phoebus.applications.saveandrestore.model.SnapshotItem; +import org.phoebus.applications.saveandrestore.model.Tag; +import org.phoebus.applications.saveandrestore.model.TagData; import org.phoebus.applications.saveandrestore.model.search.Filter; import org.phoebus.applications.saveandrestore.model.search.SearchResult; import org.phoebus.service.saveandrestore.NodeNotFoundException; import org.phoebus.service.saveandrestore.persistence.config.ElasticConfig; -import org.phoebus.service.saveandrestore.persistence.dao.impl.elasticsearch.ConfigurationDataRepository; import org.phoebus.service.saveandrestore.persistence.dao.impl.elasticsearch.ElasticsearchDAO; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -46,9 +63,20 @@ import java.io.IOException; import java.time.Instant; -import java.util.*; - -import static org.junit.jupiter.api.Assertions.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; /** * Integration test to be executed against a running Elasticsearch 8.x instance. @@ -56,7 +84,7 @@ */ @TestInstance(Lifecycle.PER_CLASS) @SpringBootTest -@ContextConfiguration(classes = ElasticConfig.class) +@ContextConfiguration(classes = {ElasticConfig.class}) @TestPropertySource(locations = "classpath:test_application.properties") @Profile("IT") @SuppressWarnings("unused") @@ -65,9 +93,6 @@ public class DAOTestIT { @Autowired private ElasticsearchDAO nodeDAO; - @Autowired - private ConfigurationDataRepository configurationDataRepository; - @Autowired private ElasticsearchClient client; @@ -89,6 +114,13 @@ public void init() { time = Time.of(Instant.now()); alarm = Alarm.of(AlarmSeverity.NONE, AlarmStatus.NONE, "name"); display = Display.none(); + clearAllData(); + System.out.println(); + } + + @AfterEach + public void cleanUp(){ + clearAllData(); } @Test @@ -102,8 +134,6 @@ public void testNewNode() { assertEquals(NodeType.FOLDER, newNode.getNodeType()); // Check that the parent folder's last modified date is updated assertTrue(root.getLastModified().getTime() > lastModified.getTime()); - - clearAllData(); } @Test @@ -116,8 +146,6 @@ public void testNewFolderNoDuplicateName() { assertNotNull(nodeDAO.createNode(rootNode.getUniqueId(), folder1)); // Try to create a new folder with a different name in the same parent directory assertNotNull(nodeDAO.createNode(rootNode.getUniqueId(), folder2)); - - clearAllData(); } @Test @@ -166,8 +194,6 @@ public void testDeleteConfigurationAndPvs() { // This should not throw exception nodeDAO.createNode(topLevelFolderNode.getUniqueId(), config); - - clearAllData(); } @@ -215,7 +241,6 @@ public void testGetNodeAsConfig() { Node configFromDB = nodeDAO.getNode(newConfig.getUniqueId()); assertEquals(newConfig.getUniqueId(), configFromDB.getUniqueId()); - clearAllData(); } @Test @@ -252,7 +277,6 @@ public void testGetConfigForSnapshot() { assertNotNull(config); - clearAllData(); } @Test @@ -297,8 +321,6 @@ public void testDeleteSnapshotReferencedInCompositeSnapshot() { assertThrows(RuntimeException.class, () -> nodeDAO.deleteNode(snapshotNode.getUniqueId())); nodeDAO.deleteNode(compositeSnapshot.getCompositeSnapshotNode().getUniqueId()); - - clearAllData(); } @Test @@ -380,7 +402,6 @@ public void testUpdateCompositeSnapshot() { nodeDAO.deleteNode(compositeSnapshot.getCompositeSnapshotNode().getUniqueId()); - clearAllData(); } @Test @@ -434,8 +455,6 @@ public void testGetAllCompositeSnapshotData() { compositeSnapshotNodeIds.forEach(id -> nodeDAO.deleteNode(id)); - clearAllData(); - } @Test @@ -486,7 +505,6 @@ public void testTakeSnapshot() { snapshots = nodeDAO.getSnapshots(config.getUniqueId()); assertTrue(snapshots.isEmpty()); - clearAllData(); } @Test @@ -544,7 +562,6 @@ public void testUpdateSnapshot() { assertEquals("other snapshot name", snapshotNode.getName()); assertEquals("other comment", snapshotNode.getDescription()); - clearAllData(); } @Test @@ -603,7 +620,6 @@ public void testGetSnapshotItemsWithNullPvValues() { assertNull(snapshot1.getSnapshotData().getSnapshotItems()); - clearAllData(); } @Test @@ -679,7 +695,6 @@ public void testSnapshotTag() { tagList2 = n2.getTags(); assertFalse(tagList2.stream().anyMatch(t -> t.getName().equals(newTag.getName()))); - clearAllData(); } @Test @@ -701,7 +716,6 @@ public void testGetChildNodes() { List childNodes = nodeDAO.getChildNodes(rootNode.getUniqueId()); assertTrue(nodeDAO.getChildNodes(folder1.getUniqueId()).isEmpty()); - clearAllData(); } @Test @@ -722,7 +736,6 @@ public void testUpdateNode() { assertEquals("folderB", folderA.getName()); assertEquals(uniqueId, folderA.getUniqueId()); - clearAllData(); } @@ -824,8 +837,6 @@ public void testUpdateNodeType() { Node node = folderNode; assertThrows(IllegalArgumentException.class, () -> nodeDAO.updateNode(node, false)); - - clearAllData(); } @@ -842,7 +853,6 @@ public void testNoNameClash1() { Node n2 = Node.builder().nodeType(NodeType.CONFIGURATION).uniqueId("2").name("n1").build(); nodeDAO.createNode(topLevelFolderNode.getUniqueId(), n2); - clearAllData(); } @Test @@ -854,8 +864,6 @@ public void testNoNameClash() { nodeDAO.createNode(rootNode.getUniqueId(), n1); Node n2 = Node.builder().uniqueId("2").name("n2").build(); nodeDAO.createNode(rootNode.getUniqueId(), n2); - - clearAllData(); } @Test @@ -877,7 +885,6 @@ public void testUpdateNodeNewNameInvalid() { assertThrows(IllegalArgumentException.class, () -> nodeDAO.updateNode(node, false)); - clearAllData(); } @Test @@ -886,7 +893,6 @@ public void testCreateNodeWithNonNullUniqueId() { Node folder = Node.builder().name("Folder").nodeType(NodeType.FOLDER).uniqueId("uniqueid").build(); folder = nodeDAO.createNode(rootNode.getUniqueId(), folder); assertEquals("uniqueid", folder.getUniqueId()); - clearAllData(); } @Test @@ -914,8 +920,6 @@ public void testCreateFolderInConfigNode() { Node node = configNode; assertThrows(IllegalArgumentException.class, () -> nodeDAO.createNode(node.getUniqueId(), folderNode)); - - clearAllData(); } @Test @@ -936,8 +940,6 @@ public void testCreateConfigInConfigNode() { Node node = configNode; assertThrows(IllegalArgumentException.class, () -> nodeDAO.createNode(node.getUniqueId(), node)); - - clearAllData(); } @Test @@ -971,8 +973,6 @@ public void testCreateNameClash() { assertThrows(IllegalArgumentException.class, () -> nodeDAO.createNode(rootNode.getUniqueId(), anotherConfig)); - - clearAllData(); } @Test @@ -1008,8 +1008,6 @@ public void testFindParentFromPathElements() { found = nodeDAO.findParentFromPathElements(rootNode, "/a/d/c".split("/"), 1); assertNull(found); - - clearAllData(); } @Test @@ -1022,8 +1020,6 @@ public void testGetFromPathTwoNodes() { List nodes = nodeDAO.getFromPath("/a/b/c"); assertEquals(2, nodes.size()); - - clearAllData(); } @Test @@ -1044,8 +1040,6 @@ public void testGetFromPathOneNode() { nodes = nodeDAO.getFromPath("/a"); assertEquals(1, nodes.size()); assertEquals(a.getUniqueId(), nodes.get(0).getUniqueId()); - - clearAllData(); } @Test @@ -1060,8 +1054,6 @@ public void testGetFromPathZeroNodes() { nodes = nodeDAO.getFromPath("/a/x/c"); assertNull(nodes); - - clearAllData(); } @Test @@ -1091,8 +1083,6 @@ public void testGetFullPathNonExistingNode() { // This will throw NodeNotFoundException assertThrows(NodeNotFoundException.class, () -> nodeDAO.getFullPath("nonExisting")); - - clearAllData(); } @Test @@ -1103,8 +1093,6 @@ public void testGetFullPathRootNode() { nodeDAO.createNode(b.getUniqueId(), Node.builder().nodeType(NodeType.FOLDER).name("c").build()); assertEquals("/", nodeDAO.getFullPath(rootNode.getUniqueId())); - - clearAllData(); } @Test @@ -1115,8 +1103,6 @@ public void testGetFullPath() { Node c = nodeDAO.createNode(b.getUniqueId(), Node.builder().nodeType(NodeType.FOLDER).name("c").build()); assertEquals("/a/b/c", nodeDAO.getFullPath(c.getUniqueId())); - - clearAllData(); } @Test @@ -1137,8 +1123,6 @@ public void testMoveNodesInvalidId() { assertThrows(NodeNotFoundException.class, () -> nodeDAO.moveNodes(nodeIds, rootNode.getUniqueId(), "userName")); - - clearAllData(); } @Test @@ -1167,8 +1151,6 @@ public void testMoveConfiguration() { assertEquals(1, nodeDAO.getChildNodes(topLevelFolderNode2.getUniqueId()).size()); assertEquals(0, nodeDAO.getChildNodes(topLevelFolderNode.getUniqueId()).size()); - - clearAllData(); } @Test @@ -1197,8 +1179,6 @@ public void testMoveConfigurationNameClash() { assertThrows(IllegalArgumentException.class, () -> nodeDAO.moveNodes(List.of(_configNode.getUniqueId()), topLevelFolderNode2.getUniqueId(), "user")); - - clearAllData(); } @Test @@ -1222,8 +1202,6 @@ public void testMoveSnasphotToRoot() { assertThrows(IllegalArgumentException.class, () -> nodeDAO.moveNodes(List.of(uniqueId), rootNode.getUniqueId(), "user")); - - clearAllData(); } @Test @@ -1247,8 +1225,6 @@ public void testMoveSnasphotToConfiguration() { assertThrows(IllegalArgumentException.class, () -> nodeDAO.moveNodes(List.of(uniqueId), rootNode.getUniqueId(), "user")); - - clearAllData(); } @Test @@ -1285,8 +1261,6 @@ public void testMoveNodes() { // After move parent of source nodes should now have only one element assertEquals(1, nodeDAO.getChildNodes(folderNode.getUniqueId()).size()); - - clearAllData(); } @Test @@ -1301,8 +1275,6 @@ public void testCopyFolderToSameParent() { assertThrows(IllegalArgumentException.class, () -> nodeDAO.copyNodes(List.of(uniqueId), rootNode.getUniqueId(), "username")); - - clearAllData(); } @Test @@ -1320,8 +1292,6 @@ public void testCopyConfigToSameParent() { assertThrows(IllegalArgumentException.class, () -> nodeDAO.copyNodes(List.of(uniqueId), rootNode.getUniqueId(), "username")); - - clearAllData(); } @Test @@ -1342,8 +1312,6 @@ public void testCopyFolderNotSupported() { assertThrows(IllegalArgumentException.class, () -> nodeDAO.copyNodes(List.of(_childFolderNode.getUniqueId()), rootNode.getUniqueId(), "username")); - - clearAllData(); } @Test @@ -1375,8 +1343,6 @@ public void testCopyConfigToOtherParent() { List childNodes = nodeDAO.getChildNodes(rootNode.getUniqueId()); assertEquals(2, childNodes.size()); - - clearAllData(); } @Test @@ -1403,8 +1369,6 @@ public void testCopyMultipleFolders() { assertThrows(IllegalArgumentException.class, () -> nodeDAO.copyNodes(Arrays.asList(f1, f2), rootNode.getUniqueId(), "username")); - - clearAllData(); } @Test @@ -1431,8 +1395,6 @@ public void testCopyFolderAndConfig() { assertThrows(IllegalArgumentException.class, () -> nodeDAO.copyNodes(Arrays.asList(folderUniqueId, configUniqueId), rootNode.getUniqueId(), "username")); - - clearAllData(); } @Test @@ -1474,8 +1436,6 @@ public void testCopySnapshotToFolderNotSupported() { String uniqueId = folderNode1.getUniqueId(); assertThrows(IllegalArgumentException.class, () -> nodeDAO.copyNodes(List.of(snapshotId), uniqueId, "username")); - - clearAllData(); } @Test @@ -1516,12 +1476,10 @@ public void testCopySnapshotToConfiguration() { String snapshotId = snapshot.getSnapshotNode().getUniqueId(); - Node updatedConfigNode = + List newNodes = nodeDAO.copyNodes(List.of(snapshotId), configuration2.getConfigurationNode().getUniqueId(), "useername"); - assertEquals(1, nodeDAO.getChildNodes(updatedConfigNode.getUniqueId()).size()); - - clearAllData(); + assertEquals(1, newNodes.size()); } @Test @@ -1567,8 +1525,6 @@ public void testCopySnapshotToConfigurationPvListMismatch() { assertThrows(IllegalArgumentException.class, () -> nodeDAO.copyNodes(List.of(snapshotId), config2Id, "userName")); - - clearAllData(); } @Test @@ -1617,18 +1573,17 @@ public void testCopyCompositeSnapshot() { String compositeSnapshotId = compositeSnapshot.getCompositeSnapshotNode().getUniqueId(); - folderNode1 = nodeDAO.copyNodes(List.of(compositeSnapshotId), folderNode1.getUniqueId(), "user"); + List newNodes = nodeDAO.copyNodes(List.of(compositeSnapshotId), folderNode1.getUniqueId(), "user"); List childNodes = nodeDAO.getChildNodes(folderNode1.getUniqueId()); + assertEquals(1, newNodes.size()); assertEquals(1, childNodes.size()); // Make sure referenced nodes have been copied to copied composite snapshot assertEquals(1, nodeDAO.getCompositeSnapshotData(childNodes.get(0).getUniqueId()).getReferencedSnapshotNodes().size()); nodeDAO.deleteNode(childNodes.get(0).getUniqueId()); nodeDAO.deleteNode(compositeSnapshot.getCompositeSnapshotNode().getUniqueId()); - - clearAllData(); } @Test @@ -1692,8 +1647,6 @@ public void testCopyCompositeSnapshotToConfiguration() { () -> nodeDAO.copyNodes(List.of(compositeSnapshotId), config2Id, "user")); nodeDAO.deleteNode(compositeSnapshot.getCompositeSnapshotNode().getUniqueId()); - - clearAllData(); } @Test @@ -1769,8 +1722,6 @@ public void testIsContainedInSubTree() { assertFalse(nodeDAO.isContainedInSubtree(L2F2.getUniqueId(), rootNode.getUniqueId())); assertFalse(nodeDAO.isContainedInSubtree(L1F1.getUniqueId(), L1F1.getUniqueId())); assertFalse(nodeDAO.isContainedInSubtree(L2F1.getUniqueId(), L1F1.getUniqueId())); - - clearAllData(); } @Test @@ -1812,8 +1763,6 @@ public void testGetAllTags() { List tags = nodeDAO.getAllTags(); assertEquals(4, tags.size()); - - clearAllData(); } @Test @@ -1853,8 +1802,6 @@ public void testGetAllSnapshots() { List snapshotNodes = nodeDAO.getAllSnapshots(); assertEquals(1, snapshotNodes.size()); - - clearAllData(); } @Test @@ -1872,8 +1819,6 @@ public void testGetAllNodes() { List nodes = nodeDAO.getNodes(Arrays.asList(folderNode1.getUniqueId(), folderNode2.getUniqueId())); assertEquals(2, nodes.size()); - - clearAllData(); } /** @@ -1883,7 +1828,6 @@ private void clearAllData() { List childNodes = nodeDAO.getChildNodes(Node.ROOT_FOLDER_UNIQUE_ID); childNodes.forEach(node -> nodeDAO.deleteNode(node.getUniqueId())); nodeDAO.deleteAllFilters(); - } @@ -2030,8 +1974,6 @@ public void testCheckForPVNameDuplicates() { assertEquals("pv1", duplicates.get(0)); nodeDAO.deleteNode(compositeSnapshotNode.getUniqueId()); - - clearAllData(); } @Test @@ -2116,8 +2058,6 @@ public void testCheckForRejectedReferencedNodesInCompositeSnapshot() { compositeSnapshot.setCompositeSnapshotData(compositeSnapshotData); assertFalse(nodeDAO.checkCompositeSnapshotReferencedNodeTypes(compositeSnapshot)); - - clearAllData(); } @Test @@ -2200,8 +2140,6 @@ public void testGetSnapshotItemsFromCompositeSnapshot() { assertEquals(4, snapshotItems.size()); nodeDAO.deleteNode(compositeSnapshotNode.getUniqueId()); - - clearAllData(); } @Test @@ -2254,12 +2192,10 @@ public void testFilters() { unformattedQueryStringFilter = nodeDAO.saveFilter(unformattedQueryStringFilter); assertTrue(unformattedQueryStringFilter.getQueryString().contains("type=Folder,Configuration")); assertFalse(unformattedQueryStringFilter.getQueryString().contains("unsupoorted")); - - clearAllData(); } @Test - public void testSearchForPvs() { + public void testSearchForPvs() { Node rootNode = nodeDAO.getRootNode(); Node folderNode = Node.builder().name("folder").build(); @@ -2316,8 +2252,5 @@ public void testSearchForPvs() { searchResult = nodeDAO.search(searchParameters); // No pvs specified -> find all nodes. assertEquals(4, searchResult.getHitCount()); - - clearAllData(); - } } diff --git a/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/config/ControllersTestConfig.java b/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/config/ControllersTestConfig.java index 588f8c2c9a..fa8b973151 100644 --- a/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/config/ControllersTestConfig.java +++ b/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/config/ControllersTestConfig.java @@ -27,6 +27,7 @@ import org.phoebus.service.saveandrestore.persistence.dao.impl.elasticsearch.FilterRepository; import org.phoebus.service.saveandrestore.persistence.dao.impl.elasticsearch.SnapshotDataRepository; import org.phoebus.service.saveandrestore.search.SearchUtil; +import org.phoebus.service.saveandrestore.websocket.WebSocketHandler; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringBootConfiguration; import org.springframework.context.annotation.Bean; @@ -34,6 +35,7 @@ import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Profile; import org.springframework.util.Base64Utils; +import org.springframework.web.socket.WebSocketSession; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -129,4 +131,15 @@ public ExecutorService executorService() { public SnapshotUtil snapshotUtil() { return new SnapshotUtil(); } + + @Bean + public WebSocketSession webSocketSession(){ + return Mockito.mock(WebSocketSession.class); + } + + @Bean + public WebSocketHandler webSocketHandler(){ + return Mockito.mock(WebSocketHandler.class); + } + } diff --git a/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/controllers/CompositeSnapshotControllerTest.java b/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/controllers/CompositeSnapshotControllerTest.java index 8d4162cf24..1eb9fdfd21 100644 --- a/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/controllers/CompositeSnapshotControllerTest.java +++ b/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/controllers/CompositeSnapshotControllerTest.java @@ -21,6 +21,8 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -30,8 +32,10 @@ import org.phoebus.applications.saveandrestore.model.Node; import org.phoebus.applications.saveandrestore.model.NodeType; import org.phoebus.applications.saveandrestore.model.SnapshotItem; +import org.phoebus.applications.saveandrestore.model.websocket.SaveAndRestoreWebSocketMessage; import org.phoebus.service.saveandrestore.persistence.dao.NodeDAO; import org.phoebus.service.saveandrestore.web.config.ControllersTestConfig; +import org.phoebus.service.saveandrestore.websocket.WebSocketHandler; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.http.HttpHeaders; @@ -45,6 +49,8 @@ import java.util.List; import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.phoebus.service.saveandrestore.web.controllers.BaseController.JSON; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -77,6 +83,9 @@ public class CompositeSnapshotControllerTest { @Autowired private MockMvc mockMvc; + @Autowired + private WebSocketHandler webSocketHandler; + private final ObjectMapper objectMapper = new ObjectMapper(); private static CompositeSnapshot compositeSnapshot; @@ -91,8 +100,13 @@ public static void init() { compositeSnapshot.setCompositeSnapshotData(compositeSnapshotData); } + @AfterEach + public void resetMocks(){ + reset(nodeDAO, webSocketHandler); + } + @Test - public void testCreateCompositeSnapshot() throws Exception { + public void testCreateCompositeSnapshot1() throws Exception { when(nodeDAO.createCompositeSnapshot(Mockito.any(String.class), Mockito.any(CompositeSnapshot.class))).thenReturn(compositeSnapshot); @@ -110,27 +124,54 @@ public void testCreateCompositeSnapshot() throws Exception { // Make sure response contains expected data objectMapper.readValue(s, CompositeSnapshot.class); - request = put("/composite-snapshot?parentNodeId=id") + verify(webSocketHandler, times(1)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + } + + @Test + public void testCreateCompositeSnapshot2() throws Exception { + + when(nodeDAO.createCompositeSnapshot(Mockito.any(String.class), Mockito.any(CompositeSnapshot.class))).thenReturn(compositeSnapshot); + + String compositeSnapshotString = objectMapper.writeValueAsString(compositeSnapshot); + + MockHttpServletRequestBuilder request = put("/composite-snapshot?parentNodeId=id") .header(HttpHeaders.AUTHORIZATION, adminAuthorization) .contentType(JSON) .content(compositeSnapshotString); mockMvc.perform(request).andExpect(status().isOk()); - request = put("/composite-snapshot?parentNodeId=id") + verify(webSocketHandler, times(1)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + } + + @Test + public void testCreateCompositeSnapshot3() throws Exception { + + String compositeSnapshotString = objectMapper.writeValueAsString(compositeSnapshot); + + MockHttpServletRequestBuilder request = put("/composite-snapshot?parentNodeId=id") .header(HttpHeaders.AUTHORIZATION, readOnlyAuthorization) .contentType(JSON) .content(compositeSnapshotString); mockMvc.perform(request).andExpect(status().isForbidden()); - request = put("/composite-snapshot?parentNodeId=id") + verify(webSocketHandler, times(0)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + } + + @Test + public void testCreateCompositeSnapshot4() throws Exception { + + String compositeSnapshotString = objectMapper.writeValueAsString(compositeSnapshot); + + MockHttpServletRequestBuilder request = put("/composite-snapshot?parentNodeId=id") .contentType(JSON) .content(compositeSnapshotString); mockMvc.perform(request).andExpect(status().isUnauthorized()); - reset(nodeDAO); + verify(webSocketHandler, times(0)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + } @Test @@ -144,10 +185,12 @@ public void testCreateCompositeSnapshotWrongNodeType() throws Exception{ .contentType(JSON) .content(objectMapper.writeValueAsString(compositeSnapshot1)); mockMvc.perform(request).andExpect(status().isBadRequest()); + + verify(webSocketHandler, times(0)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); } @Test - public void testUpdateCompositeSnapshot() throws Exception { + public void testUpdateCompositeSnapshot1() throws Exception { Node node = Node.builder().uniqueId("c").nodeType(NodeType.COMPOSITE_SNAPSHOT).userName(demoUser).build(); CompositeSnapshot compositeSnapshot1 = new CompositeSnapshot(); @@ -170,16 +213,42 @@ public void testUpdateCompositeSnapshot() throws Exception { // Make sure response contains expected data objectMapper.readValue(s, CompositeSnapshot.class); + verify(webSocketHandler, times(1)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + } + + @Test + public void testUpdateCompositeSnapshot2() throws Exception { + + Node node = Node.builder().uniqueId("c").nodeType(NodeType.COMPOSITE_SNAPSHOT).userName(demoUser).build(); when(nodeDAO.getNode("c")).thenReturn(Node.builder().nodeType(NodeType.COMPOSITE_SNAPSHOT).uniqueId("c").userName("notUser").build()); - request = post("/composite-snapshot") + CompositeSnapshot compositeSnapshot1 = new CompositeSnapshot(); + compositeSnapshot1.setCompositeSnapshotNode(node); + + String compositeSnapshotString = objectMapper.writeValueAsString(compositeSnapshot1); + + MockHttpServletRequestBuilder request = post("/composite-snapshot") .header(HttpHeaders.AUTHORIZATION, userAuthorization) .contentType(JSON) .content(compositeSnapshotString); mockMvc.perform(request).andExpect(status().isForbidden()); - request = post("/composite-snapshot") + verify(webSocketHandler, times(0)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + } + + @Test + public void testUpdateCompositeSnapshot3() throws Exception { + + Node node = Node.builder().uniqueId("c").nodeType(NodeType.COMPOSITE_SNAPSHOT).userName(demoUser).build(); + when(nodeDAO.getNode("c")).thenReturn(Node.builder().nodeType(NodeType.COMPOSITE_SNAPSHOT).uniqueId("c").userName("notUser").build()); + + CompositeSnapshot compositeSnapshot1 = new CompositeSnapshot(); + compositeSnapshot1.setCompositeSnapshotNode(node); + + String compositeSnapshotString = objectMapper.writeValueAsString(compositeSnapshot1); + + MockHttpServletRequestBuilder request = post("/composite-snapshot") .header(HttpHeaders.AUTHORIZATION, readOnlyAuthorization) .contentType(JSON) .content(compositeSnapshotString); @@ -192,16 +261,30 @@ public void testUpdateCompositeSnapshot() throws Exception { mockMvc.perform(request).andExpect(status().isUnauthorized()); + verify(webSocketHandler, times(0)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + } + + @Test + public void testUpdateCompositeSnapshot4() throws Exception { + + Node node = Node.builder().uniqueId("c").nodeType(NodeType.COMPOSITE_SNAPSHOT).userName(demoUser).build(); when(nodeDAO.getNode("c")).thenReturn(Node.builder().nodeType(NodeType.COMPOSITE_SNAPSHOT).uniqueId("c").userName("notUser").build()); - request = post("/composite-snapshot") + CompositeSnapshot compositeSnapshot1 = new CompositeSnapshot(); + compositeSnapshot1.setCompositeSnapshotNode(node); + + String compositeSnapshotString = objectMapper.writeValueAsString(compositeSnapshot1); + + when(nodeDAO.updateCompositeSnapshot(compositeSnapshot1)).thenReturn(compositeSnapshot1); + + MockHttpServletRequestBuilder request = post("/composite-snapshot") .header(HttpHeaders.AUTHORIZATION, adminAuthorization) .contentType(JSON) .content(compositeSnapshotString); mockMvc.perform(request).andExpect(status().isOk()); - reset(nodeDAO); + verify(webSocketHandler, times(1)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); } @Test @@ -217,6 +300,8 @@ public void testUpdateCompositeSnapshotWrongNodeType() throws Exception{ .contentType(JSON) .content(objectMapper.writeValueAsString(compositeSnapshot1)); mockMvc.perform(request).andExpect(status().isBadRequest()); + + verify(webSocketHandler, times(0)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); } @Test @@ -236,7 +321,6 @@ public void testGetCompositeSnapshotData() throws Exception { // Make sure response contains expected data objectMapper.readValue(s, CompositeSnapshotData.class); - reset(nodeDAO); } @Test @@ -257,8 +341,6 @@ public void testGetCompositeSnapshotNodes() throws Exception { // Make sure response contains expected data objectMapper.readValue(s, new TypeReference>() { }); - - reset(nodeDAO); } @Test @@ -276,8 +358,6 @@ public void testGetCompositeSnapshotItems() throws Exception { // Make sure response contains expected data objectMapper.readValue(s, new TypeReference>() { }); - - reset(nodeDAO); } @Test @@ -317,7 +397,5 @@ public void testGetCompositeSnapshotConsistency() throws Exception { .content(objectMapper.writeValueAsString(List.of("id"))); mockMvc.perform(request).andExpect(status().isOk()); - - reset(nodeDAO); } } diff --git a/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/controllers/ConfigurationControllerTest.java b/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/controllers/ConfigurationControllerTest.java index bf355ff07f..bc114aa464 100644 --- a/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/controllers/ConfigurationControllerTest.java +++ b/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/controllers/ConfigurationControllerTest.java @@ -20,17 +20,26 @@ package org.phoebus.service.saveandrestore.web.controllers; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; + +import org.mockito.Mockito; import org.phoebus.applications.saveandrestore.model.Comparison; import org.phoebus.applications.saveandrestore.model.ConfigPv; + import org.phoebus.applications.saveandrestore.model.Configuration; import org.phoebus.applications.saveandrestore.model.ConfigurationData; import org.phoebus.applications.saveandrestore.model.Node; import org.phoebus.applications.saveandrestore.model.NodeType; + +import org.phoebus.applications.saveandrestore.model.websocket.SaveAndRestoreWebSocketMessage; + import org.phoebus.applications.saveandrestore.model.ComparisonMode; + import org.phoebus.service.saveandrestore.persistence.dao.NodeDAO; import org.phoebus.service.saveandrestore.web.config.ControllersTestConfig; +import org.phoebus.service.saveandrestore.websocket.WebSocketHandler; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.http.HttpHeaders; @@ -43,6 +52,8 @@ import java.util.List; import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.phoebus.service.saveandrestore.web.controllers.BaseController.JSON; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -76,10 +87,16 @@ public class ConfigurationControllerTest { @Autowired private String demoUser; - @Test - public void testCreateConfiguration() throws Exception { + @Autowired + private WebSocketHandler webSocketHandler; - reset(nodeDAO); + @AfterEach + public void resetMocks(){ + reset(nodeDAO, webSocketHandler); + } + + @Test + public void testCreateConfiguration1() throws Exception { Configuration configuration = new Configuration(); configuration.setConfigurationNode(Node.builder().build()); @@ -93,26 +110,56 @@ public void testCreateConfiguration() throws Exception { mockMvc.perform(request).andExpect(status().isOk()); - request = put("/config?parentNodeId=a") + verify(webSocketHandler, times(1)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + } + + @Test + public void testCreateConfiguration2() throws Exception { + + Configuration configuration = new Configuration(); + configuration.setConfigurationNode(Node.builder().build()); + + MockHttpServletRequestBuilder request = put("/config?parentNodeId=a") .header(HttpHeaders.AUTHORIZATION, userAuthorization) .contentType(JSON).content(objectMapper.writeValueAsString(configuration)); mockMvc.perform(request).andExpect(status().isOk()); - request = put("/config?parentNodeId=a") + verify(webSocketHandler, times(1)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + } + + @Test + public void testCreateConfiguration3() throws Exception { + + + Configuration configuration = new Configuration(); + configuration.setConfigurationNode(Node.builder().build()); + + MockHttpServletRequestBuilder request = put("/config?parentNodeId=a") .header(HttpHeaders.AUTHORIZATION, readOnlyAuthorization) .contentType(JSON).content(objectMapper.writeValueAsString(configuration)); mockMvc.perform(request).andExpect(status().isForbidden()); - request = put("/config?parentNodeId=a") + verify(webSocketHandler, times(0)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + } + + @Test + public void testCreateConfiguration4() throws Exception{ + + Configuration configuration = new Configuration(); + configuration.setConfigurationNode(Node.builder().build()); + + MockHttpServletRequestBuilder request = put("/config?parentNodeId=a") .contentType(JSON).content(objectMapper.writeValueAsString(configuration)); mockMvc.perform(request).andExpect(status().isUnauthorized()); + + verify(webSocketHandler, times(0)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); } @Test - public void testUpdateConfiguration() throws Exception { + public void testUpdateConfiguration1() throws Exception { Node configurationNode = Node.builder().uniqueId("uniqueId").nodeType(NodeType.CONFIGURATION).userName(demoUser).build(); @@ -127,51 +174,97 @@ public void testUpdateConfiguration() throws Exception { mockMvc.perform(request).andExpect(status().isOk()); + verify(webSocketHandler, times(1)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + } + + @Test + public void tesUpdateConfiguration2() throws Exception { + + Node configurationNode = Node.builder().uniqueId("uniqueId").nodeType(NodeType.CONFIGURATION).userName(demoUser).build(); + + Configuration configuration = new Configuration(); + configuration.setConfigurationNode(configurationNode); + when(nodeDAO.getNode("uniqueId")).thenReturn(configurationNode); - request = post("/config") + MockHttpServletRequestBuilder request = post("/config") .header(HttpHeaders.AUTHORIZATION, userAuthorization) .contentType(JSON).content(objectMapper.writeValueAsString(configuration)); mockMvc.perform(request).andExpect(status().isOk()); - configurationNode = Node.builder().uniqueId("uniqueId").nodeType(NodeType.CONFIGURATION).userName("someUser").build(); + verify(webSocketHandler, times(1)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + } + + @Test + public void testUpdateConfiguration3() throws Exception { + + Configuration configuration = new Configuration(); + Node configurationNode = Node.builder().uniqueId("uniqueId").nodeType(NodeType.CONFIGURATION).userName("someUser").build(); configuration.setConfigurationNode(configurationNode); when(nodeDAO.getNode("uniqueId")).thenReturn(configurationNode); - request = post("/config") + MockHttpServletRequestBuilder request = post("/config") .header(HttpHeaders.AUTHORIZATION, adminAuthorization) .contentType(JSON).content(objectMapper.writeValueAsString(configuration)); mockMvc.perform(request).andExpect(status().isOk()); + verify(webSocketHandler, times(1)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + } + + @Test + public void testUpdateConfiguration4() throws Exception { + + Configuration configuration = new Configuration(); + Node configurationNode = Node.builder().uniqueId("uniqueId").nodeType(NodeType.CONFIGURATION).userName("someUser").build(); + configuration.setConfigurationNode(configurationNode); + when(nodeDAO.getNode("uniqueId")).thenReturn(configurationNode); - request = post("/config") + MockHttpServletRequestBuilder request = post("/config") .header(HttpHeaders.AUTHORIZATION, userAuthorization) .contentType(JSON).content(objectMapper.writeValueAsString(configuration)); mockMvc.perform(request).andExpect(status().isForbidden()); + } + + @Test + public void testUpdateConfiguration5() throws Exception { + + Configuration configuration = new Configuration(); + Node configurationNode = Node.builder().uniqueId("uniqueId").nodeType(NodeType.CONFIGURATION).userName("someUser").build(); + configuration.setConfigurationNode(configurationNode); - request = post("/config") + MockHttpServletRequestBuilder request = post("/config") .header(HttpHeaders.AUTHORIZATION, readOnlyAuthorization) .contentType(JSON).content(objectMapper.writeValueAsString(configuration)); mockMvc.perform(request).andExpect(status().isForbidden()); - request = post("/config") + verify(webSocketHandler, times(0)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + } + + @Test + public void testUpdateConfiguration6() throws Exception{ + + Configuration configuration = new Configuration(); + Node configurationNode = Node.builder().uniqueId("uniqueId").nodeType(NodeType.CONFIGURATION).userName("someUser").build(); + configuration.setConfigurationNode(configurationNode); + + MockHttpServletRequestBuilder request = post("/config") .contentType(JSON).content(objectMapper.writeValueAsString(configuration)); - mockMvc.perform(request).andExpect(status().isUnauthorized() - ); + mockMvc.perform(request).andExpect(status().isUnauthorized()); + + verify(webSocketHandler, times(0)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + mockMvc.perform(request).andExpect(status().isUnauthorized()); } @Test public void testCreateInvalidConfiguration() throws Exception { - reset(nodeDAO); - Configuration configuration = new Configuration(); configuration.setConfigurationNode(Node.builder().build()); ConfigurationData configurationData = new ConfigurationData(); diff --git a/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/controllers/FilterControllerTest.java b/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/controllers/FilterControllerTest.java index dccca5a33e..3d268e201b 100644 --- a/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/controllers/FilterControllerTest.java +++ b/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/controllers/FilterControllerTest.java @@ -20,12 +20,15 @@ package org.phoebus.service.saveandrestore.web.controllers; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mockito; import org.phoebus.applications.saveandrestore.model.search.Filter; +import org.phoebus.applications.saveandrestore.model.websocket.SaveAndRestoreWebSocketMessage; import org.phoebus.service.saveandrestore.persistence.dao.NodeDAO; import org.phoebus.service.saveandrestore.web.config.ControllersTestConfig; +import org.phoebus.service.saveandrestore.websocket.WebSocketHandler; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.http.HttpHeaders; @@ -40,6 +43,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.phoebus.service.saveandrestore.web.controllers.BaseController.JSON; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; @@ -73,10 +78,16 @@ public class FilterControllerTest { @Autowired private String demoUser; - @Test - public void testSaveFilter() throws Exception { + @Autowired + private WebSocketHandler webSocketHandler; - reset(nodeDAO); + @AfterEach + public void resetMocks(){ + reset(webSocketHandler,nodeDAO); + } + + @Test + public void testSaveFilter1() throws Exception { Filter filter = new Filter(); filter.setName("name"); @@ -99,25 +110,68 @@ public void testSaveFilter() throws Exception { // Make sure response contains expected data objectMapper.readValue(s, Filter.class); - request = put("/filter") + verify(webSocketHandler, times(1)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + } + + @Test + public void testSaveFilter2() throws Exception { + + Filter filter = new Filter(); + filter.setName("name"); + filter.setQueryString("query"); + filter.setUser("user"); + + String filterString = objectMapper.writeValueAsString(filter); + + MockHttpServletRequestBuilder request = put("/filter") .header(HttpHeaders.AUTHORIZATION, adminAuthorization) .contentType(JSON) .content(filterString); mockMvc.perform(request).andExpect(status().isOk()); - request = put("/filter") + verify(webSocketHandler, times(1)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + + } + + @Test + public void testSaveFiliter3() throws Exception { + + Filter filter = new Filter(); + filter.setName("name"); + filter.setQueryString("query"); + filter.setUser("user"); + + String filterString = objectMapper.writeValueAsString(filter); + MockHttpServletRequestBuilder request = put("/filter") .header(HttpHeaders.AUTHORIZATION, readOnlyAuthorization) .contentType(JSON) .content(filterString); mockMvc.perform(request).andExpect(status().isForbidden()); - request = put("/filter") + verify(webSocketHandler, times(0)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + + } + + @Test + public void testSaveFiliter4() throws Exception{ + + Filter filter = new Filter(); + filter.setName("name"); + filter.setQueryString("query"); + filter.setUser("user"); + + String filterString = objectMapper.writeValueAsString(filter); + + MockHttpServletRequestBuilder request = put("/filter") .contentType(JSON) .content(filterString); mockMvc.perform(request).andExpect(status().isUnauthorized()); + + verify(webSocketHandler, times(0)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + } @Test @@ -134,28 +188,60 @@ public void testDeleteFilter() throws Exception { .contentType(JSON); mockMvc.perform(request).andExpect(status().isOk()); - request = delete("/filter/name") + verify(webSocketHandler, times(1)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + + } + + @Test + public void testDeleteFilter2() throws Exception { + + MockHttpServletRequestBuilder request = delete("/filter/name") .header(HttpHeaders.AUTHORIZATION, adminAuthorization) .contentType(JSON); mockMvc.perform(request).andExpect(status().isOk()); - request = delete("/filter/name") - .header(HttpHeaders.AUTHORIZATION, readOnlyAuthorization) - .contentType(JSON); - mockMvc.perform(request).andExpect(status().isForbidden()); + verify(webSocketHandler, times(1)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + + } - request = delete("/filter/name") + @Test + public void testDeleteFilter3() throws Exception { + + MockHttpServletRequestBuilder request = delete("/filter/name") + .header(HttpHeaders.AUTHORIZATION, readOnlyAuthorization) + .contentType(JSON); + mockMvc.perform(request).andExpect(status().isForbidden()); + + verify(webSocketHandler, times(0)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + + } + + @Test + public void testDeleteFilter4() throws Exception { + MockHttpServletRequestBuilder request = delete("/filter/name") .contentType(JSON); mockMvc.perform(request).andExpect(status().isUnauthorized()); + verify(webSocketHandler, times(0)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + + } + + @Test + public void testDeleteFilter5() throws Exception{ + Filter filter = new Filter(); + filter.setName("name"); + filter.setQueryString("query"); filter.setUser("notUser"); when(nodeDAO.getAllFilters()).thenReturn(List.of(filter)); - request = delete("/filter/name") + MockHttpServletRequestBuilder request = delete("/filter/name") .header(HttpHeaders.AUTHORIZATION, userAuthorization) .contentType(JSON); mockMvc.perform(request).andExpect(status().isForbidden()); + verify(webSocketHandler, times(0)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + + } @Test diff --git a/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/controllers/NodeControllerTest.java b/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/controllers/NodeControllerTest.java index 37fc550b12..677225fd74 100644 --- a/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/controllers/NodeControllerTest.java +++ b/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/controllers/NodeControllerTest.java @@ -20,6 +20,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -33,9 +34,12 @@ import org.phoebus.applications.saveandrestore.model.Node; import org.phoebus.applications.saveandrestore.model.NodeType; import org.phoebus.applications.saveandrestore.model.Tag; +import org.phoebus.applications.saveandrestore.model.websocket.MessageType; +import org.phoebus.applications.saveandrestore.model.websocket.SaveAndRestoreWebSocketMessage; import org.phoebus.service.saveandrestore.NodeNotFoundException; import org.phoebus.service.saveandrestore.persistence.dao.NodeDAO; import org.phoebus.service.saveandrestore.web.config.ControllersTestConfig; +import org.phoebus.service.saveandrestore.websocket.WebSocketHandler; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.http.HttpHeaders; @@ -49,9 +53,12 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.phoebus.service.saveandrestore.web.controllers.BaseController.JSON; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; @@ -77,6 +84,9 @@ public class NodeControllerTest { @Autowired private MockMvc mockMvc; + @Autowired + private WebSocketHandler webSocketHandler; + private static Node folderFromClient; private static Node config1; @@ -107,6 +117,11 @@ public static void setUp() { folderFromClient = Node.builder().name("SomeFolder").userName("myusername").uniqueId("11").build(); } + @AfterEach + public void resetMocks(){ + reset(webSocketHandler, nodeDAO); + } + @Test public void testCreateFolder() throws Exception { @@ -161,8 +176,6 @@ public void testCreateFolderParentIdDoesNotExist() throws Exception { @Test public void testCreateConfig() throws Exception { - reset(nodeDAO); - Node config = Node.builder().nodeType(NodeType.CONFIGURATION).name("config").uniqueId("hhh") .userName("user").build(); @@ -273,7 +286,6 @@ public void testCreateConfigWithBadToleranceData2() throws Exception { @Test public void testUpdateConfig() throws Exception { - reset(nodeDAO); Node config = Node.builder().nodeType(NodeType.CONFIGURATION).name("config").uniqueId("hhh") .userName("user").build(); @@ -328,7 +340,6 @@ public void testUpdateConfig() throws Exception { @Test public void testCreateNodeBadRequests() throws Exception { - reset(nodeDAO); Node config = Node.builder().nodeType(NodeType.FOLDER).uniqueId("hhh") .userName("valid").build(); @@ -349,7 +360,6 @@ public void testCreateNodeBadRequests() throws Exception { @Test public void testGetChildNodes() throws Exception { - reset(nodeDAO); when(nodeDAO.getChildNodes("p")).thenAnswer((Answer>) invocation -> Collections.singletonList(config1)); @@ -367,7 +377,6 @@ public void testGetChildNodes() throws Exception { @Test public void testGetChildNodesNonExistingNode() throws Exception { - reset(nodeDAO); when(nodeDAO.getChildNodes("non-existing")).thenThrow(NodeNotFoundException.class); MockHttpServletRequestBuilder request = get("/node/non-existing/children").contentType(JSON); @@ -419,6 +428,7 @@ public void testDeleteFolder() throws Exception { mockMvc.perform(request).andExpect(status().isUnauthorized()); when(nodeDAO.getNode("a")).thenReturn(Node.builder().uniqueId("a").userName(demoUser).build()); + when(nodeDAO.getParentNode("a")).thenReturn(Node.builder().uniqueId("b").build()); request = delete("/node") @@ -426,62 +436,115 @@ public void testDeleteFolder() throws Exception { .header(HttpHeaders.AUTHORIZATION, userAuthorization); mockMvc.perform(request).andExpect(status().isOk()); + verify(webSocketHandler, times(1)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + } + + @Test + public void testDeleteForbiddenAccess() throws Exception { + when(nodeDAO.getNode("a")).thenReturn(Node.builder().uniqueId("a").userName("notDemoUser").build()); - request = + MockHttpServletRequestBuilder request = delete("/node") .contentType(JSON).content(objectMapper.writeValueAsString(List.of("a"))) .header(HttpHeaders.AUTHORIZATION, userAuthorization); mockMvc.perform(request).andExpect(status().isForbidden()); + verify(webSocketHandler, times(0)).sendMessage(new SaveAndRestoreWebSocketMessage(MessageType.NODE_REMOVED, "b")); + } + + @Test + public void testDeleteForbiddenAccess2() throws Exception { + when(nodeDAO.getNode("a")).thenReturn(Node.builder().uniqueId("a").userName(demoUser).build()); + when(nodeDAO.getParentNode("a")).thenReturn(Node.builder().uniqueId("b").build()); - request = + MockHttpServletRequestBuilder request = delete("/node") .contentType(JSON).content(objectMapper.writeValueAsString(List.of("a"))) .header(HttpHeaders.AUTHORIZATION, readOnlyAuthorization); mockMvc.perform(request).andExpect(status().isForbidden()); + verify(webSocketHandler, times(0)).sendMessage(new SaveAndRestoreWebSocketMessage(MessageType.NODE_REMOVED, "b")); + when(nodeDAO.getNode("a")).thenReturn(Node.builder().uniqueId("a").nodeType(NodeType.CONFIGURATION).userName(demoUser).build()); when(nodeDAO.getChildNodes("a")).thenReturn(Collections.emptyList()); - request = + verify(webSocketHandler, times(0)).sendMessage(new SaveAndRestoreWebSocketMessage(MessageType.NODE_REMOVED, "b")); + + } + + @Test + public void testDeleteFolder2() throws Exception { + + when(nodeDAO.getNode("a")).thenReturn(Node.builder().uniqueId("a").userName(demoUser).build()); + when(nodeDAO.getParentNode("a")).thenReturn(Node.builder().uniqueId("b").build()); + //when(nodeDAO.deleteNodes(List.of("a"))).thenReturn(Set.of("b")); + + MockHttpServletRequestBuilder request = delete("/node") .contentType(JSON).content(objectMapper.writeValueAsString(List.of("a"))) .header(HttpHeaders.AUTHORIZATION, userAuthorization); mockMvc.perform(request).andExpect(status().isOk()); + verify(webSocketHandler, times(1)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + + } + + @Test + public void testDeleteFolder3() throws Exception { + when(nodeDAO.getNode("a")).thenReturn(Node.builder().uniqueId("a").nodeType(NodeType.FOLDER).userName(demoUser).build()); + when(nodeDAO.getParentNode("a")).thenReturn(Node.builder().uniqueId("b").build()); when(nodeDAO.getChildNodes("a")).thenReturn(Collections.emptyList()); - request = + MockHttpServletRequestBuilder request = delete("/node") .contentType(JSON).content(objectMapper.writeValueAsString(List.of("a"))) .header(HttpHeaders.AUTHORIZATION, userAuthorization); mockMvc.perform(request).andExpect(status().isOk()); + verify(webSocketHandler, times(1)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + } + + @Test + public void testDeleteForbidden3() throws Exception{ + when(nodeDAO.getNode("a")).thenReturn(Node.builder().uniqueId("a").nodeType(NodeType.CONFIGURATION).userName(demoUser).build()); when(nodeDAO.getChildNodes("a")).thenReturn(List.of(Node.builder().build())); - request = + MockHttpServletRequestBuilder request = delete("/node") .contentType(JSON).content(objectMapper.writeValueAsString(List.of("a"))) .header(HttpHeaders.AUTHORIZATION, userAuthorization); mockMvc.perform(request).andExpect(status().isForbidden()); + verify(webSocketHandler, times(0)).sendMessage(new SaveAndRestoreWebSocketMessage(MessageType.NODE_REMOVED, "b")); + } + + @Test + public void testDeleteForbidden4() throws Exception{ + when(nodeDAO.getNode("a")).thenReturn(Node.builder().uniqueId("a").nodeType(NodeType.FOLDER).userName(demoUser).build()); when(nodeDAO.getChildNodes("a")).thenReturn(List.of(Node.builder().build())); - request = + MockHttpServletRequestBuilder request = delete("/node") .contentType(JSON).content(objectMapper.writeValueAsString(List.of("a"))) .header(HttpHeaders.AUTHORIZATION, userAuthorization); mockMvc.perform(request).andExpect(status().isForbidden()); + verify(webSocketHandler, times(0)).sendMessage(new SaveAndRestoreWebSocketMessage(MessageType.NODE_REMOVED, "b")); + + } + + @Test + public void testDeleteFolder5() throws Exception{ + when(nodeDAO.getNode("a")).thenReturn(Node.builder().uniqueId("a").nodeType(NodeType.CONFIGURATION).userName(demoUser).build()); when(nodeDAO.getChildNodes("a")).thenReturn(List.of(Node.builder().build())); - request = + MockHttpServletRequestBuilder request = delete("/node") .contentType(JSON).content(objectMapper.writeValueAsString(List.of("a"))) .header(HttpHeaders.AUTHORIZATION, adminAuthorization); @@ -513,8 +576,6 @@ public void testGetFolder() throws Exception { @Test public void testGetConfiguration() throws Exception { - Mockito.reset(nodeDAO); - when(nodeDAO.getNode("a")).thenReturn(Node.builder().build()); MockHttpServletRequestBuilder request = get("/node/a"); @@ -528,7 +589,6 @@ public void testGetConfiguration() throws Exception { @Test public void testGetNonExistingConfiguration() throws Exception { - Mockito.reset(nodeDAO); when(nodeDAO.getNode("a")).thenThrow(NodeNotFoundException.class); MockHttpServletRequestBuilder request = get("/node/a"); @@ -540,8 +600,6 @@ public void testGetNonExistingConfiguration() throws Exception { @Test public void testGetNonExistingFolder() throws Exception { - - Mockito.reset(nodeDAO); when(nodeDAO.getNode("a")).thenThrow(NodeNotFoundException.class); MockHttpServletRequestBuilder request = get("/node/a"); @@ -570,8 +628,6 @@ public void testGetFolderIllegalArgument() throws Exception { @Test public void testUpdateNode() throws Exception { - reset(nodeDAO); - Node node = Node.builder().name("foo").uniqueId("a").userName(demoUser).build(); when(nodeDAO.getNode("a")).thenReturn(node); @@ -726,7 +782,6 @@ public void testCreateNodeWithValidTags1() throws Exception { mockMvc.perform(request).andExpect(status().isOk()); - reset(nodeDAO); } @Test @@ -750,6 +805,5 @@ public void testCreateNodeWithValidTags2() throws Exception { mockMvc.perform(request).andExpect(status().isOk()); - reset(nodeDAO); } } diff --git a/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/controllers/SnapshotControllerTest.java b/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/controllers/SnapshotControllerTest.java index 5424ce6965..43dc9378e9 100644 --- a/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/controllers/SnapshotControllerTest.java +++ b/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/controllers/SnapshotControllerTest.java @@ -20,6 +20,7 @@ package org.phoebus.service.saveandrestore.web.controllers; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mockito; @@ -27,8 +28,10 @@ import org.phoebus.applications.saveandrestore.model.Node; import org.phoebus.applications.saveandrestore.model.NodeType; import org.phoebus.applications.saveandrestore.model.Snapshot; +import org.phoebus.applications.saveandrestore.model.websocket.SaveAndRestoreWebSocketMessage; import org.phoebus.service.saveandrestore.persistence.dao.NodeDAO; import org.phoebus.service.saveandrestore.web.config.ControllersTestConfig; +import org.phoebus.service.saveandrestore.websocket.WebSocketHandler; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.http.HttpHeaders; @@ -40,7 +43,11 @@ import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import java.util.List; +import java.util.Set; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.phoebus.service.saveandrestore.web.controllers.BaseController.JSON; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; @@ -75,6 +82,14 @@ public class SnapshotControllerTest { @Autowired private String demoUser; + @Autowired + private WebSocketHandler webSocketHandler; + + @AfterEach + public void resetMocks(){ + reset(webSocketHandler, nodeDAO); + } + @Test public void testSaveSnapshotWrongNodeType() throws Exception { @@ -92,6 +107,8 @@ public void testSaveSnapshotWrongNodeType() throws Exception { .content(objectMapper.writeValueAsString(snapshot)); mockMvc.perform(request).andExpect(status().isBadRequest()); + + verify(webSocketHandler, times(0)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); } @Test @@ -105,10 +122,12 @@ public void testSaveSnapshotNoParentNodeId() throws Exception { .content(objectMapper.writeValueAsString(snapshot)); mockMvc.perform(request).andExpect(status().isBadRequest()); + + verify(webSocketHandler, times(0)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); } @Test - public void testCreateSnapshot() throws Exception { + public void testCreateSnapshot1() throws Exception { Node node = Node.builder().uniqueId("uniqueId").nodeType(NodeType.SNAPSHOT).userName(demoUser).build(); Snapshot snapshot = new Snapshot(); snapshot.setSnapshotNode(node); @@ -130,7 +149,19 @@ public void testCreateSnapshot() throws Exception { // Make sure response contains expected data objectMapper.readValue(result.getResponse().getContentAsString(), Snapshot.class); - request = put("/snapshot?parentNodeId=a") + verify(webSocketHandler, times(1)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + } + + @Test + public void testCreateSnapshot2() throws Exception { + + Node node = Node.builder().uniqueId("uniqueId").nodeType(NodeType.SNAPSHOT).userName(demoUser).build(); + Snapshot snapshot = new Snapshot(); + snapshot.setSnapshotNode(node); + + String snapshotString = objectMapper.writeValueAsString(snapshot); + + MockHttpServletRequestBuilder request = put("/snapshot?parentNodeId=a") .contentType(JSON) .content(snapshotString); mockMvc.perform(request).andExpect(status().isUnauthorized()); @@ -141,15 +172,29 @@ public void testCreateSnapshot() throws Exception { .content(snapshotString); mockMvc.perform(request).andExpect(status().isForbidden()); - request = put("/snapshot?parentNodeId=a") + verify(webSocketHandler, times(0)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + } + + @Test + public void testCreateSnapshot3() throws Exception{ + + Node node = Node.builder().uniqueId("uniqueId").nodeType(NodeType.SNAPSHOT).userName(demoUser).build(); + Snapshot snapshot = new Snapshot(); + snapshot.setSnapshotNode(node); + + String snapshotString = objectMapper.writeValueAsString(snapshot); + + MockHttpServletRequestBuilder request = put("/snapshot?parentNodeId=a") .header(HttpHeaders.AUTHORIZATION, adminAuthorization) .contentType(JSON) .content(snapshotString); mockMvc.perform(request).andExpect(status().isOk()); + + verify(webSocketHandler, times(1)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); } @Test - public void testUpdateSnapshot() throws Exception { + public void testUpdateSnapshot1() throws Exception { Node node = Node.builder().uniqueId("s").nodeType(NodeType.SNAPSHOT).userName(demoUser).build(); Snapshot snapshot = new Snapshot(); snapshot.setSnapshotNode(node); @@ -171,30 +216,72 @@ public void testUpdateSnapshot() throws Exception { // Make sure response contains expected data objectMapper.readValue(result.getResponse().getContentAsString(), Snapshot.class); - request = put("/snapshot") + verify(webSocketHandler, times(1)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + } + + @Test + public void testUpdateSnapshot2() throws Exception { + + Node node = Node.builder().uniqueId("s").nodeType(NodeType.SNAPSHOT).userName(demoUser).build(); + Snapshot snapshot = new Snapshot(); + snapshot.setSnapshotNode(node); + + String snapshotString = objectMapper.writeValueAsString(snapshot); + + MockHttpServletRequestBuilder request = put("/snapshot") .contentType(JSON) .content(snapshotString); mockMvc.perform(request).andExpect(status().isUnauthorized()); - request = post("/snapshot") + verify(webSocketHandler, times(0)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + } + + @Test + public void testUpdateSnapshot3() throws Exception { + + Node node = Node.builder().uniqueId("s").nodeType(NodeType.SNAPSHOT).userName(demoUser).build(); + Snapshot snapshot = new Snapshot(); + snapshot.setSnapshotNode(node); + + String snapshotString = objectMapper.writeValueAsString(snapshot); + + MockHttpServletRequestBuilder request = post("/snapshot") .header(HttpHeaders.AUTHORIZATION, readOnlyAuthorization) .contentType(JSON) .content(snapshotString); mockMvc.perform(request).andExpect(status().isForbidden()); - request = post("/snapshot") + verify(webSocketHandler, times(0)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + } + + @Test + public void testUpdateSnapshot4() throws Exception{ + + Node node = Node.builder().uniqueId("s").nodeType(NodeType.SNAPSHOT).userName(demoUser).build(); + Snapshot snapshot = new Snapshot(); + snapshot.setSnapshotNode(node); + + String snapshotString = objectMapper.writeValueAsString(snapshot); + + MockHttpServletRequestBuilder request = post("/snapshot") .header(HttpHeaders.AUTHORIZATION, adminAuthorization) .contentType(JSON) .content(snapshotString); mockMvc.perform(request).andExpect(status().isOk()); + + verify(webSocketHandler, times(1)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); } @Test - public void testDeleteSnapshot() throws Exception{ + public void testDeleteSnapshot1() throws Exception { - when(nodeDAO.getNode("a")).thenReturn(Node.builder() + Node node = Node.builder() .nodeType(NodeType.SNAPSHOT) - .uniqueId("a").userName(demoUser).build()); + .uniqueId("a").userName(demoUser).build(); + + when(nodeDAO.getNode("a")).thenReturn(node); + + //when(nodeDAO.deleteNodes(List.of("a"))).thenReturn(Set.of("a")); MockHttpServletRequestBuilder request = delete("/node") @@ -203,28 +290,51 @@ public void testDeleteSnapshot() throws Exception{ mockMvc.perform(request).andExpect(status().isOk()); - request = + verify(webSocketHandler, times(1)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + } + + @Test + public void testDeleteSnapshot2() throws Exception { + + MockHttpServletRequestBuilder request = delete("/node") .contentType(JSON).content(objectMapper.writeValueAsString(List.of("a"))) .header(HttpHeaders.AUTHORIZATION, readOnlyAuthorization); mockMvc.perform(request).andExpect(status().isForbidden()); - when(nodeDAO.getNode("a")).thenReturn(Node.builder() + verify(webSocketHandler, times(0)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + } + + @Test + public void testDeleteSnapshot3() throws Exception { + + Node node = Node.builder() .nodeType(NodeType.SNAPSHOT) - .uniqueId("a").userName("otherUser").build()); + .uniqueId("a").userName("otherUser").build(); + + when(nodeDAO.getNode("a")).thenReturn(node); + //when(nodeDAO.deleteNodes(List.of("a"))).thenReturn(Set.of("a")); - request = + MockHttpServletRequestBuilder request = delete("/node") .contentType(JSON).content(objectMapper.writeValueAsString(List.of("a"))) .header(HttpHeaders.AUTHORIZATION, adminAuthorization); mockMvc.perform(request).andExpect(status().isOk()); - request = + verify(webSocketHandler, times(1)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + } + + @Test + public void testDeleteSnapshot4() throws Exception{ + + MockHttpServletRequestBuilder request = delete("/node") .contentType(JSON).content(objectMapper.writeValueAsString(List.of("a"))); mockMvc.perform(request).andExpect(status().isUnauthorized()); + + verify(webSocketHandler, times(0)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); } } diff --git a/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/controllers/StructureControllerTest.java b/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/controllers/StructureControllerTest.java index df736f8acc..67eeb88386 100644 --- a/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/controllers/StructureControllerTest.java +++ b/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/controllers/StructureControllerTest.java @@ -20,11 +20,15 @@ package org.phoebus.service.saveandrestore.web.controllers; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; import org.phoebus.applications.saveandrestore.model.Node; +import org.phoebus.applications.saveandrestore.model.websocket.SaveAndRestoreWebSocketMessage; import org.phoebus.service.saveandrestore.persistence.dao.NodeDAO; import org.phoebus.service.saveandrestore.web.config.ControllersTestConfig; +import org.phoebus.service.saveandrestore.websocket.WebSocketHandler; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.http.HttpHeaders; @@ -38,6 +42,9 @@ import java.util.Collections; import java.util.List; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.phoebus.service.saveandrestore.web.controllers.BaseController.JSON; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -73,9 +80,16 @@ public class StructureControllerTest { @Autowired private String readOnlyAuthorization; + @Autowired + private WebSocketHandler webSocketHandler; + + @AfterEach + public void resetMocks(){ + reset(webSocketHandler, nodeDAO); + } @Test - public void testMoveNode() throws Exception { + public void testMoveNode1() throws Exception { when(nodeDAO.moveNodes(List.of("a"), "b", demoAdmin)) .thenReturn(Node.builder().uniqueId("2").uniqueId("a").userName(demoAdmin).build()); @@ -92,10 +106,16 @@ public void testMoveNode() throws Exception { // Make sure response contains expected data objectMapper.readValue(result.getResponse().getContentAsString(), Node.class); + verify(webSocketHandler, times(2)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + } + + @Test + public void testMoveNode2() throws Exception{ + when(nodeDAO.moveNodes(List.of("a"), "b", demoUser)) .thenReturn(Node.builder().uniqueId("2").uniqueId("a").userName(demoUser).build()); - request = post("/move") + MockHttpServletRequestBuilder request = post("/move") .header(HttpHeaders.AUTHORIZATION, userAuthorization) .contentType(JSON) .content(objectMapper.writeValueAsString(List.of("a"))) @@ -104,8 +124,13 @@ public void testMoveNode() throws Exception { mockMvc.perform(request).andExpect(status().isForbidden()); + verify(webSocketHandler, times(0)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + } - request = post("/move") + @Test + public void testMoveNode3() throws Exception{ + + MockHttpServletRequestBuilder request = post("/move") .header(HttpHeaders.AUTHORIZATION, readOnlyAuthorization) .contentType(JSON) .content(objectMapper.writeValueAsString(List.of("a"))) @@ -121,6 +146,8 @@ public void testMoveNode() throws Exception { .param("username", "username"); mockMvc.perform(request).andExpect(status().isUnauthorized()); + + verify(webSocketHandler, times(0)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); } @Test @@ -133,6 +160,8 @@ public void testMoveNodeSourceNodeListEmpty() throws Exception { .param("username", "user"); mockMvc.perform(request).andExpect(status().isBadRequest()); + + verify(webSocketHandler, times(0)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); } @Test @@ -145,10 +174,12 @@ public void testMoveNodeTargetIdEmpty() throws Exception { .param("username", "user"); mockMvc.perform(request).andExpect(status().isBadRequest()); + + verify(webSocketHandler, times(0)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); } @Test - public void testCopyNodes() throws Exception { + public void testCopyNodes1() throws Exception { MockHttpServletRequestBuilder request = post("/copy") .header(HttpHeaders.AUTHORIZATION, adminAuthorization) .contentType(JSON) @@ -163,7 +194,13 @@ public void testCopyNodes() throws Exception { .param("to", "target"); mockMvc.perform(request).andExpect(status().isForbidden()); - request = post("/copy") + verify(webSocketHandler, times(0)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + } + + @Test + public void testCopyNodes2() throws Exception{ + + MockHttpServletRequestBuilder request = post("/copy") .contentType(JSON) .content(objectMapper.writeValueAsString(List.of("a"))) .param("to", "target"); @@ -171,8 +208,8 @@ public void testCopyNodes() throws Exception { } @Test - public void testCopyNodesBadRequest() throws Exception { - Node node = Node.builder().uniqueId("uniqueId").userName(demoUser).build(); + public void testCopyNodesBadRequest1() throws Exception { + MockHttpServletRequestBuilder request = post("/copy") .header(HttpHeaders.AUTHORIZATION, adminAuthorization) .contentType(JSON) @@ -180,17 +217,30 @@ public void testCopyNodesBadRequest() throws Exception { .param("to", ""); mockMvc.perform(request).andExpect(status().isBadRequest()); - request = post("/copy") + verify(webSocketHandler, times(0)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); + } + + @Test + public void testCopyNodesBadRequest2() throws Exception { + + MockHttpServletRequestBuilder request = post("/copy") .header(HttpHeaders.AUTHORIZATION, adminAuthorization) .contentType(JSON) .content(objectMapper.writeValueAsString(List.of("a"))); mockMvc.perform(request).andExpect(status().isBadRequest()); - request = post("/copy") + } + + @Test + public void testCopyNodesBadRequest3() throws Exception { + + MockHttpServletRequestBuilder request = post("/copy") .header(HttpHeaders.AUTHORIZATION, adminAuthorization) .contentType(JSON) .content(objectMapper.writeValueAsString(Collections.emptyList())) .param("to", "target"); mockMvc.perform(request).andExpect(status().isBadRequest()); + + verify(webSocketHandler, times(0)).sendMessage(Mockito.any(SaveAndRestoreWebSocketMessage.class)); } }