diff --git a/app/alarm/ui/doc/index.rst b/app/alarm/ui/doc/index.rst index 921ec89f4d..4fa8680f00 100644 --- a/app/alarm/ui/doc/index.rst +++ b/app/alarm/ui/doc/index.rst @@ -136,29 +136,9 @@ Alarm Configuration Options Alarm configurations are imported into the Alarm Server in an XML format, the schema for which may be found `here `_. -The options for an entry in the hierarchical alarm configuration -always include guidance, display links etc. as described further below. -In addition, alarm PV entries have the following settings. -Description -^^^^^^^^^^^ -This text is displayed in the alarm table when the alarm triggers. - -The description is also used by the alarm annunciator. -By default, the annunciator will start the actual message with -the alarm severity. For example, a description of "Vacuum Problem" -will be annunciated as for example "Minor Alarm: Vacuum Problem". -The addition of the alarm severity can be disabled by starting -the description with a "\*" as in "\* Vacuum Problem". - -When there is a flurry of alarms, the annunciator will summarize -them to "There are 10 more alarms". To assert that certain alarms -are always annunciated, even if they occur within a burst of other alarms, -start the message with "!" (or "\*!"). - - -Behavior -^^^^^^^^ +Behavior - PV entries +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * Enabled: De-select to disable an alarm, i.e. to ignore the value of this alarm trigger PV. @@ -171,10 +151,14 @@ Behavior Should the alarm be annunciated (if the annunciator is running), or should it only be displayed silently? + * Disable until: + Disables an alarm and sets a date and time when the alarm + would be enabled automatically. User may select absolute or relative date/time. + * Alarm Delay: Only alarm if the trigger PV remains in alarm for at least this time, see examples below. - + * Alarm Count: Used in combination with the alarm delay. If the trigger PVs exhibits a not-OK alarm severity more than 'count' times @@ -188,9 +172,8 @@ Behavior * Enabling Filter: An optional expression that can enable the alarm based on other PVs. - - Example: `'abc' > 10` will only enable this alarm if the PV 'abc' has a value above 10. + Example: `'abc' > 10` will only enable this alarm if the PV 'abc' has a value above 10. The Alarm Delay and Count work in combination. By default, with both the alarm delay and count at zero, a non-OK PV severity is right away recognized. @@ -230,8 +213,45 @@ guidance and display links which allow the user to figure out: * What does this alarm mean? What should I do about it? * What displays allow me to see more, where can I do something about the alarm? +Behavior - component entries +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + + * Enabled: + Enable or disable all PV items in the subtree. + + * Disable until: + Disables an all PV items in the subtree and set a date and time when the alarms + would be enabled automatically. User may select absolute or relative date/time. + +Note: + + * An enable date will be set even if a PV item is already disabled. + + * Ticking the Enable check box will enable all PVs in the subtree when saving the changes, + including those that have an enable date set. + + * If an enable date is set on any PV in the subtree, it is not possible to set an enable + date. + +Description - PV entries only +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +This text is displayed in the alarm table when the alarm triggers. + +The description is also used by the alarm annunciator. +By default, the annunciator will start the actual message with +the alarm severity. For example, a description of "Vacuum Problem" +will be annunciated as for example "Minor Alarm: Vacuum Problem". +The addition of the alarm severity can be disabled by starting +the description with a "\*" as in "\* Vacuum Problem". + +When there is a flurry of alarms, the annunciator will summarize +them to "There are 10 more alarms". To assert that certain alarms +are always annunciated, even if they occur within a burst of other alarms, +start the message with "!" (or "\*!"). + Guidance --------- +^^^^^^^^ Each alarm should have at least one guidance message to explain the meaning of an alarm to the user, to list for example contact information for subsystem experts. @@ -249,7 +269,7 @@ parent components of the alarm hierarchy. Displays --------- +^^^^^^^^ As with Guidance, each alarm should have at least one link to a control system display that shows the actual alarm PV and the surrounding subsystem. @@ -275,7 +295,7 @@ Examples:: file:///path/to/display.bob?MACRO=Value&OTHER=42$NAME=Text+with+spaces Automated Actions ------------------ +^^^^^^^^^^^^^^^^ Automated actions are performed when the node in the alarm hierarchy enters and remains in an active alarm state for some time. diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/Messages.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/Messages.java index 18455f7dfa..df38bf1bae 100644 --- a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/Messages.java +++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/Messages.java @@ -15,12 +15,25 @@ public class Messages public static String acknowledgeFailed; public static String addComponentFailed; + public static String configure; + public static String delayTooltip0; + public static String delayTooltip1; + public static String delayTooltip2; public static String disableAlarmFailed; + public static String disableAlarms; public static String disabled; public static String disabledUntil; public static String enableAlarmFailed; + public static String enableAlarms; + public static String headerAlreadyDisabled; + public static String headerAlreadyEnabled; + public static String headerConfirmDisable; + public static String headerConfirmDisableWithEnableDate; + public static String headerConfirmEnable; public static String moveItemFailed; public static String partlyDisabled; + public static String promptTitle; + public static String promptContent; public static String removeComponentFailed; public static String renameItemFailed; public static String timer; diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/config/ComponentConfigDialogController.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/config/ComponentConfigDialogController.java new file mode 100644 index 0000000000..26012c28b7 --- /dev/null +++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/config/ComponentConfigDialogController.java @@ -0,0 +1,219 @@ +/* + * Copyright (C) 2025 European Spallation Source ERIC. + */ +package org.phoebus.applications.alarm.ui.config; + +import javafx.fxml.FXML; +import javafx.scene.control.Alert; +import javafx.scene.control.ButtonType; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.TextField; +import javafx.scene.layout.HBox; +import org.phoebus.applications.alarm.client.AlarmClient; +import org.phoebus.applications.alarm.client.AlarmClientLeaf; +import org.phoebus.applications.alarm.model.AlarmTreeItem; +import org.phoebus.applications.alarm.ui.Messages; +import org.phoebus.applications.alarm.ui.tree.ComponentActionHelper; +import org.phoebus.framework.jobs.JobManager; +import org.phoebus.ui.dialog.DialogHelper; +import org.phoebus.ui.dialog.ExceptionDetailsErrorDialog; + +import java.text.MessageFormat; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * FXML controller for LeafConfigDialog.fxml. + */ +@SuppressWarnings("nls") +public class ComponentConfigDialogController extends ConfigDialogController { + + + // ── FXML-injected fields ────────────────────────────────────────────────── + + @SuppressWarnings("unused") + @FXML + private ScrollPane scroll; + @SuppressWarnings("unused") + @FXML + private javafx.scene.layout.GridPane layout; + + // Path row (always visible) + @SuppressWarnings("unused") + @FXML + private TextField path; + + // Leaf-only rows + @SuppressWarnings("unused") + @FXML + private Label descriptionLabel; + @SuppressWarnings("unused") + @FXML + private TextField description; + + @SuppressWarnings("unused") + @FXML + private Label behaviorLabel; + @SuppressWarnings("unused") + @FXML + private HBox behaviorBox; + @SuppressWarnings("unused") + @FXML + private CheckBox enabled; + + @SuppressWarnings("unused") + @FXML + private Label disableUntilLabel; + @SuppressWarnings("unused") + @FXML + private ComboBox relativeDate; + + @SuppressWarnings("unused") + @FXML + private DateTimePicker enabledDatePicker; + + @SuppressWarnings("unused") + @FXML + private Label partlyDisabledLabel; + + private List alarmClientLeaves; + + public ComponentConfigDialogController(AlarmClient alarmClient, AlarmTreeItem alarmTreeItem) { + super(alarmClient, alarmTreeItem); + } + + @SuppressWarnings("unused") + @FXML + public void initialize() { + + super.initialize(); + + alarmClientLeaves = new ArrayList<>(); + List disabled = new ArrayList<>(); + List withEnableDate = new ArrayList<>(); + // Check subtree for disabled PVs and PVs with non-null enable date + findAffectedPVs(alarmTreeItem, alarmClientLeaves, disabled, withEnableDate); + + if(disabled.isEmpty()) { + enabled.setSelected(true); + } + else if (alarmClientLeaves.size() != disabled.size()) { + partlyDisabledLabel.setVisible(true); + enabled.setSelected(false); + } + + if (!withEnableDate.isEmpty()) { + relativeDate.setDisable(true); + enabledDatePicker.setDisable(true); + } + } + + /** + * Validates input and sends the configuration off to the message broker. + * + */ + public void validateAndStore() { + + // First check if user has specified a valid enable date + LocalDateTime enableDate; + try { + enableDate = determineEnableDate(); + } catch (Exception e) { + Logger.getLogger(LeafConfigDialogController.class.getName()) + .log(Level.WARNING, "Invalid enable date specified", e); + return; + } + + // Next store guidance, displays... + alarmTreeItem.setGuidance(guidance.getItems()); + alarmTreeItem.setDisplays(displays.getItems()); + alarmTreeItem.setCommands(commands.getItems()); + alarmTreeItem.setActions(actions.getItems()); + + try { + alarmClient.sendItemConfigurationUpdate(alarmTreeItem.getPathName(), alarmTreeItem); + } catch (Exception ex) { + ExceptionDetailsErrorDialog.openError("Error", "Cannot update item", ex); + return; + } + + // Lastly update enable or - if non-null - set enable date. + if (enableDate != null) { + updateEnablement(enableDate); + } else { + ComponentActionHelper.updateEnablement(scroll, alarmClient, List.of(alarmTreeItem), itemEnabledProperty.get()); + } + } + + /** + * Updates a component to disable a hierarchy of PVs with an enable date. + * + * @param enableDate The {@link LocalDateTime} to set on all leaf nodes specified in items . + */ + private void updateEnablement(LocalDateTime enableDate) { + if (alarmClientLeaves.isEmpty()) { + return; + } + if (alarmClientLeaves.size() > 1) { + final Alert dialog = new Alert(Alert.AlertType.CONFIRMATION); + dialog.setTitle(Messages.disableAlarms); + dialog.setHeaderText(MessageFormat.format(Messages.headerConfirmDisableWithEnableDate, enableDate, alarmClientLeaves.size())); + + DialogHelper.positionDialog(dialog, scroll, -50, -25); + if (dialog.showAndWait().get() != ButtonType.OK) { + return; + } + } + + JobManager.schedule(Messages.disableAlarms, monitor -> + { + for (AlarmClientLeaf pv : alarmClientLeaves) { + final AlarmClientLeaf copy = pv.createDetachedCopy(); + if (copy.setEnabledDate(enableDate)) { + try { + alarmClient.sendItemConfigurationUpdate(pv.getPathName(), copy); + } catch (Exception e) { + ExceptionDetailsErrorDialog.openError(Messages.error, + Messages.disableAlarmFailed, + e); + throw e; + } + } + } + }); + } + + /** + * Recursively counts alarm tree items in a subtree to find total number, number of disabled, and + * number of disabled with enable date. + * + * @param item Root item + * @param total {@link AtomicInteger} that will hold the total number of leaf nodes + * @param disabled {@link AtomicInteger} that will hold the number of disabled leaf nodes (with or without enable date) + * @param withEnableDate {@link AtomicInteger} that will hold the number of leaf nodes with non-null enable date + * + */ + public static void findAffectedPVs(final AlarmTreeItem item, final List total, final List disabled, final List withEnableDate) { + if (item instanceof AlarmClientLeaf) { + final AlarmClientLeaf pv = (AlarmClientLeaf) item; + total.add(pv); + if (!pv.isEnabled()) { + disabled.add(pv); + if (pv.getEnabledDate() != null) { + withEnableDate.add(pv); + } + } + } else { + for (AlarmTreeItem sub : item.getChildren()) { + findAffectedPVs(sub, total, disabled, withEnableDate); + } + } + } +} diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/config/ConfigDialogController.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/config/ConfigDialogController.java new file mode 100644 index 0000000000..8f1a11b740 --- /dev/null +++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/config/ConfigDialogController.java @@ -0,0 +1,229 @@ +/* + * Copyright (C) 2025 European Spallation Source ERIC. + */ + +package org.phoebus.applications.alarm.ui.config; + +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.fxml.FXML; +import javafx.scene.control.Alert; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ComboBox; +import javafx.scene.control.DateCell; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.TextField; +import javafx.scene.control.TextFormatter; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.StackPane; +import javafx.util.StringConverter; +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.Messages; +import org.phoebus.applications.alarm.ui.tree.TitleDetailDelayTable; +import org.phoebus.applications.alarm.ui.tree.TitleDetailTable; +import org.phoebus.ui.dialog.DialogHelper; +import org.phoebus.util.time.TimeParser; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeParseException; +import java.time.temporal.TemporalAmount; + +public abstract class ConfigDialogController { + + @SuppressWarnings("unused") + @FXML + private ScrollPane scroll; + @SuppressWarnings("unused") + @FXML + private GridPane layout; + + + @SuppressWarnings("unused") + @FXML + private TextField path; + + @SuppressWarnings("unused") + @FXML + protected CheckBox enabled; + + @SuppressWarnings("unused") + @FXML + protected ComboBox relativeDate; + + + @SuppressWarnings("unused") + @FXML + protected DateTimePicker enabledDatePicker; + + // Shared table placeholders + @SuppressWarnings("unused") + @FXML + private StackPane guidancePlaceholder; + @SuppressWarnings("unused") + @FXML + private StackPane displaysPlaceholder; + @SuppressWarnings("unused") + @FXML + private StackPane commandsPlaceholder; + @SuppressWarnings("unused") + @FXML + private StackPane actionsPlaceholder; + + protected TitleDetailTable guidance; + protected TitleDetailTable displays; + protected TitleDetailTable commands; + protected TitleDetailDelayTable actions; + + protected final AlarmClient alarmClient; + protected final AlarmTreeItem alarmTreeItem; + + protected final SimpleBooleanProperty itemEnabledProperty = new SimpleBooleanProperty(); + protected final SimpleStringProperty relativeDateProperty = new SimpleStringProperty(null); + protected final SimpleObjectProperty enableDateProperty = + new SimpleObjectProperty<>(null); + + public ConfigDialogController(AlarmClient alarmClient, AlarmTreeItem alarmTreeItem) { + this.alarmClient = alarmClient; + this.alarmTreeItem = alarmTreeItem; + } + + @FXML + public void initialize() { + + path.setText(alarmTreeItem.getPathName()); + + // ── Shared tables (guidance, displays, commands, actions) ───────────── + guidance = new TitleDetailTable(alarmTreeItem.getGuidance()); + guidance.setPrefHeight(100); + guidancePlaceholder.getChildren().setAll(guidance); + + displays = new TitleDetailTable(alarmTreeItem.getDisplays()); + displays.setPrefHeight(100); + displaysPlaceholder.getChildren().setAll(displays); + + commands = new TitleDetailTable(alarmTreeItem.getCommands()); + commands.setPrefHeight(100); + commandsPlaceholder.getChildren().setAll(commands); + + actions = new TitleDetailDelayTable(alarmTreeItem.getActions()); + actions.setPrefHeight(100); + actionsPlaceholder.getChildren().setAll(actions); + + relativeDate.valueProperty().bindBidirectional(relativeDateProperty); + enabledDatePicker.dateTimeValueProperty().bindBidirectional(enableDateProperty); + + enabled.setOnAction(e -> { + itemEnabledProperty.setValue(enabled.isSelected()); + relativeDateProperty.set(null); + enableDateProperty.set(null); + }); + + enableDateProperty.addListener((observable, oldValue, newValue) -> { + enabled.setSelected(newValue == null && relativeDateProperty.isNull().get()); + if (newValue != null) { + relativeDateProperty.setValue(null); + } + }); + + relativeDateProperty.addListener((observable, oldValue, newValue) -> { + enabled.setSelected(newValue == null && enableDateProperty.isNull().get()); + if (newValue != null) { + enableDateProperty.setValue(null); + } + }); + + // Day-cell factory – disable past dates + enabledDatePicker.setDayCellFactory(picker -> new DateCell() { + @Override + public void updateItem(LocalDate date, boolean empty) { + super.updateItem(date, empty); + setDisable(empty || date.isBefore(LocalDate.now())); + } + }); + + // ENTER key handler on the date picker's editor + enabledDatePicker.addEventHandler(KeyEvent.KEY_PRESSED, keyEvent -> { + if (keyEvent.getCode() == KeyCode.ENTER) { + try { + TextFormatter tf = enabledDatePicker.getEditor().getTextFormatter(); + @SuppressWarnings("unchecked") + StringConverter conv = + (StringConverter) tf.getValueConverter(); + conv.fromString(enabledDatePicker.getEditor().getText()); + enableDateProperty.set(conv.fromString(enabledDatePicker.getEditor().getText())); + enabledDatePicker.getEditor().commitValue(); + } catch (DateTimeParseException ex) { + keyEvent.consume(); + } + } + }); + + // Make sure first element in shelving options is null + // so user can "deselect" a relative date. + String[] shelvingOptions = new String[AlarmSystem.shelving_options.length + 1]; + System.arraycopy(AlarmSystem.shelving_options, 0, shelvingOptions, 1, AlarmSystem.shelving_options.length); + relativeDate.getItems().addAll(shelvingOptions); + + // ── Scroll-pane width listener ──────────────────────────────────────── + scroll.widthProperty().addListener((p, old, width) -> + layout.setPrefWidth(Math.max(width.doubleValue() - 40, 450))); + } + + /** + * Attempts to determine a {@link LocalDateTime} based on the user input. + * + * @return A non-null {@link LocalDateTime} if user has specified a valid date/time, or null if + * there is no user input from which to determine a date/time. + * @throws IllegalArgumentException if user has entered an invalid date/time. + */ + protected LocalDateTime determineEnableDate() { + + if (enableDateProperty.isNotNull().get()) { + if (isEnableDateValid(enableDateProperty.get())) { + return enableDateProperty.get(); + } else { + showInvalidEnableDateDialog(); + throw new IllegalArgumentException("Enable date invalid"); + } + } else if (relativeDateProperty.isNotNull().get()) { + final TemporalAmount amount = + TimeParser.parseTemporalAmount(relativeDateProperty.get()); + final LocalDateTime updateDate = LocalDateTime.now().plus(amount); + if (isEnableDateValid(updateDate)) { + return updateDate; + } else { + showInvalidEnableDateDialog(); + throw new IllegalArgumentException("Enable date invalid"); + } + } + return null; + } + + /** + * @param enableDate A non-null {@link LocalDateTime} + * @return true if the specified date/time is considered valid, e.g. in the future. + */ + private boolean isEnableDateValid(LocalDateTime enableDate) { + return !enableDate.isBefore(LocalDateTime.now()) && !enableDate.isEqual(LocalDateTime.now()); + } + + /** + * Shows a dialog indicate that the user-specified date is invalid, e.g. a date/time not in the future. + */ + private void showInvalidEnableDateDialog() { + Alert prompt = new Alert(Alert.AlertType.INFORMATION); + prompt.setTitle(Messages.promptTitle); + prompt.setHeaderText(Messages.promptTitle); + prompt.setContentText(Messages.promptContent); + DialogHelper.positionDialog(prompt, enabledDatePicker, 0, 0); + prompt.showAndWait(); + } + + public abstract void validateAndStore(); +} diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/datetimepicker/DateTimePicker.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/config/DateTimePicker.java similarity index 87% rename from app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/datetimepicker/DateTimePicker.java rename to app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/config/DateTimePicker.java index 33af059b3d..cfa68442b5 100644 --- a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/datetimepicker/DateTimePicker.java +++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/config/DateTimePicker.java @@ -1,4 +1,8 @@ -package org.phoebus.applications.alarm.ui.tree.datetimepicker; +/* + * Copyright (C) 2025 European Spallation Source ERIC. + */ + +package org.phoebus.applications.alarm.ui.config; import java.time.LocalDate; import java.time.LocalDateTime; @@ -14,15 +18,15 @@ /** * A DateTimePicker with configurable datetime format where both date and time can be changed * via the text field and the date can additionally be changed via the JavaFX default date picker. - * Modified from https://github.com/edvin/tornadofx-controls/blob/master/src/main/java/tornadofx/control/DateTimePicker.java + * Modified from DateTimePicker */ @SuppressWarnings("unused") public class DateTimePicker extends DatePicker { private static final String DefaultFormat = "yyyy-MM-dd HH:mm"; private DateTimeFormatter formatter; - private ObjectProperty dateTimeValue = new SimpleObjectProperty<>(null); - private ObjectProperty format = new SimpleObjectProperty() { + private final ObjectProperty dateTimeValue = new SimpleObjectProperty<>(null); + private final ObjectProperty format = new SimpleObjectProperty<>() { @Override public void set(String newValue) { super.set(newValue); @@ -92,7 +96,7 @@ public void setDateTimeValue(LocalDateTime dateTimeValue) { this.dateTimeValue.set(dateTimeValue); } - private ObjectProperty dateTimeValueProperty() { + protected ObjectProperty dateTimeValueProperty() { return dateTimeValue; } diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/config/ItemConfigDialog.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/config/ItemConfigDialog.java new file mode 100644 index 0000000000..0584693e92 --- /dev/null +++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/config/ItemConfigDialog.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2025 European Spallation Source ERIC. + */ +package org.phoebus.applications.alarm.ui.config; + +import javafx.event.ActionEvent; +import javafx.fxml.FXMLLoader; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonType; +import javafx.scene.control.Dialog; +import javafx.scene.control.ScrollPane; +import javafx.stage.Modality; +import org.phoebus.applications.alarm.client.AlarmClient; +import org.phoebus.applications.alarm.client.AlarmClientLeaf; +import org.phoebus.applications.alarm.model.AlarmTreeItem; +import org.phoebus.applications.alarm.ui.Messages; +import org.phoebus.framework.nls.NLS; + +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Dialog for editing {@link AlarmTreeItem}. + * + *

