diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8b1e890f3d..00ee599c99 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,7 +14,7 @@ jobs: - name: Setup Maven and Java Action uses: s4u/setup-maven-action@v1.18.0 with: - java-version: '17' + java-version: '21' maven-version: '3.9.6' - name: Build run: mvn --batch-mode install -DskipTests \ No newline at end of file diff --git a/.github/workflows/build_latest.yml b/.github/workflows/build_latest.yml index 6d39ae0918..72bc62f3be 100644 --- a/.github/workflows/build_latest.yml +++ b/.github/workflows/build_latest.yml @@ -18,7 +18,7 @@ jobs: - name: Setup Maven and Java Action uses: s4u/setup-maven-action@v1.18.0 with: - java-version: '17' + java-version: '21' maven-version: '3.9.6' - name: Build run: mvn --batch-mode install -DskipTests diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/AlarmTreeConfigDialog.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/AlarmTreeConfigDialog.java new file mode 100644 index 0000000000..0667f5133d --- /dev/null +++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/AlarmTreeConfigDialog.java @@ -0,0 +1,138 @@ +package org.phoebus.applications.alarm.ui.tree; + +import javafx.beans.value.ChangeListener; +import javafx.geometry.Insets; +import javafx.scene.control.*; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import org.phoebus.applications.alarm.client.AlarmClient; +import org.phoebus.applications.alarm.model.AlarmTreeItem; +import org.phoebus.ui.dialog.ExceptionDetailsErrorDialog; + +import java.util.Optional; + +/** + * Dialog for configuring alarm tree items with path selection. + * Displays the alarm tree structure and allows user to select a path from the tree. + */ +public class AlarmTreeConfigDialog extends Dialog +{ + private final TextField pathInput; + + /** + * Constructor for AlarmTreeConfigDialog + * + * @param alarmClient The alarm client model to display the tree + * @param currentPath The current path (initial value for text input) + * @param title The title of the dialog + * @param headerText The header text of the dialog + */ + public AlarmTreeConfigDialog(AlarmClient alarmClient, String currentPath, String title, String headerText) + { + setTitle(title); + setHeaderText(headerText); + setResizable(true); + + // Create content + VBox content = new VBox(10); + content.setPadding(new Insets(15)); + + // Add AlarmTreeConfigView + AlarmTreeConfigView configView = new AlarmTreeConfigView(alarmClient); + configView.setPrefHeight(300); + configView.setPrefWidth(400); + + // Initialize path input first + pathInput = new TextField(); + pathInput.setText(currentPath != null ? currentPath : ""); + pathInput.setStyle("-fx-font-family: monospace;"); + pathInput.setPromptText("Select a path from the tree above or type manually"); + pathInput.setEditable(true); + + // Extract the last segment from the initial currentPath to preserve across selections + final String selectedTreeItem; + if (currentPath != null && !currentPath.isEmpty()) { + int lastSlashIndex = currentPath.lastIndexOf('/'); + if (lastSlashIndex >= 0 && lastSlashIndex < currentPath.length() - 1) { + selectedTreeItem = currentPath.substring(lastSlashIndex + 1); + } else { + selectedTreeItem = ""; + } + } else { + selectedTreeItem = ""; + } + + // Store the listener in a variable + ChangeListener>> selectionListener = (obs, oldVal, newVal) -> { + if (newVal != null && newVal.getValue() != null) + { + String selectedPath = newVal.getValue().getPathName(); + if (selectedPath != null && !selectedPath.isEmpty()) + { + // Only update if not focused + if (!pathInput.isFocused()) { + // Append the preserved last segment to the selected path + String newPath = selectedPath; + if (!selectedTreeItem.isEmpty()) { + newPath = selectedPath + "/" + selectedTreeItem; + } + + pathInput.setText(newPath); + } + } + } + }; + configView.addTreeSelectionListener(selectionListener); + // Remove the listener when the dialog is closed + this.setOnHidden(e -> configView.removeTreeSelectionListener(selectionListener)); + + // Add text input for path + Label pathLabel = new Label("Selected Path:"); + + content.getChildren().addAll( + configView, + pathLabel, + pathInput + ); + + // Make tree view grow to fill available space + VBox.setVgrow(configView, Priority.ALWAYS); + + getDialogPane().setContent(content); + getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); + getDialogPane().setPrefSize(500, 600); + + // Set result converter + setResultConverter(this::handleResult); + } + + /** + * Handle the dialog result + */ + private String handleResult(ButtonType buttonType) + { + if (buttonType == ButtonType.OK) + { + String path = pathInput.getText().trim(); + if (path.isEmpty()) + { + ExceptionDetailsErrorDialog.openError("Invalid Path", + "Path cannot be empty.", + null); + return null; + } + return path; + } + return null; + } + + /** + * Show the dialog and get the result + * + * @return Optional containing the path if OK was clicked, empty otherwise + */ + public Optional getPath() + { + return showAndWait(); + } +} diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/AlarmTreeConfigView.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/AlarmTreeConfigView.java new file mode 100644 index 0000000000..45b99ff4e5 --- /dev/null +++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/AlarmTreeConfigView.java @@ -0,0 +1,574 @@ +/******************************************************************************* + * Copyright (c) 2018-2023 Oak Ridge National Laboratory. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + *******************************************************************************/ +package org.phoebus.applications.alarm.ui.tree; + +import javafx.application.Platform; +import javafx.beans.value.ChangeListener; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.scene.Cursor; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.SelectionMode; +import javafx.scene.control.ToolBar; +import javafx.scene.control.Tooltip; +import javafx.scene.control.TreeItem; +import javafx.scene.control.TreeView; +import javafx.scene.layout.Background; +import javafx.scene.layout.BackgroundFill; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.CornerRadii; +import javafx.scene.paint.Color; +import org.phoebus.applications.alarm.AlarmSystem; +import org.phoebus.applications.alarm.client.AlarmClient; +import org.phoebus.applications.alarm.client.AlarmClientLeaf; +import org.phoebus.applications.alarm.client.AlarmClientListener; +import org.phoebus.applications.alarm.model.AlarmTreeItem; +import org.phoebus.applications.alarm.model.BasicState; +import org.phoebus.applications.alarm.ui.AlarmUI; +import org.phoebus.ui.javafx.ImageCache; +import org.phoebus.ui.javafx.ToolbarHelper; +import org.phoebus.ui.javafx.UpdateThrottle; +import org.phoebus.util.text.CompareNatural; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Level; + +import static org.phoebus.applications.alarm.AlarmSystem.logger; + +/** Tree-based UI for alarm configuration + * + *

Implemented as {@link BorderPane}, but should be treated + * as generic JavaFX Node, only calling public methods + * defined on this class. + * + * @author Kay Kasemir + */ +@SuppressWarnings("nls") +public class AlarmTreeConfigView extends BorderPane implements AlarmClientListener +{ + private final Label no_server = AlarmUI.createNoServerLabel(); + private final TreeView> tree_config_view = new TreeView<>(); + + /** Model with current alarm tree, sends updates */ + private final AlarmClient model; + private final String itemName; + + /** Latch for initially pausing model listeners + * + * Imagine a large alarm tree that changes. + * The alarm table can periodically display the current + * alarms, it does not need to display every change right away. + * The tree on the other hand must reflect every added or removed item, + * because updates cannot be applied once the tree structure gets out of sync. + * When the model is first started, there is a flurry of additions and removals, + * which arrive in the order in which the tree was generated, not necessarily + * in the order they're laid out in the hierarchy. + * These can be slow to render, especially if displaying via a remote desktop (ssh-X). + * The alarm tree view thus starts in stages: + * 1) Wait for model to receive the bulk of initial additions and removals + * 2) Add listeners to model changes, but block them via this latch + * 3) Represent the initial model + * 4) Release this latch to handle changes (blocked and those that arrive from now on) + */ + private final CountDownLatch block_item_changes = new CountDownLatch(1); + + /** Map from alarm tree path to view's TreeItem */ + private final ConcurrentHashMap>> path2view = new ConcurrentHashMap<>(); + + /** Items to update, ordered by time of original update request + * + * SYNC on access + */ + private final Set>> items_to_update = new LinkedHashSet<>(); + + /** Throttle [5Hz] used for updates of existing items */ + private final UpdateThrottle throttle = new UpdateThrottle(200, TimeUnit.MILLISECONDS, this::performUpdates); + + /** Cursor change doesn't work on Mac, so add indicator to toolbar */ + private final Label changing = new Label("Loading..."); + + /** Is change indicator shown, and future been submitted to clear it? */ + private final AtomicReference> ongoing_change = new AtomicReference<>(); + + /** Clear the change indicator */ + private final Runnable clear_change_indicator = () -> + Platform.runLater(() -> + { + logger.log(Level.INFO, "Alarm tree changes end"); + ongoing_change.set(null); + setCursor(null); + final ObservableList items = getToolbar().getItems(); + items.remove(changing); + }); + + // Javadoc for TreeItem shows example for overriding isLeaf() and getChildren() + // to dynamically create TreeItem as TreeView requests information. + // + // The alarm tree, however, keeps changing, and needs to locate the TreeItem + // for the changed AlarmTreeItem. + // Added code for checking if a TreeItem has been created, yet, + // can only make things slower, + // and the overall performance should not degrade when user opens more and more + // sections of the overall tree. + // --> Create the complete TreeItems ASAP and then keep updating to get + // constant performance? + + /** @param model Model to represent. Must not be running, yet */ + public AlarmTreeConfigView(final AlarmClient model) { + this(model, null); + } + + /** + * @param model Model to represent. Must not be running, yet + * @param itemName item name that may be expanded or given focus + */ + public AlarmTreeConfigView(final AlarmClient model, String itemName) + { + changing.setTextFill(Color.WHITE); + changing.setBackground(new Background(new BackgroundFill(Color.BLUE, CornerRadii.EMPTY, Insets.EMPTY))); + + this.model = model; + this.itemName = itemName; + + tree_config_view.setShowRoot(false); + tree_config_view.setCellFactory(view -> new AlarmTreeViewCell()); + tree_config_view.getSelectionModel().setSelectionMode(SelectionMode.SINGLE); + + setTop(createToolbar()); + setCenter(tree_config_view); + + if (AlarmSystem.alarm_tree_startup_ms <= 0) + { + // Original implementation: + // Create initial (empty) representation, + // register listener, then model gets started + block_item_changes.countDown(); + tree_config_view.setRoot(createViewItem(model.getRoot())); + model.addListener(AlarmTreeConfigView.this); + } + else + UpdateThrottle.TIMER.schedule(this::startup, AlarmSystem.alarm_tree_startup_ms, TimeUnit.MILLISECONDS); + + // Caller will start the model once we return from constructor + } + + private void startup() + { + // Waited for model to receive the bulk of initial additions and removals... + Platform.runLater(() -> + { + if (! model.isRunning()) + { + logger.log(Level.WARNING, model.getRoot().getName() + " was disposed while waiting for alarm tree startup"); + return; + } + // Listen to model changes, but they're blocked, + // so this blocks model changes from now on + model.addListener(AlarmTreeConfigView.this); + + // Represent model that should by now be fairly complete + tree_config_view.setRoot(createViewItem(model.getRoot())); + + // expand tree item if is matches item name + if (tree_config_view.getRoot() != null && itemName != null) { + for (TreeItem treeItem : tree_config_view.getRoot().getChildren()) { + if (((AlarmTreeItem) treeItem.getValue()).getName().equals(itemName)) { + expandAlarms(treeItem); + break; + } + } + } + + // Set change indicator so that it clears when there are no more changes + indicateChange(); + showServerState(model.isServerAlive()); + + // Un-block to handle changes from now on + block_item_changes.countDown(); + }); + } + + private ToolBar createToolbar() + { + final Button collapse = new Button("", + ImageCache.getImageView(AlarmUI.class, "/icons/collapse.png")); + collapse.setTooltip(new Tooltip("Collapse alarm tree")); + collapse.setOnAction(event -> + { + for (TreeItem> sub : tree_config_view.getRoot().getChildren()) + sub.setExpanded(false); + }); + + final Button show_alarms = new Button("", + ImageCache.getImageView(AlarmUI.class, "/icons/expand_alarms.png")); + show_alarms.setTooltip(new Tooltip("Expand alarm tree to show active alarms")); + show_alarms.setOnAction(event -> expandAlarms(tree_config_view.getRoot())); + + final Button show_disabled = new Button("", + ImageCache.getImageView(AlarmUI.class, "/icons/expand_disabled.png")); + show_disabled.setTooltip(new Tooltip("Expand alarm tree to show disabled PVs")); + show_disabled.setOnAction(event -> expandDisabledPVs(tree_config_view.getRoot())); + + return new ToolBar(no_server, changing, ToolbarHelper.createSpring(), collapse, show_alarms, show_disabled); + } + + ToolBar getToolbar() + { + return (ToolBar) getTop(); + } + + private void expandAlarms(final TreeItem> node) + { + if (node.isLeaf()) + return; + + // Always expand the root, which itself is not visible, + // but this will show all the top-level elements. + // In addition, expand those items which are in active alarm. + final boolean expand = node.getValue().getState().severity.isActive() || + node == tree_config_view.getRoot(); + node.setExpanded(expand); + for (TreeItem> sub : node.getChildren()) + expandAlarms(sub); + } + + /** @param node Subtree node where to expand disabled PVs + * @return Does subtree contain disabled PVs? + */ + private boolean expandDisabledPVs(final TreeItem> node) + { + if (node != null && (node.getValue() instanceof AlarmClientLeaf)) + { + AlarmClientLeaf pv = (AlarmClientLeaf) node.getValue(); + if (! pv.isEnabled()) + return true; + } + + // Always expand the root, which itself is not visible, + // but this will show all the top-level elements. + // In addition, expand those items which contain disabled PV. + boolean expand = node == tree_config_view.getRoot(); + for (TreeItem> sub : node.getChildren()) + if (expandDisabledPVs(sub)) + expand = true; + node.setExpanded(expand); + return expand; + } + + private TreeItem> createViewItem(final AlarmTreeItem model_item) + { + // Create view item for model item itself + final TreeItem> view_item = new TreeItem<>(model_item); + final TreeItem> previous = path2view.put(model_item.getPathName(), view_item); + if (previous != null) + throw new IllegalStateException("Found existing view item for " + model_item.getPathName()); + + // Create view items for model item's children + for (final AlarmTreeItem model_child : model_item.getChildren()) + view_item.getChildren().add(createViewItem(model_child)); + + return view_item; + } + + /** Called when an item is added/removed to tell user + * that there are changes to the tree structure, + * may not make sense to interact with the tree right now. + * + *

Resets on its own after 1 second without changes. + */ + private void indicateChange() + { + final ScheduledFuture previous = ongoing_change.getAndSet(UpdateThrottle.TIMER.schedule(clear_change_indicator, 1, TimeUnit.SECONDS)); + if (previous == null) + { + logger.log(Level.INFO, "Alarm tree changes start"); + setCursor(Cursor.WAIT); + final ObservableList items = getToolbar().getItems(); + if (! items.contains(changing)) + items.add(1, changing); + } + else + previous.cancel(false); + } + + /** @param alive Have we seen server messages? */ + private void showServerState(final boolean alive) + { + final ObservableList items = getToolbar().getItems(); + items.remove(no_server); + if (! alive) + // Place left of spring, collapse, expand_alarms, + // i.e. right of potential AlarmConfigSelector + items.add(items.size()-3, no_server); + } + + // AlarmClientModelListener + @Override + public void serverStateChanged(final boolean alive) + { + Platform.runLater(() -> showServerState(alive)); + } + + // AlarmClientModelListener + @Override + public void serverModeChanged(final boolean maintenance_mode) + { + // NOP + } + + // AlarmClientModelListener + @Override + public void serverDisableNotifyChanged(final boolean disable_notify) + { + // NOP + } + + /** Block until changes to items should be shown */ + private void blockItemChanges() + { + try + { + block_item_changes.await(); + } + catch (InterruptedException ex) + { + logger.log(Level.WARNING, "Blocker for item changes got interrupted", ex); + } + } + + // AlarmClientModelListener + @Override + public void itemAdded(final AlarmTreeItem item) + { + blockItemChanges(); + // System.out.println(Thread.currentThread() + " Add " + item.getPathName()); + + // Parent must already exist + final AlarmTreeItem model_parent = item.getParent(); + final TreeItem> view_parent = path2view.get(model_parent.getPathName()); + + if (view_parent == null) + { + dumpTree(tree_config_view.getRoot()); + throw new IllegalStateException("Missing parent view item for " + item.getPathName()); + } + // Create view item ASAP so that following updates will find it.. + final TreeItem> view_item = createViewItem(item); + + // .. but defer showing it on screen to UI thread + final CountDownLatch done = new CountDownLatch(1); + Platform.runLater(() -> + { + indicateChange(); + // Keep sorted by inserting at appropriate index + final List>> items = view_parent.getChildren(); + final int index = Collections.binarySearch(items, view_item, + (a, b) -> CompareNatural.compareTo(a.getValue().getName(), + b.getValue().getName())); + if (index < 0) + items.add(-index-1, view_item); + else + items.add(index, view_item); + done.countDown(); + }); + + // Waiting on the UI thread throttles the model's updates + // to a rate that the UI can handle. + // The result is a slower startup when loading the model, + // but keeping the UI responsive + try + { + done.await(); + } + catch (final InterruptedException ex) + { + logger.log(Level.WARNING, "Alarm tree update error for added item " + item.getPathName(), ex); + } + } + + // AlarmClientModelListener + @Override + public void itemRemoved(final AlarmTreeItem item) + { + blockItemChanges(); + // System.out.println(Thread.currentThread() + " Removed " + item.getPathName()); + + // Remove item and all sub-items from model2ui + final TreeItem> view_item = removeViewItems(item); + if (view_item == null) + throw new IllegalStateException("No view item for " + item.getPathName()); + + // Remove the corresponding view + final CountDownLatch done = new CountDownLatch(1); + Platform.runLater(() -> + { + indicateChange(); + // Can only locate the parent view item on UI thread, + // because item might just have been created by itemAdded() event + // and won't be on the screen until UI thread runs. + final TreeItem> view_parent = view_item.getParent(); + if (view_parent == null) + throw new IllegalStateException("No parent in view for " + item.getPathName()); + view_parent.getChildren().remove(view_item); + done.countDown(); + }); + + // Waiting on the UI thread throttles the model's updates + // to a rate that the UI can handle. + // The result is a slower startup when loading the model, + // but keeping the UI responsive + try + { + done.await(); + } + catch (final InterruptedException ex) + { + logger.log(Level.WARNING, "Alarm tree update error for removed item " + item.getPathName(), ex); + } + } + + /** @param item Item for which the TreeItem should be removed from path2view. Recurses to all child entries. + * @return TreeItem for 'item' + */ + private TreeItem> removeViewItems(final AlarmTreeItem item) + { + final TreeItem> view_item = path2view.remove(item.getPathName()); + + for (final AlarmTreeItem child : item.getChildren()) + removeViewItems(child); + + return view_item; + } + + // AlarmClientModelListener + @Override + public void itemUpdated(final AlarmTreeItem item) + { + blockItemChanges(); + // System.out.println(Thread.currentThread() + " Updated " + item.getPathName()); + final TreeItem> view_item = path2view.get(item.getPathName()); + if (view_item == null) + { + System.out.println("Unknown view for " + item.getPathName()); + path2view.keySet().stream().forEach(System.out::println); + throw new IllegalStateException("No view item for " + item.getPathName()); + } + + // UI update of existing item, i.e. + // Platform.runLater(() -> TreeHelper.triggerTreeItemRefresh(view_item)); + // is throttled. + // If several items update, they're all redrawn in one Platform call, + // and rapid updates of the same item are merged into just one final update + synchronized (items_to_update) + { + items_to_update.add(view_item); + } + throttle.trigger(); + } + + /** Called by throttle to perform accumulated updates */ + @SuppressWarnings("unchecked") + private void performUpdates() + { + final TreeItem>[] view_items; + synchronized (items_to_update) + { + // Creating a direct copy, i.e. another new LinkedHashSet<>(items_to_update), + // would be expensive, since we only need a _list_ of what's to update. + // Could use type-safe + // new ArrayList>>(items_to_update) + // but that calls toArray() internally, so doing that directly + view_items = items_to_update.toArray(new TreeItem[items_to_update.size()]); + items_to_update.clear(); + } + + // Remember selection + final ObservableList>> updatedSelectedItems = + FXCollections.observableArrayList(tree_config_view.getSelectionModel().getSelectedItems()); + + // How to update alarm tree cells when data changed? + // `setValue()` with a truly new value (not 'equal') should suffice, + // but there are two problems: + // Since we're currently using the alarm tree model item as a value, + // the value as seen by the TreeView remains the same. + // We could use a model item wrapper class as the cell value + // and replace it (while still holding the same model item!) + // for the TreeView to see a different wrapper value, but + // as shown in org.phoebus.applications.alarm.TreeItemUpdateDemo, + // replacing a tree cell value fails to trigger refreshes + // for certain hidden items. + // Only replacing the TreeItem gives reliable refreshes. + for (final TreeItem> view_item : view_items) + // Top-level item has no parent, and is not visible, so we keep it + if (view_item.getParent() != null) + { + // Locate item in tree parent + final TreeItem> parent = view_item.getParent(); + final int index = parent.getChildren().indexOf(view_item); + + // Create new TreeItem for that value + final AlarmTreeItem value = view_item.getValue(); + final TreeItem> update = new TreeItem<>(value); + if (updatedSelectedItems.contains(view_item)) { + updatedSelectedItems.remove(view_item); + updatedSelectedItems.add(update); + } + // Move child links to new item + final ArrayList>> children = new ArrayList<>(view_item.getChildren()); + view_item.getChildren().clear(); + update.getChildren().addAll(children); + update.setExpanded(view_item.isExpanded()); + + path2view.put(value.getPathName(), update); + parent.getChildren().set(index, update); + } + + tree_config_view.getSelectionModel().clearSelection(); + updatedSelectedItems.forEach(item -> tree_config_view.getSelectionModel().select(item)); + + } + + private void dumpTree(TreeItem> item) + { + final ObservableList>> children = item.getChildren(); + System.out.printf("item: %s , has %d children.\n", item.getValue().getName(), children.size()); + for (final TreeItem> child : children) + { + System.out.println(child.getValue().getName()); + dumpTree(child); + } + } + + /** + * Allows external classes to attach a selection listener to the tree view. + * @param listener ChangeListener for selected TreeItem + */ + public void addTreeSelectionListener(ChangeListener>> listener) { + tree_config_view.getSelectionModel().selectedItemProperty().addListener(listener); + } + + /** + * Allows external classes to remove a selection listener from the tree view. + * @param listener ChangeListener for selected TreeItem + */ + public void removeTreeSelectionListener(ChangeListener>> listener) { + tree_config_view.getSelectionModel().selectedItemProperty().removeListener(listener); + } +} diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/ContextMenuAddComponentPVs.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/ContextMenuAddComponentPVs.java new file mode 100644 index 0000000000..7c6b6af104 --- /dev/null +++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/ContextMenuAddComponentPVs.java @@ -0,0 +1,312 @@ +package org.phoebus.applications.alarm.ui.tree; + +import javafx.beans.value.ChangeListener; +import javafx.geometry.Insets; +import javafx.scene.control.ButtonType; +import javafx.scene.control.Dialog; +import javafx.scene.control.Label; +import javafx.scene.control.TextArea; +import javafx.scene.control.TextField; +import javafx.scene.control.TreeItem; +import javafx.scene.image.Image; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import org.phoebus.applications.alarm.AlarmSystem; +import org.phoebus.applications.alarm.client.AlarmClient; +import org.phoebus.applications.alarm.model.AlarmTreeItem; +import org.phoebus.applications.alarm.ui.AlarmConfigSelector; +import org.phoebus.core.types.ProcessVariable; +import org.phoebus.framework.selection.Selection; +import org.phoebus.ui.dialog.ExceptionDetailsErrorDialog; +import org.phoebus.ui.javafx.ImageCache; +import org.phoebus.ui.spi.ContextMenuEntry; + +import java.util.List; +import java.util.logging.Level; +import java.util.stream.Collectors; + +import static org.phoebus.applications.alarm.AlarmSystemConstants.logger; + +public class ContextMenuAddComponentPVs implements ContextMenuEntry { + + private static final Class supportedTypes = ProcessVariable.class; + + @Override + public String getName() { + return "Add PVs to Alarm System"; + } + + @Override + public Image getIcon() { + return ImageCache.getImageView(ImageCache.class, "/icons/add.png").getImage(); + } + + @Override + public Class getSupportedType() { + return supportedTypes; + } + + @Override + public void call(Selection selection) throws Exception { + List pvs = selection.getSelections(); + + AddComponentPVsDialog addDialog = new AddComponentPVsDialog(AlarmSystem.server, + AlarmSystem.config_name, + AlarmSystem.kafka_properties, + pvs.stream().map(ProcessVariable::getName).collect(Collectors.toList()), + null); + + addDialog.showAndWait(); + } + + /** + * Dialog for adding component PVs to an alarm configuration + */ + private static class AddComponentPVsDialog extends Dialog { + private final TextArea pvNamesInput; + private final TextField pathInput; + private final VBox content; + private final Label treeLabel; + + private AlarmClient alarmClient; + private AlarmTreeConfigView configView; + private ChangeListener>> selectionListener; + private final String server; + private final String kafka_properties; + + /** + * Constructor for AddComponentPVsDialog + * + * @param server The alarm server + * @param config_name The alarm configuration name + * @param kafka_properties Kafka properties for the AlarmClient + * @param pvNames Initial list of PV names to pre-populate + * @param currentPath The current path (initial value for text input) + */ + + public AddComponentPVsDialog(String server, String config_name, String kafka_properties, List pvNames, String currentPath) { + this.server = server; + this.kafka_properties = kafka_properties; + + setTitle("Add PVs to Alarm Configuration"); + setHeaderText("Select PVs and destination path"); + setResizable(true); + + // Create content + content = new VBox(10); + content.setPadding(new Insets(15)); + + // PV Names input section + Label pvLabel = new Label("PV Names (semicolon-separated):"); + pvNamesInput = new TextArea(); + pvNamesInput.setPromptText("Enter PV names separated by semicolons (;)"); + pvNamesInput.setPrefRowCount(3); + pvNamesInput.setWrapText(true); + + // Pre-populate with initial PVs if provided + if (pvNames != null && !pvNames.isEmpty()) { + pvNamesInput.setText(String.join("; ", pvNames)); + } + + // Tree label + treeLabel = new Label("Select destination in alarm tree:"); + + // Path input + Label pathLabel = new Label("Destination Path:"); + pathInput = new TextField(); + pathInput.setText(currentPath != null ? currentPath : ""); + pathInput.setStyle("-fx-font-family: monospace;"); + pathInput.setPromptText("Select a path from the tree above or type manually"); + pathInput.setEditable(true); + + // Add static components to layout + content.getChildren().addAll( + pvLabel, + pvNamesInput, + treeLabel + ); + + // Create initial tree view + createTreeView(config_name); + + // Add path input section + content.getChildren().addAll( + pathLabel, + pathInput + ); + + // Make tree view grow to fill available space + VBox.setVgrow(configView, Priority.ALWAYS); + + getDialogPane().setContent(content); + getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); + getDialogPane().setPrefSize(600, 700); + + // Validate and add PVs when OK is clicked + getDialogPane().lookupButton(ButtonType.OK).addEventFilter(javafx.event.ActionEvent.ACTION, event -> { + // Validate path + String path = pathInput.getText().trim(); + if (path.isEmpty()) { + event.consume(); // Prevent dialog from closing + ExceptionDetailsErrorDialog.openError("Invalid Path", + "Destination path cannot be empty.\nPlease enter or select a valid path.", + null); + return; + } + + // Validate that path exists in the alarm tree + if (!AlarmTreeHelper.validateNewPath(path, alarmClient.getRoot())) { + event.consume(); // Prevent dialog from closing + ExceptionDetailsErrorDialog.openError("Invalid Path", + "The path '" + path + "' is not valid in the alarm tree.\n\n" + + "Please select a valid path from the tree or enter a valid path manually.", + null); + return; + } + + // Get PV names + List pvNamesToAdd = getPVNames(); + if (pvNamesToAdd.isEmpty()) { + event.consume(); // Prevent dialog from closing + ExceptionDetailsErrorDialog.openError("No PV Names", + "No PV names were entered.\n\n" + + "Please enter one or more PV names separated by semicolons (;).", + null); + return; + } + + // Try to add PVs, tracking successes and failures + List successfulPVs = new java.util.ArrayList<>(); + List failedPVs = new java.util.ArrayList<>(); + Exception lastException = null; + + for (String pvName : pvNamesToAdd) { + try { + alarmClient.addPV(path, pvName); + successfulPVs.add(pvName); + } catch (Exception e) { + failedPVs.add(pvName); + lastException = e; + logger.log(Level.WARNING, "Failed to add PV '" + pvName + "' to " + path, e); + } + } + + // Report results + if (!failedPVs.isEmpty()) { + event.consume(); // Prevent dialog from closing + String message = String.format( + "Failed to add %d of %d PV(s) to path: %s\n\n" + + "Successful: %s\n" + + "Failed: %s\n\n" + + "Last error: %s", + failedPVs.size(), + pvNamesToAdd.size(), + path, + successfulPVs.isEmpty() ? "None" : String.join(", ", successfulPVs), + String.join(", ", failedPVs), + lastException != null ? lastException.getMessage() : "Unknown" + ); + logger.log(Level.WARNING, message); + ExceptionDetailsErrorDialog.openError("Add Component PVs Failed", message, lastException); + } else { + logger.log(Level.INFO, "Successfully added " + successfulPVs.size() + " PV(s) to " + path); + } + }); + } + + private void createTreeView(String config_name) { + // Create new AlarmClient + alarmClient = new AlarmClient(server, config_name, kafka_properties); + + // Create new AlarmTreeConfigView + configView = new AlarmTreeConfigView(alarmClient); + configView.setPrefHeight(300); + configView.setPrefWidth(500); + + // Add config selector if multiple configs are available + if (AlarmSystem.config_names.length > 0) { + final AlarmConfigSelector configs = new AlarmConfigSelector(config_name, this::changeConfig); + configView.getToolbar().getItems().add(0, configs); + } + + // Start the client + alarmClient.start(); + + // Create selection listener + selectionListener = (obs, oldVal, newVal) -> { + if (newVal != null && newVal.getValue() != null) { + String selectedPath = newVal.getValue().getPathName(); + if (selectedPath != null && !selectedPath.isEmpty()) { + // Only update if path input is not focused + if (!pathInput.isFocused()) { + pathInput.setText(selectedPath); + } + } + } + }; + configView.addTreeSelectionListener(selectionListener); + + // Remove the listener and dispose AlarmClient when the dialog is closed + this.setOnHidden(e -> { + configView.removeTreeSelectionListener(selectionListener); + dispose(); + }); + + // Find the position where tree view should be (after treeLabel) + int treeIndex = content.getChildren().indexOf(treeLabel) + 1; + + // Remove old tree view if present (when switching configs) + if (treeIndex < content.getChildren().size()) { + if (content.getChildren().get(treeIndex) instanceof AlarmTreeConfigView) { + content.getChildren().remove(treeIndex); + } + } + + // Add new tree view at the correct position + content.getChildren().add(treeIndex, configView); + VBox.setVgrow(configView, Priority.ALWAYS); + } + + private void changeConfig(String new_config_name) { + // Dispose existing client + dispose(); + + try { + // Create new tree view with new configuration + createTreeView(new_config_name); + } catch (Exception ex) { + logger.log(Level.WARNING, "Cannot switch alarm tree to " + new_config_name, ex); + ExceptionDetailsErrorDialog.openError("Configuration Switch Failed", + "Failed to switch to configuration: " + new_config_name, + ex); + } + } + + private void dispose() + { + if (alarmClient != null) + { + alarmClient.shutdown(); + alarmClient = null; + } + } + /** + * Get the list of PV names entered by the user + * + * @return List of PV names (trimmed and non-empty) + */ + private List getPVNames() { + String text = pvNamesInput.getText(); + if (text == null || text.trim().isEmpty()) { + return List.of(); + } + + // Split by semicolon, trim each entry, and filter out empty strings + return List.of(text.split(";")) + .stream() + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + } + } +} diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/MoveTreeItemAction.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/MoveTreeItemAction.java index ff689a02bd..93ac6d881b 100644 --- a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/MoveTreeItemAction.java +++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/MoveTreeItemAction.java @@ -40,19 +40,27 @@ public MoveTreeItemAction(TreeView> node, setOnAction(event -> { - //Prompt for new name - - String prompt = "Enter new path for item"; - + // Show dialog with tree visualization for path selection String path = item.getPathName(); while (true) { - path = AlarmTreeHelper.prompt(getText(), prompt, path, node); - if (path == null) + AlarmTreeConfigDialog dialog = new AlarmTreeConfigDialog( + model, + path, + getText(), + "Select new path for item" + ); + var result = dialog.getPath(); + if (result.isEmpty()) return; - if (AlarmTreeHelper.validateNewPath(path, node.getRoot().getValue()) ) + path = result.get(); + if (AlarmTreeHelper.validateNewPath(path, node.getRoot().getValue())) break; - prompt = "Invalid path. Try again or cancel"; + + // Show error dialog and retry + ExceptionDetailsErrorDialog.openError("Invalid Path", + "Invalid path. Please try again.", + null); } // The move is done by copying the node from the old path to the new path, @@ -64,8 +72,7 @@ public MoveTreeItemAction(TreeView> node, // Tree view keeps the selection indices, which will point to wrong content // after those items have been removed. - if (node instanceof TreeView) - ((TreeView) node).getSelectionModel().clearSelection(); + node.getSelectionModel().clearSelection(); final String new_path = path; // On a background thread, send the item configuration updates for the item to be moved and all its children. diff --git a/app/alarm/ui/src/main/resources/META-INF/services/org.phoebus.ui.spi.ContextMenuEntry b/app/alarm/ui/src/main/resources/META-INF/services/org.phoebus.ui.spi.ContextMenuEntry new file mode 100644 index 0000000000..f0f8dc2a79 --- /dev/null +++ b/app/alarm/ui/src/main/resources/META-INF/services/org.phoebus.ui.spi.ContextMenuEntry @@ -0,0 +1 @@ +org.phoebus.applications.alarm.ui.tree.ContextMenuAddComponentPVs