Layout is defined in {@code LeafConfigDialog.fxml}. + * Runtime wiring (model data, bindings, event handlers) is handled by + * {@link LeafConfigDialogController}. + * + *

When pressing "OK", the dialog sends the updated configuration. + */ +@SuppressWarnings("nls") +public class ItemConfigDialog extends Dialog { + + public ItemConfigDialog(final AlarmClient model, final AlarmTreeItem item) { + super(); + // Allow multiple instances + initModality(Modality.NONE); + setTitle(Messages.configure + " " + item.getName()); + getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); + final FXMLLoader fxmlLoader = new FXMLLoader(); + try { + fxmlLoader.setResources(NLS.getMessages(Messages.class)); + if (item instanceof AlarmClientLeaf){ + fxmlLoader.setLocation(this.getClass().getResource("LeafConfigDialog.fxml")); + } + else{ + fxmlLoader.setLocation(this.getClass().getResource("ComponentConfigDialog.fxml")); + } + fxmlLoader.setControllerFactory(clazz -> { + try { + return clazz.getConstructor(AlarmClient.class, AlarmTreeItem.class).newInstance(model, item); + } catch (Exception e) { + Logger.getLogger(ItemConfigDialog.class.getName()).log(Level.SEVERE, "Failed to construct ConfigDialogController", e); + } + return null; + }); + + // Load returns the root node (ScrollPane) declared in the FXML + final ScrollPane root = fxmlLoader.load(); + + // ── OK-button validation filter ─────────────────────────────────────── + final Button ok = (Button) getDialogPane().lookupButton(ButtonType.OK); + ok.addEventFilter(ActionEvent.ACTION, + event -> ((ConfigDialogController)fxmlLoader.getController()).validateAndStore()); + + getDialogPane().setContent(root); + + } catch (Exception ex) { + throw new RuntimeException("Failed to load " + fxmlLoader.getLocation(), ex); + } + + setResizable(true); + + setResultConverter(button -> button == ButtonType.OK); + } +} diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/datetimepicker/LICENSE.txt b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/config/LICENSE.txt similarity index 100% rename from app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/datetimepicker/LICENSE.txt rename to app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/config/LICENSE.txt diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/config/LeafConfigDialogController.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/config/LeafConfigDialogController.java new file mode 100644 index 0000000000..5f5eed6163 --- /dev/null +++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/config/LeafConfigDialogController.java @@ -0,0 +1,198 @@ +/* + * Copyright (C) 2025 European Spallation Source ERIC. + */ +package org.phoebus.applications.alarm.ui.config; + +import javafx.application.Platform; +import javafx.beans.binding.Bindings; +import javafx.beans.binding.BooleanBinding; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.fxml.FXML; +import javafx.scene.control.CheckBox; +import javafx.scene.control.Spinner; +import javafx.scene.control.SpinnerValueFactory; +import javafx.scene.control.TextField; +import javafx.scene.control.TextFormatter; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.HBox; +import javafx.util.Duration; +import org.phoebus.applications.alarm.client.AlarmClient; +import org.phoebus.applications.alarm.client.AlarmClientLeaf; +import org.phoebus.applications.alarm.model.AlarmTreeItem; +import org.phoebus.applications.alarm.ui.Messages; +import org.phoebus.ui.dialog.ExceptionDetailsErrorDialog; +import org.phoebus.util.time.SecondsParser; + +import java.text.MessageFormat; +import java.text.NumberFormat; +import java.text.ParsePosition; +import java.time.LocalDateTime; +import java.util.function.UnaryOperator; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * FXML controller for LeafConfigDialog.fxml. Intended for configuration + * of alarm tree leaf items. + */ +@SuppressWarnings("nls") +public class LeafConfigDialogController extends ConfigDialogController { + + @SuppressWarnings("unused") + @FXML + private TextField description; + @SuppressWarnings("unused") + @FXML + private HBox behaviorBox; + @SuppressWarnings("unused") + @FXML + private CheckBox latching; + @SuppressWarnings("unused") + @FXML + private CheckBox annunciating; + @SuppressWarnings("unused") + @FXML + private Spinner delay; + @SuppressWarnings("unused") + @FXML + private Spinner count; + @SuppressWarnings("unused") + @FXML + private TextField filter; + + private final SimpleStringProperty descriptionProperty = new SimpleStringProperty(""); + private final SimpleBooleanProperty latchingProperty = new SimpleBooleanProperty(); + private final SimpleBooleanProperty annunciatingProperty = new SimpleBooleanProperty(); + private final SimpleStringProperty enablingFilterProperty = new SimpleStringProperty(""); + + private SpinnerValueFactory countValueFactory; + private SpinnerValueFactory delayValueFactory; + + public LeafConfigDialogController(AlarmClient alarmClient, AlarmTreeItem alarmTreeItem) { + super(alarmClient, alarmTreeItem); + } + + @SuppressWarnings("unused") + @FXML + public void initialize() { + + super.initialize(); + + description.textProperty().bindBidirectional(descriptionProperty); + latching.selectedProperty().bindBidirectional(latchingProperty); + annunciating.selectedProperty().bindBidirectional(annunciatingProperty); + filter.textProperty().bindBidirectional(enablingFilterProperty); + + relativeDate.disableProperty().bind(itemEnabledProperty.not()); + enabledDatePicker.disableProperty().bind(itemEnabledProperty.not()); + + AlarmClientLeaf leaf = (AlarmClientLeaf) alarmTreeItem; + + // Filter to disallow anything but numbers in the spinner + UnaryOperator integerFilter = c -> { + if (c.isContentChange()) { + ParsePosition parsePosition = new ParsePosition(0); + // NumberFormat evaluates the beginning of the text + NumberFormat.getIntegerInstance().parse(c.getControlNewText(), parsePosition); + if (parsePosition.getIndex() == 0 || parsePosition.getIndex() < c.getControlNewText().length()) { + // reject parsing the complete text failed + return null; + } + } + return c; + }; + + countValueFactory = new SpinnerValueFactory.IntegerSpinnerValueFactory(0, Integer.MAX_VALUE, leaf.getCount(), 1); + TextFormatter countSpinnerFormatter = new TextFormatter<>(countValueFactory.getConverter(), countValueFactory.getValue(), integerFilter); + countValueFactory.valueProperty().bindBidirectional(countSpinnerFormatter.valueProperty()); + count.getEditor().setTextFormatter(countSpinnerFormatter); + count.setValueFactory(countValueFactory); + + delayValueFactory = new SpinnerValueFactory.IntegerSpinnerValueFactory(0, Integer.MAX_VALUE, leaf.getDelay(), 1); + TextFormatter delaySpinnerFormatter = new TextFormatter<>(delayValueFactory.getConverter(), delayValueFactory.getValue(), integerFilter); + delayValueFactory.valueProperty().bindBidirectional(delaySpinnerFormatter.valueProperty()); + delay.getEditor().setTextFormatter(delaySpinnerFormatter); + delay.setValueFactory(delayValueFactory); + + descriptionProperty.set(leaf.getDescription()); + enablingFilterProperty.set(leaf.getFilter()); + enableDateProperty.set(leaf.getEnabledDate()); + + // Behavior checkboxes + itemEnabledProperty.setValue(leaf.isEnabled()); + enabled.selectedProperty().set(leaf.isEnabled()); + latchingProperty.setValue(leaf.isLatching()); + annunciatingProperty.setValue(leaf.isAnnunciating()); + + BooleanBinding binding = Bindings.createBooleanBinding(() -> + itemEnabledProperty.not().get() || relativeDateProperty.isNotNull().get() || enableDateProperty.isNotNull().get(), + itemEnabledProperty, relativeDateProperty, enableDateProperty); + + latching.disableProperty().bind(binding); + annunciating.disableProperty().bind(binding); + count.disableProperty().bind(binding); + delay.disableProperty().bind(binding); + filter.disableProperty().bind(binding); + + // Delay spinner + final Tooltip delayTt = new Tooltip(); + delayTt.setShowDuration(Duration.seconds(30)); + delayTt.setOnShowing(event -> { + final int seconds = leaf.getDelay(); + final String detail; + if (seconds <= 0) + detail = Messages.delayTooltip1; + else { + detail = MessageFormat.format(Messages.delayTooltip2, seconds, SecondsParser.formatSeconds(seconds)); + } + delayTt.setText(MessageFormat.format(Messages.delayTooltip0, detail)); + }); + delay.setTooltip(delayTt); + + // Initial focus + Platform.runLater(() -> description.requestFocus()); + } + + /** + * Validates input and sends the configuration off to the message broker. + */ + @Override + public void validateAndStore() { + + final AlarmClientLeaf pv = new AlarmClientLeaf(null, alarmTreeItem.getName()); + + LocalDateTime enableDate; + try { + enableDate = determineEnableDate(); + } catch (Exception e) { + Logger.getLogger(LeafConfigDialogController.class.getName()) + .log(Level.WARNING, "Invalid enable date specified", e); + return; + } + if (enableDate != null) { + pv.setEnabledDate(enableDate); + } else { + pv.setEnabled(itemEnabledProperty.get()); + } + + pv.setDescription(descriptionProperty.get()); + pv.setLatching(latchingProperty.get()); + pv.setAnnunciating(annunciatingProperty.get()); + pv.setDelay(delayValueFactory.getValue()); + pv.setCount(countValueFactory.getValue()); + // TODO Check filter expression + pv.setFilter(enablingFilterProperty.getValue()); + + pv.setGuidance(guidance.getItems()); + pv.setDisplays(displays.getItems()); + pv.setCommands(commands.getItems()); + pv.setActions(actions.getItems()); + + try { + alarmClient.sendItemConfigurationUpdate(alarmTreeItem.getPathName(), pv); + } catch (Exception ex) { + ExceptionDetailsErrorDialog.openError("Error", "Cannot update item", ex); + } + } +} diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/AlarmTreeView.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/AlarmTreeView.java index 179b971ce0..2d4bfa7184 100644 --- a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/AlarmTreeView.java +++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/AlarmTreeView.java @@ -19,7 +19,6 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Level; import java.util.stream.Collectors; @@ -34,6 +33,7 @@ import org.phoebus.applications.alarm.model.BasicState; import org.phoebus.applications.alarm.ui.AlarmContextMenuHelper; import org.phoebus.applications.alarm.ui.AlarmUI; +import org.phoebus.applications.alarm.ui.config.ItemConfigDialog; import org.phoebus.framework.selection.Selection; import org.phoebus.framework.selection.SelectionService; import org.phoebus.ui.application.ContextMenuService; diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/ComponentActionHelper.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/ComponentActionHelper.java new file mode 100644 index 0000000000..0426b0730c --- /dev/null +++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/ComponentActionHelper.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2025 European Spallation Source ERIC. + */ + +package org.phoebus.applications.alarm.ui.tree; + +import javafx.scene.Node; +import javafx.scene.control.Alert; +import javafx.scene.control.ButtonType; +import org.phoebus.applications.alarm.client.AlarmClient; +import org.phoebus.applications.alarm.client.AlarmClientLeaf; +import org.phoebus.applications.alarm.model.AlarmTreeItem; +import org.phoebus.applications.alarm.ui.Messages; +import org.phoebus.framework.jobs.JobManager; +import org.phoebus.ui.dialog.DialogHelper; +import org.phoebus.ui.dialog.ExceptionDetailsErrorDialog; + +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.List; + +/** + * Code externalized from {@link EnableComponentAction}. + */ +public class ComponentActionHelper { + + /** + * Updates a component or PV node to enable or disable alarms + * + * @param node The visual component relative to which a confirmation dialog is positioned. + * @param model {@link AlarmClient} dispatching producer messages. + * @param items {@link List} of items subject for update, e.g. selected by user. + * @param enable If true, enable alarms on selected nodes, otherwise disable. + */ + public static void updateEnablement(final Node node, final AlarmClient model, final List> items, boolean enable) { + final List pvs = new ArrayList<>(); + for (AlarmTreeItem item : items) { + findAffectedPVs(item, pvs, enable); + } + + // If this affects exactly one PV, just do it. + // Otherwise ask for confirmation + if (pvs.size() != 1) { + final Alert dialog = new Alert(Alert.AlertType.CONFIRMATION); + dialog.setTitle(enable ? Messages.enableAlarms : Messages.disableAlarms); + if (pvs.isEmpty()) { + dialog.setHeaderText( + enable + ? Messages.headerAlreadyEnabled + : Messages.headerAlreadyDisabled); + } else { + dialog.setHeaderText(MessageFormat.format( + enable + ? Messages.headerConfirmEnable + : Messages.headerConfirmDisable, + pvs.size())); + } + DialogHelper.positionDialog(dialog, node, -100, -50); + if (dialog.showAndWait().get() != ButtonType.OK) { + return; + } + } + + JobManager.schedule(enable ? Messages.enableAlarms : Messages.disableAlarms, monitor -> + { + for (AlarmClientLeaf pv : pvs) { + final AlarmClientLeaf copy = pv.createDetachedCopy(); + if (copy.setEnabled(enable)) + try { + model.sendItemConfigurationUpdate(pv.getPathName(), copy); + } catch (Exception e) { + ExceptionDetailsErrorDialog.openError(Messages.error, + copy.isEnabled() ? Messages.enableAlarmFailed : Messages.disableAlarmFailed, + e); + throw e; + } + } + }); + } + + /** + * @param item Node where to start recursing for PVs that would be affected + * @param pvs Array to update with PVs that would be affected + */ + public static void findAffectedPVs(final AlarmTreeItem item, final List pvs, boolean enable) { + if (item instanceof AlarmClientLeaf) { + final AlarmClientLeaf pv = (AlarmClientLeaf) item; + // If pv has different enablement, and wasn't already added + // because selection contains its parent as well as the PV itself... + if (pv.isEnabled() != enable && !pvs.contains(pv)) { + pvs.add(pv); + } + } else { + for (AlarmTreeItem sub : item.getChildren()) { + findAffectedPVs(sub, pvs, enable); + } + } + } +} diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/ConfigureComponentAction.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/ConfigureComponentAction.java index 282641c809..febedc90cd 100644 --- a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/ConfigureComponentAction.java +++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/ConfigureComponentAction.java @@ -10,6 +10,7 @@ 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.config.ItemConfigDialog; import org.phoebus.ui.dialog.DialogHelper; import org.phoebus.ui.javafx.ImageCache; diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/EnableComponentAction.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/EnableComponentAction.java index ea64bdc4c0..625d3513e0 100644 --- a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/EnableComponentAction.java +++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/EnableComponentAction.java @@ -7,119 +7,42 @@ *******************************************************************************/ package org.phoebus.applications.alarm.ui.tree; -import java.text.MessageFormat; -import java.util.ArrayList; -import java.util.List; - +import javafx.scene.Node; +import javafx.scene.control.MenuItem; import org.phoebus.applications.alarm.client.AlarmClient; -import org.phoebus.applications.alarm.client.AlarmClientLeaf; import org.phoebus.applications.alarm.model.AlarmTreeItem; import org.phoebus.applications.alarm.ui.AlarmUI; import org.phoebus.applications.alarm.ui.Messages; -import org.phoebus.framework.jobs.JobManager; -import org.phoebus.ui.dialog.DialogHelper; -import org.phoebus.ui.dialog.ExceptionDetailsErrorDialog; import org.phoebus.ui.javafx.ImageCache; -import javafx.scene.Node; -import javafx.scene.control.Alert; -import javafx.scene.control.Alert.AlertType; -import javafx.scene.control.ButtonType; -import javafx.scene.control.MenuItem; +import java.util.List; -/** Action that enables items in the alarm tree configuration - * @author Kay Kasemir +/** + * Action that enables items in the alarm tree configuration + * + * @author Kay Kasemir */ @SuppressWarnings("nls") -class EnableComponentAction extends MenuItem -{ - /** @param node Node to position dialog - * @param model {@link AlarmClient} - * @param items Items to enable +class EnableComponentAction extends MenuItem { + /** + * @param node Node to position dialog + * @param model {@link AlarmClient} + * @param items Items to enable */ - public EnableComponentAction(final Node node, final AlarmClient model, final List> items) - { - if (doEnable()) - { - setText("Enable Alarms"); + public EnableComponentAction(final Node node, final AlarmClient model, final List> items) { + if (doEnable()) { + setText(Messages.enableAlarms); setGraphic(ImageCache.getImageView(AlarmUI.class, "/icons/enabled.png")); - } - else - { - setText("Disable Alarms"); + } else { + setText(Messages.disableAlarms); setGraphic(ImageCache.getImageView(AlarmUI.class, "/icons/disabled.png")); } - setOnAction(event -> - { - final List pvs = new ArrayList<>(); - for (AlarmTreeItem item : items) - findAffectedPVs(item, pvs); - - // If this affects exactly one PV, just do it. - // Otherwise ask for confirmation - if (pvs.size() != 1) - { - final Alert dialog = new Alert(AlertType.CONFIRMATION); - dialog.setTitle(getText()); - if (pvs.size() == 0) - dialog.setHeaderText( - doEnable() - ? "All PVs in the selected section are already enabled" - : "All PVs in the selected section are already disabled"); - else - dialog.setHeaderText(MessageFormat.format( - doEnable() - ? "Enable all PVs in the selected section of the alarm hierarchy?\n" + - "This would enable {0} PVs" - : "Disable all PVs in the selected section of the alarm hierarchy?\n" + - "This would disable {0} PVs", - pvs.size())); - DialogHelper.positionDialog(dialog, node, -100, -50); - if (dialog.showAndWait().get() != ButtonType.OK) - return; - } - - JobManager.schedule(getText(), monitor -> - { - for (AlarmClientLeaf pv : pvs) - { - final AlarmClientLeaf copy = pv.createDetachedCopy(); - if (copy.setEnabled(doEnable())) - try { - model.sendItemConfigurationUpdate(pv.getPathName(), copy); - } catch (Exception e) { - ExceptionDetailsErrorDialog.openError(Messages.error, - copy.isEnabled() ? Messages.enableAlarmFailed : Messages.disableAlarmFailed, - e); - throw e; - } - } - }); - }); + setOnAction(event -> ComponentActionHelper.updateEnablement(node, model, items, doEnable())); } // Implementation can actually disable or enable. Which one is it going to be? - protected boolean doEnable() - { + protected boolean doEnable() { return true; } - - /** @param item Node where to start recursing for PVs that would be affected - * @param pvs Array to update with PVs that would be affected - */ - private void findAffectedPVs(final AlarmTreeItem item, final List pvs) - { - if (item instanceof AlarmClientLeaf) - { - final AlarmClientLeaf pv = (AlarmClientLeaf) item; - // If pv has different enablement, and wasn't already added - // because selection contains its parent as well as the PV itself... - if (pv.isEnabled() != doEnable() && !pvs.contains(pv)) - pvs.add(pv); - } - else - for (AlarmTreeItem sub : item.getChildren()) - findAffectedPVs(sub, pvs); - } } diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/ItemConfigDialog.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/ItemConfigDialog.java deleted file mode 100644 index 6a8edaafc3..0000000000 --- a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/ItemConfigDialog.java +++ /dev/null @@ -1,370 +0,0 @@ -/******************************************************************************* - * Copyright (c) 2018-2021 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.property.SimpleBooleanProperty; -import javafx.event.ActionEvent; -import javafx.event.EventHandler; -import javafx.geometry.Pos; -import javafx.scene.control.Alert; -import javafx.scene.control.Button; -import javafx.scene.control.ButtonType; -import javafx.scene.control.CheckBox; -import javafx.scene.control.ComboBox; -import javafx.scene.control.DateCell; -import javafx.scene.control.Dialog; -import javafx.scene.control.Label; -import javafx.scene.control.ScrollPane; -import javafx.scene.control.Spinner; -import javafx.scene.control.TextField; -import javafx.scene.control.TextFormatter; -import javafx.scene.control.Tooltip; -import javafx.scene.input.KeyCode; -import javafx.scene.input.KeyEvent; -import javafx.scene.layout.ColumnConstraints; -import javafx.scene.layout.GridPane; -import javafx.scene.layout.HBox; -import javafx.scene.layout.Priority; -import javafx.stage.Modality; -import javafx.util.Duration; -import javafx.util.StringConverter; -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.AlarmClientNode; -import org.phoebus.applications.alarm.model.AlarmTreeItem; -import org.phoebus.applications.alarm.ui.tree.datetimepicker.DateTimePicker; -import org.phoebus.ui.dialog.DialogHelper; -import org.phoebus.ui.dialog.ExceptionDetailsErrorDialog; -import org.phoebus.util.time.SecondsParser; -import org.phoebus.util.time.TimeParser; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.format.DateTimeParseException; -import java.time.temporal.TemporalAmount; - - -/** - * Dialog for editing {@link AlarmTreeItem} - * - *

When pressing "OK", dialog sends updated - * configuration. - */ -@SuppressWarnings("nls") -class ItemConfigDialog extends Dialog { - private TextField description; - private CheckBox enabled, latching, annunciating; - private DateTimePicker enabled_date_picker; - private Spinner delay, count; - private TextField filter; - private ComboBox relative_date; - private final TitleDetailTable guidance, displays, commands; - private final TitleDetailDelayTable actions; - - private final SimpleBooleanProperty itemEnabled = new SimpleBooleanProperty(); - - public ItemConfigDialog(final AlarmClient model, final AlarmTreeItem item) { - // Allow multiple instances - initModality(Modality.NONE); - setTitle("Configure " + item.getName()); - - final GridPane layout = new GridPane(); - layout.setHgap(5); - layout.setVgap(5); - - // First fixed-size column for labels - // Second column grows - final ColumnConstraints col1 = new ColumnConstraints(190); - final ColumnConstraints col2 = new ColumnConstraints(); - col2.setHgrow(Priority.ALWAYS); - layout.getColumnConstraints().setAll(col1, col2); - - int row = 0; - - // Show item path, allow copying it out. - // Can't edit; that's done via rename or move actions. - layout.add(new Label("Path:"), 0, row); - final TextField path = new TextField(item.getPathName()); - path.setEditable(false); - layout.add(path, 1, row++); - - if (item instanceof AlarmClientLeaf leaf) { - itemEnabled.setValue(((AlarmClientLeaf) item).isEnabled()); - - layout.add(new Label("Description:"), 0, row); - description = new TextField(leaf.getDescription()); - description.setTooltip(new Tooltip("Alarm description, also used for annunciation")); - layout.add(description, 1, row++); - GridPane.setHgrow(description, Priority.ALWAYS); - - layout.add(new Label("Behavior:"), 0, row); - enabled = new CheckBox("Enabled"); - enabled.setTooltip(new Tooltip("Enable alarms? See also 'Enabling Filter'")); - enabled.setSelected(leaf.isEnabled()); - - itemEnabled.addListener((obs, o, n) -> { - ((AlarmClientLeaf) item).setEnabled(n); - }); - - enabled.setOnAction(e -> { - itemEnabled.setValue(enabled.isSelected()); - if (enabled.isSelected()) { - relative_date.getSelectionModel().clearSelection(); - relative_date.setValue(null); - enabled_date_picker.getEditor().clear(); - enabled_date_picker.setValue(null); - } - // User has unchecked checkbox to disable alarm -> disable indefinitely. - if (!enabled.isSelected()) { - ((AlarmClientLeaf) item).setEnabled(false); - } - }); - - latching = new CheckBox("Latch"); - latching.setTooltip(new Tooltip("Latch alarm until acknowledged?")); - latching.setSelected(leaf.isLatching()); - latching.disableProperty().bind(itemEnabled.not()); - - annunciating = new CheckBox("Annunciate"); - annunciating.setTooltip(new Tooltip("Request audible alarm annunciation (using the description)?")); - annunciating.setSelected(leaf.isAnnunciating()); - annunciating.disableProperty().bind(itemEnabled.not()); - - layout.add(new HBox(10, enabled, latching, annunciating), 1, row++); - - layout.add(new Label("Disable until:"), 0, row); - enabled_date_picker = new DateTimePicker(); - enabled_date_picker.setTooltip(new Tooltip("Select a date until which the alarm should be disabled")); - enabled_date_picker.setDateTimeValue(leaf.getEnabledDate()); - enabled_date_picker.setPrefSize(280, 25); - enabled_date_picker.setDisable(!leaf.isEnabled()); - enabled_date_picker.disableProperty().bind(itemEnabled.not()); - - - enabled_date_picker.addEventHandler(KeyEvent.KEY_PRESSED, keyEvent -> { - if (keyEvent.getCode() == KeyCode.ENTER) { - try { - // Test that the input is well-formed (if the input - // isn't well-formed, valueConverter.fromString() - // throws a DateTimeParseException): - TextFormatter textFormatter = enabled_date_picker.getEditor().getTextFormatter(); - StringConverter valueConverter = textFormatter.getValueConverter(); - LocalDate dateTime = (LocalDate) valueConverter.fromString(enabled_date_picker.getEditor().getText()); - - enabled_date_picker.getEditor().commitValue(); - } - catch (DateTimeParseException dateTimeParseException) { - // The input was not well-formed. Prevent further - // processing by consuming the key-event: - keyEvent.consume(); - } - } - }); - - relative_date = new ComboBox<>(); - relative_date.setTooltip(new Tooltip("Select a predefined duration for disabling the alarm")); - relative_date.getItems().addAll(AlarmSystem.shelving_options); - relative_date.setPrefSize(200, 25); - relative_date.setDisable(!leaf.isEnabled()); - relative_date.disableProperty().bind(itemEnabled.not()); - - final EventHandler relative_event_handler = (ActionEvent e) -> - { - enabled.setSelected(false); - enabled_date_picker.getEditor().clear(); - }; - - relative_date.setOnAction(relative_event_handler); - - // setOnAction for relative date must be set to null as to not trigger event when setting value - enabled_date_picker.setOnAction((ActionEvent e) -> - { - if (enabled_date_picker.getDateTimeValue() != null) { - relative_date.setOnAction(null); - enabled.setSelected(false); - enabled_date_picker.getEditor().commitValue(); - relative_date.getSelectionModel().clearSelection(); - relative_date.setValue(null); - relative_date.setOnAction(relative_event_handler); - } - }); - - // Configure date picker to disable selection of all dates in the past. - enabled_date_picker.setDayCellFactory(picker -> new DateCell() { - public void updateItem(LocalDate date, boolean empty) { - super.updateItem(date, empty); - LocalDate today = LocalDate.now(); - setDisable(empty || date.isBefore(today)); - } - }); - - final HBox until_box = new HBox(10, enabled_date_picker, relative_date); - until_box.setAlignment(Pos.CENTER); - HBox.setHgrow(relative_date, Priority.ALWAYS); - layout.add(until_box, 1, row++); - - layout.add(new Label("Alarm Delay [seconds]:"), 0, row); - delay = new Spinner<>(0, Integer.MAX_VALUE, leaf.getDelay()); - final Tooltip delay_tt = new Tooltip(); - delay_tt.setShowDuration(Duration.seconds(30)); - delay_tt.setOnShowing(event -> - { - final int seconds = leaf.getDelay(); - final String detail; - if (seconds <= 0) - detail = "With the current delay of 0 seconds, alarms trigger immediately"; - else { - final String hhmmss = SecondsParser.formatSeconds(seconds); - detail = "With the current delay of " + seconds + " seconds, alarms trigger after " + hhmmss + " hours:minutes:seconds"; - } - delay_tt.setText("Alarms are indicated when they persist for at least this long.\n" + detail); - }); - delay.setTooltip(delay_tt); - delay.setEditable(true); - delay.setPrefWidth(80); - delay.disableProperty().bind(itemEnabled.not()); - layout.add(delay, 1, row++); - - layout.add(new Label("Alarm Count [within delay]:"), 0, row); - count = new Spinner<>(0, Integer.MAX_VALUE, leaf.getCount()); - count.setTooltip(new Tooltip("Alarms are indicated when they occur this often within the delay")); - count.setEditable(true); - count.setPrefWidth(80); - count.disableProperty().bind(itemEnabled.not()); - layout.add(count, 1, row++); - - layout.add(new Label("Enabling Filter:"), 0, row); - filter = new TextField(leaf.getFilter()); - filter.setTooltip(new Tooltip("Optional expression for enabling the alarm")); - filter.disableProperty().bind(itemEnabled.not()); - layout.add(filter, 1, row++); - - // Initial focus on description - Platform.runLater(() -> description.requestFocus()); - } - - // Layout has two column - // The PV-specific items above use two columns. - // If there's no PV, - // the following items use one column or span two columns. - // There must be _something_ in the second column with Hgrow=Always - // to cause the layout to fill its parent area. - // 'dummy' is used for that. - - // Guidance: - layout.add(new Label("Guidance:"), 0, row++, 2, 1); - guidance = new TitleDetailTable(item.getGuidance()); - guidance.setPrefHeight(100); - layout.add(guidance, 0, row++, 2, 1); - - // Displays: - layout.add(new Label("Displays:"), 0, row++, 2, 1); - displays = new TitleDetailTable(item.getDisplays()); - displays.setPrefHeight(100); - layout.add(displays, 0, row++, 2, 1); - - // Commands: - layout.add(new Label("Commands:"), 0, row++, 2, 1); - commands = new TitleDetailTable(item.getCommands()); - commands.setPrefHeight(100); - layout.add(commands, 0, row++, 2, 1); - - // Automated Actions: - layout.add(new Label("Automated Actions:"), 0, row++, 2, 1); - actions = new TitleDetailDelayTable(item.getActions()); - actions.setPrefHeight(100); - layout.add(actions, 0, row++, 2, 1); - - // Dialog is quite high; allow scroll - final ScrollPane scroll = new ScrollPane(layout); - - // Scroll pane stops the content from resizing, - // so tell content to use the widths of the scroll pane - // minus 40 to provide space for the scroll bar, and suggest minimum width - scroll.widthProperty().addListener((p, old, width) -> layout.setPrefWidth(Math.max(width.doubleValue() - 40, 450))); - - getDialogPane().setContent(scroll); - setResizable(true); - - getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); - - final Button ok = (Button) getDialogPane().lookupButton(ButtonType.OK); - ok.addEventFilter(ActionEvent.ACTION, event -> - validateAndStore(model, item, event)); - - setResultConverter(button -> button == ButtonType.OK); - } - - /** - * Send requested configuration - * - * @param model {@link AlarmClient} - * @param item Original item - * @param event Button click event, consumed if save action fails (e.g. Kafka not reachable) - */ - private void validateAndStore(final AlarmClient model, final AlarmTreeItem item, ActionEvent event) { - final AlarmTreeItem config; - - if (item instanceof AlarmClientLeaf) { - final AlarmClientLeaf pv = new AlarmClientLeaf(null, item.getName()); - - boolean validEnableDate; - { - final LocalDateTime selected_enable_date = enabled_date_picker.getDateTimeValue(); - final String relative_enable_date = relative_date.getValue(); - - if ((selected_enable_date != null)) { - validEnableDate = pv.setEnabledDate(selected_enable_date); - } else if (relative_enable_date != null) { - final TemporalAmount amount = TimeParser.parseTemporalAmount(relative_enable_date); - final LocalDateTime update_date = LocalDateTime.now().plus(amount); - validEnableDate = pv.setEnabledDate(update_date); - } else { - pv.setEnabled(itemEnabled.get()); - validEnableDate = true; - } - } - - if (!validEnableDate) { - Alert prompt = new Alert(Alert.AlertType.INFORMATION); - prompt.setTitle("'Disable until' is set to a point in time in the past"); - prompt.setHeaderText("'Disable until' is set to a point in time in the past"); - prompt.setContentText("The option 'disable until' must be set to a point in time in the future."); - DialogHelper.positionDialog(prompt, enabled_date_picker, 0, 0); - prompt.showAndWait(); - - event.consume(); - return; - } - - pv.setDescription(description.getText().trim()); - pv.setLatching(latching.isSelected()); - pv.setAnnunciating(annunciating.isSelected()); - pv.setDelay(delay.getValue()); - pv.setCount(count.getValue()); - // TODO Check filter expression - pv.setFilter(filter.getText().trim()); - - config = pv; - } else - config = new AlarmClientNode(null, item.getName()); - config.setGuidance(guidance.getItems()); - config.setDisplays(displays.getItems()); - config.setCommands(commands.getItems()); - config.setActions(actions.getItems()); - - try { - model.sendItemConfigurationUpdate(item.getPathName(), config); - } catch (Exception ex) { - ExceptionDetailsErrorDialog.openError("Error", "Cannot update item", ex); - event.consume(); - } - } -} \ No newline at end of file diff --git a/app/alarm/ui/src/main/resources/org/phoebus/applications/alarm/ui/config/ComponentConfigDialog.fxml b/app/alarm/ui/src/main/resources/org/phoebus/applications/alarm/ui/config/ComponentConfigDialog.fxml new file mode 100644 index 0000000000..ec7d0991cb --- /dev/null +++ b/app/alarm/ui/src/main/resources/org/phoebus/applications/alarm/ui/config/ComponentConfigDialog.fxml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/alarm/ui/src/main/resources/org/phoebus/applications/alarm/ui/config/LeafConfigDialog.fxml b/app/alarm/ui/src/main/resources/org/phoebus/applications/alarm/ui/config/LeafConfigDialog.fxml new file mode 100644 index 0000000000..dacf80906b --- /dev/null +++ b/app/alarm/ui/src/main/resources/org/phoebus/applications/alarm/ui/config/LeafConfigDialog.fxml @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/alarm/ui/src/main/resources/org/phoebus/applications/alarm/ui/messages.properties b/app/alarm/ui/src/main/resources/org/phoebus/applications/alarm/ui/messages.properties index b2ab1ecd20..4aee77de3b 100644 --- a/app/alarm/ui/src/main/resources/org/phoebus/applications/alarm/ui/messages.properties +++ b/app/alarm/ui/src/main/resources/org/phoebus/applications/alarm/ui/messages.properties @@ -16,16 +16,51 @@ # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. # # - -error=Error acknowledgeFailed=Failed to acknowledge alarm(s) addComponentFailed=Failed to add component -disableAlarmFailed=Failed to disable alarm +alarmCount=Alarm Count [within delay]: +alarmCountTooltip=Alarms are indicated when they occur this often within the delay +alarmDelay=Alarm Delay [seconds]: +annunciate=Annunciate +annunciateTooltip=Request audible alarm annunciation (using the description)? +automatedActions=Automated Actions: +behavior=Behavior: +behaviorTooltip=Enable alarms? See also 'Enabling Filter' +commands=Commands: +configure=Configure +dateTimePickerTooltip=Select a date until which the alarm should be disabled +delayTooltip0=Alarms are indicated when they persist for at least this long.\n{0} +delayTooltip1=With the current delay of 0 seconds, alarms trigger immediately +delayTooltip2=With the current delay of {0}seconds, alarms trigger after {1} hours:minutes:seconds +description=Description: +descriptionTooltip=Alarm description, also used for annunciation disabled=Disabled +disableAlarmFailed=Failed to disable alarm +disableAlarms=Disable Alarms disabledUntil=Disabled until +disableUntil=Disable until: +displays=Displays: +enabled=Enabled +enablingFilter=Enabling Filter: +enablingFilterTooltip=Optional expression for enabling the alarm +error=Error enableAlarmFailed=Failed to enable alarm +enableAlarms=Enable Alarms +guidance=Guidance: +headerAlreadyDisabled=All PVs in the selected section are already disabled +headerAlreadyEnabled=All PVs in the selected section are already enabled +headerConfirmDisable=Disable all PVs in the selected section of the alarm hierarchy?\nThis would disable {0} PVs. +headerConfirmDisableWithEnableDate=Disable all PVs in the selected section of the alarm hierarchy until {0}?\nThis would affect {1} PVs. +headerConfirmEnable=Enable all PVs in the selected section of the alarm hierarchy?\nThis would enable {0} PVs. +latch=Latch +latchTooltip=Latch alarm until acknowledged? moveItemFailed=Failed to move item partlyDisabled=Partly disabled +partlyDisabled2=(Partly disabled) +path=Path: +promptTitle='Disable until' is set to a point in time in the past +promptContent=The option 'disable until' must be set to a point in time in the future. +relativeDateTooltip=Select a predefined duration for disabling the alarm removeComponentFailed=Failed to remove component renameItemFailed=Failed to rename item timer=Timer diff --git a/app/alarm/ui/src/test/java/org/phoebus/applications/alarm/ui/config/ComponentConfigDialogControllerTest.java b/app/alarm/ui/src/test/java/org/phoebus/applications/alarm/ui/config/ComponentConfigDialogControllerTest.java new file mode 100644 index 0000000000..f65a9cae55 --- /dev/null +++ b/app/alarm/ui/src/test/java/org/phoebus/applications/alarm/ui/config/ComponentConfigDialogControllerTest.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2025 European Spallation Source ERIC. + */ + +package org.phoebus.applications.alarm.ui.config; + +import org.junit.jupiter.api.Test; +import org.phoebus.applications.alarm.client.AlarmClientLeaf; +import org.phoebus.applications.alarm.client.AlarmClientNode; +import org.phoebus.applications.alarm.model.AlarmTreeItem; +import org.phoebus.applications.alarm.model.BasicState; +import org.phoebus.applications.alarm.model.EnabledState; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class ComponentConfigDialogControllerTest { + + @Test + public void testFindAffectedPvs_5() { + AlarmTreeItem parent1 = new AlarmClientNode("/root", "parent1"); + AlarmTreeItem parent2 = new AlarmClientNode("/parent", "parent2"); + AlarmClientLeaf child1 = new AlarmClientLeaf("/paren1", "child1"); + parent2.addToParent(parent1); + child1.setEnabled(false); + child1.addToParent(parent1); + AlarmClientLeaf child2 = new AlarmClientLeaf("/parent2", "child2"); + child2.addToParent(parent2); + AlarmClientLeaf child3 = new AlarmClientLeaf("/parent2", "child3"); + child3.setEnabled(new EnabledState(LocalDateTime.now())); + child3.addToParent(parent2); + + List total = new ArrayList<>(); + List disabled = new ArrayList<>(); + List withEnableDate = new ArrayList<>(); + + ComponentConfigDialogController.findAffectedPVs(parent1, total, disabled, withEnableDate); + + assertEquals(3, total.size()); + assertEquals(2, disabled.size()); + assertEquals(1, withEnableDate.size()); + } +} diff --git a/app/alarm/ui/src/test/java/org/phoebus/applications/alarm/ui/tree/ComponentActionHelperTest.java b/app/alarm/ui/src/test/java/org/phoebus/applications/alarm/ui/tree/ComponentActionHelperTest.java new file mode 100644 index 0000000000..91ad5c79c1 --- /dev/null +++ b/app/alarm/ui/src/test/java/org/phoebus/applications/alarm/ui/tree/ComponentActionHelperTest.java @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2025 European Spallation Source ERIC. + */ + +package org.phoebus.applications.alarm.ui.tree; + +import org.junit.jupiter.api.Test; +import org.phoebus.applications.alarm.client.AlarmClientLeaf; +import org.phoebus.applications.alarm.client.AlarmClientNode; +import org.phoebus.applications.alarm.model.AlarmTreeItem; +import org.phoebus.applications.alarm.model.BasicState; +import org.phoebus.applications.alarm.model.EnabledState; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class ComponentActionHelperTest { + + @Test + public void testFindAffectedPvs_1() { + AlarmTreeItem parent1 = new AlarmClientNode("/root", "parent1"); + AlarmTreeItem parent2 = new AlarmClientNode("/parent", "parent2"); + AlarmClientLeaf child1 = new AlarmClientLeaf("/paren1", "child1"); + parent2.addToParent(parent1); + child1.addToParent(parent1); + AlarmClientLeaf child2 = new AlarmClientLeaf("/parent2", "child2"); + child2.addToParent(parent2); + + List disabledPvs = new ArrayList<>(); + ComponentActionHelper.findAffectedPVs(parent1, disabledPvs, true); + + assertTrue(disabledPvs.isEmpty()); + } + + @Test + public void testFindAffectedPvs_2() { + AlarmTreeItem parent1 = new AlarmClientNode("/root", "parent1"); + AlarmTreeItem parent2 = new AlarmClientNode("/parent", "parent2"); + AlarmClientLeaf child1 = new AlarmClientLeaf("/paren1", "child1"); + parent2.addToParent(parent1); + child1.addToParent(parent1); + AlarmClientLeaf child2 = new AlarmClientLeaf("/parent2", "child2"); + child2.setEnabled(new EnabledState(LocalDateTime.now())); + child2.addToParent(parent2); + + List disabledPvs = new ArrayList<>(); + ComponentActionHelper.findAffectedPVs(parent1, disabledPvs, true); + + assertEquals(1, disabledPvs.size()); + } + + @Test + public void testFindAffectedPvs_3() { + AlarmTreeItem parent1 = new AlarmClientNode("/root", "parent1"); + AlarmTreeItem parent2 = new AlarmClientNode("/parent", "parent2"); + AlarmClientLeaf child1 = new AlarmClientLeaf("/paren1", "child1"); + parent2.addToParent(parent1); + child1.addToParent(parent1); + AlarmClientLeaf child2 = new AlarmClientLeaf("/parent2", "child2"); + child2.addToParent(parent2); + AlarmClientLeaf child3 = new AlarmClientLeaf("/parent2", "child3"); + child3.setEnabled(false); + child3.addToParent(parent2); + + List disabledPvs = new ArrayList<>(); + ComponentActionHelper.findAffectedPVs(parent1, disabledPvs, true); + + assertEquals(1, disabledPvs.size()); + } +}