diff --git a/app/display/representation/src/main/java/org/csstudio/display/builder/representation/ToolkitListener.java b/app/display/representation/src/main/java/org/csstudio/display/builder/representation/ToolkitListener.java
index cc8398d9d2..48b647990c 100644
--- a/app/display/representation/src/main/java/org/csstudio/display/builder/representation/ToolkitListener.java
+++ b/app/display/representation/src/main/java/org/csstudio/display/builder/representation/ToolkitListener.java
@@ -13,6 +13,9 @@
import java.util.Optional;
import java.util.concurrent.FutureTask;
+import java.util.Optional;
+import java.util.concurrent.FutureTask;
+
/** Listener to a widget representation
*
*
Provides notification of events (action invoked, ..)
diff --git a/app/display/runtime/src/main/java/org/csstudio/display/builder/runtime/app/DisplayRuntimeInstance.java b/app/display/runtime/src/main/java/org/csstudio/display/builder/runtime/app/DisplayRuntimeInstance.java
index edff92f24c..c03c5e4a6c 100644
--- a/app/display/runtime/src/main/java/org/csstudio/display/builder/runtime/app/DisplayRuntimeInstance.java
+++ b/app/display/runtime/src/main/java/org/csstudio/display/builder/runtime/app/DisplayRuntimeInstance.java
@@ -174,6 +174,7 @@ public static DisplayRuntimeInstance ofDisplayModel(final DisplayModel model)
layout.addEventFilter(KeyEvent.KEY_PRESSED, this::handleKeys);
dock_item.addClosedNotification(this::onClosed);
+ representation_init.run();
}
@Override
@@ -530,6 +531,11 @@ public void onClosed()
navigation.dispose();
}
+ DisplayModel getActiveModel()
+ {
+ return active_model;
+ }
+
public void addListener(ToolkitListener listener){
this.getRepresentation().removeListener(listener);
this.getRepresentation().addListener(listener);
diff --git a/app/display/runtime/src/main/java/org/csstudio/display/builder/runtime/app/DockItemRepresentation.java b/app/display/runtime/src/main/java/org/csstudio/display/builder/runtime/app/DockItemRepresentation.java
index 5a13862a88..c612c903e2 100644
--- a/app/display/runtime/src/main/java/org/csstudio/display/builder/runtime/app/DockItemRepresentation.java
+++ b/app/display/runtime/src/main/java/org/csstudio/display/builder/runtime/app/DockItemRepresentation.java
@@ -14,6 +14,7 @@
import org.csstudio.display.builder.model.DisplayModel;
import org.csstudio.display.builder.model.Widget;
+import org.csstudio.display.builder.representation.ToolkitListener;
import org.csstudio.display.builder.representation.ToolkitRepresentation;
import org.csstudio.display.builder.representation.javafx.JFXRepresentation;
import org.phoebus.framework.workbench.ApplicationService;
diff --git a/app/pom.xml b/app/pom.xml
index 1d6a74803a..d7da655281 100644
--- a/app/pom.xml
+++ b/app/pom.xml
@@ -36,5 +36,6 @@
imageviewer
credentials-management
eslog
+ ux-analytics
diff --git a/app/ux-analytics/monitor/pom.xml b/app/ux-analytics/monitor/pom.xml
new file mode 100644
index 0000000000..4089eb1938
--- /dev/null
+++ b/app/ux-analytics/monitor/pom.xml
@@ -0,0 +1,186 @@
+
+
+
+
+ org.phoebus
+ app-ux-analytics
+ 4.7.4-SNAPSHOT
+
+ 4.0.0
+ app-analytics-monitor
+
+
+ 22
+ 22
+ UTF-8
+
+
+
+ org.openjfx
+ javafx-graphics
+ 19
+ compile
+
+
+ org.openjfx
+ javafx-fxml
+ 19
+ compile
+
+
+ org.phoebus
+ core-framework
+ 4.7.4-SNAPSHOT
+ compile
+
+
+ org.neo4j.driver
+ neo4j-java-driver
+ 5.19.0
+
+
+ org.slf4j
+ slf4j-jdk14
+
+
+
+
+ org.mongodb
+ mongodb-driver-sync
+ 5.1.0
+
+
+ org.phoebus
+ core-ui
+ 4.7.4-SNAPSHOT
+ compile
+
+
+ org.phoebus
+ app-display-model
+ 4.7.4-SNAPSHOT
+ compile
+
+
+ org.phoebus
+ app-display-runtime
+ 4.7.4-SNAPSHOT
+ compile
+
+
+ org.apache.httpcomponents
+ httpclient
+ 4.5.14
+ compile
+
+
+ org.junit.jupiter
+ junit-jupiter
+ ${junit.version}
+ test
+
+
+
+ org.mariadb.jdbc
+ mariadb-java-client
+ 3.4.0
+ runtime
+
+
+
+ com.sun.jersey.contribs
+ jersey-multipart
+ 1.19
+
+
+ com.fasterxml.jackson.datatype
+ jackson-datatype-jsr310
+ ${jackson.version}
+
+
+ com.sun.activation
+ javax.activation
+ 1.2.0
+
+
+ com.fasterxml.jackson.core
+ jackson-core
+ ${jackson.version}
+
+
+ com.sun.jersey
+ jersey-client
+ 1.19.4
+ compile
+
+
+ org.glassfish.jersey.core
+ jersey-common
+ 2.22.2
+ compile
+
+
+ com.sun.jersey
+ jersey-json
+ 1.19.4
+
+
+ javax.ws.rs
+ javax.ws.rs-api
+ 2.1.1
+ compile
+
+
+ org.phoebus
+ app-filebrowser
+ 4.7.4-SNAPSHOT
+ compile
+
+
+ org.testng
+ testng
+ RELEASE
+ test
+
+
+ org.testfx
+ testfx-junit
+ 4.0.13-alpha
+ test
+
+
+ org.mockito
+ mockito-inline
+ ${mockito.version}
+ test
+
+
+ net.bytebuddy
+ byte-buddy
+ 1.17.5
+ test
+
+
+ org.mockito
+ mockito-core
+ 5.11.0
+ test
+
+
+
+
+
+
+ src/main/resources
+
+
+ src/main/java
+
+ **/*.fxml
+
+
+
+
+
\ No newline at end of file
diff --git a/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/UXAMonitor.java b/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/UXAMonitor.java
new file mode 100644
index 0000000000..8eb44cb7e3
--- /dev/null
+++ b/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/UXAMonitor.java
@@ -0,0 +1,58 @@
+package org.phoebus.applications.uxanalytics.monitor;
+
+
+import java.util.ArrayList;
+import java.util.concurrent.ExecutorService;
+
+import javafx.stage.Stage;
+import org.csstudio.display.builder.runtime.RuntimeUtil;
+import org.phoebus.applications.uxanalytics.monitor.backend.database.BackendConnection;
+import org.phoebus.applications.uxanalytics.monitor.representation.ActiveWindowsService;
+
+/**
+ * Singleton Class to capture UI events (clicks, PV Writes, Display open/close)
+ * and dispatch them to backend connections
+ */
+public class UXAMonitor{
+ private static final UXAMonitor instance = new UXAMonitor();
+ private ArrayList activeStages;
+ private static ActiveWindowsService activeWindowsService = ActiveWindowsService.getInstance();
+ private static final ExecutorService executor = RuntimeUtil.getExecutor();
+
+ //This dispatcher has exactly one phoebus related connection and one JFX related connection
+ //If you want to broadcast to multiple back-ends, subclass BackendConnection to notify them.
+ private BackendConnection phoebusConnection;
+ private BackendConnection jfxConnection;
+
+ private UXAMonitor(){
+ }
+
+ public BackendConnection getJfxConnection() {return jfxConnection;}
+
+ public BackendConnection getPhoebusConnection() { return phoebusConnection;}
+
+ public static synchronized UXAMonitor getInstance() {
+ return instance;
+ }
+
+ public void notifyConnectionChange(BackendConnection connection){
+ jfxConnection = connection;
+ phoebusConnection = connection;
+ }
+
+ public void setPhoebusConnection(BackendConnection phoebusConnection) {
+ this.phoebusConnection = phoebusConnection;
+ }
+
+ public void disableTracking(){
+ activeWindowsService.stop();
+ }
+
+ public void enableTracking(){
+ activeWindowsService.start();
+ }
+
+ public void setJfxConnection(BackendConnection jfxConnection) {
+ this.jfxConnection = jfxConnection;
+ }
+}
diff --git a/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/UXAMouseMonitor.java b/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/UXAMouseMonitor.java
new file mode 100644
index 0000000000..3dfb5e32e8
--- /dev/null
+++ b/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/UXAMouseMonitor.java
@@ -0,0 +1,22 @@
+package org.phoebus.applications.uxanalytics.monitor;
+
+import javafx.event.EventHandler;
+import javafx.scene.input.MouseEvent;
+import org.phoebus.applications.uxanalytics.monitor.representation.ActiveTab;
+
+public class UXAMouseMonitor implements EventHandler{
+
+ private final UXAMonitor monitor = UXAMonitor.getInstance();
+ private final ActiveTab tab;
+
+ public UXAMouseMonitor(ActiveTab tab){
+ this.tab = tab;
+ }
+
+ @Override
+ public void handle(MouseEvent event) {
+ if(event.getEventType().equals(MouseEvent.MOUSE_CLICKED)){
+ monitor.getJfxConnection().handleClick(tab, (int) event.getX(), (int) event.getY());
+ }
+ }
+}
diff --git a/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/UXAToolkitListener.java b/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/UXAToolkitListener.java
new file mode 100644
index 0000000000..a3723087ef
--- /dev/null
+++ b/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/UXAToolkitListener.java
@@ -0,0 +1,113 @@
+package org.phoebus.applications.uxanalytics.monitor;
+
+import org.csstudio.display.builder.model.Widget;
+import org.csstudio.display.builder.model.spi.ActionInfo;
+import org.csstudio.display.builder.model.properties.ActionInfoBase;
+import org.csstudio.display.builder.representation.ToolkitListener;
+import org.csstudio.display.builder.runtime.app.DisplayInfo;
+import org.phoebus.applications.uxanalytics.monitor.representation.ActiveTab;
+import org.phoebus.applications.uxanalytics.monitor.util.ResourceOpenSources;
+
+import java.util.*;
+import java.util.logging.Logger;
+
+
+public class UXAToolkitListener implements ToolkitListener {
+
+ Logger logger = Logger.getLogger(UXAToolkitListener.class.getName());
+
+ public static final HashMap openSources = new HashMap<>(
+ Map.of(
+ org.csstudio.display.builder.runtime.app.actionhandlers.OpenDisplayActionHandler.class.getName()+".handleAction", ResourceOpenSources.ACTION_BUTTON,
+ org.phoebus.applications.filebrowser.FileBrowserController.class.getName()+".openResource", ResourceOpenSources.FILE_BROWSER,
+ org.phoebus.ui.application.PhoebusApplication.class.getName()+".fileOpen", ResourceOpenSources.FILE_BROWSER,
+ org.csstudio.display.builder.runtime.app.NavigationAction.class.getName()+".navigate", ResourceOpenSources.NAVIGATION_BUTTON,
+ org.phoebus.ui.internal.MementoHelper.class.getName()+".restoreDockItem", ResourceOpenSources.RESTORED,
+ org.phoebus.ui.application.PhoebusApplication.class.getName()+".createTopResourcesMenu", ResourceOpenSources.TOP_RESOURCES,
+ org.csstudio.display.builder.runtime.app.DisplayRuntimeInstance.class.getName()+".reload", ResourceOpenSources.RELOAD
+ )
+ );
+
+ private ActiveTab tabWrapper;
+ private final UXAMonitor monitor = UXAMonitor.getInstance();
+ public void setTabWrapper(ActiveTab tabWrapper){
+ this.tabWrapper = tabWrapper;
+ }
+
+ @Override
+ public void handleAction(Widget widget, ActionInfo action) {
+ monitor.getPhoebusConnection().handleAction(tabWrapper, widget, action);
+ }
+
+ @Override
+ public void handleWrite(Widget widget, Object value) {
+ monitor.getPhoebusConnection().handlePVWrite(tabWrapper, widget, widget.getPropertyValue("pv_name"), value.toString());
+ }
+
+ @Override
+ public void handleClick(Widget widget, boolean with_control) {
+ //nothing for now
+ }
+
+ //Traverse down a given call stack to find out what caused the display to open
+ private static ResourceOpenSources getSourceOfOpen(StackTraceElement[] stackTrace){
+
+ for(StackTraceElement e: stackTrace){
+ String methodName = unmangleLambda(e.getMethodName());
+ String fullName = e.getClassName()+"."+methodName;
+ if(openSources.containsKey(fullName)){
+ return openSources.get(fullName);
+ }
+ }
+ return ResourceOpenSources.UNKNOWN;
+ }
+
+ private static String unmangleLambda(String expression){
+ //find index of first '$' after 'lambda$'
+ if(expression.contains("lambda$")) {
+ int start = expression.indexOf("lambda$") + 7;
+ int end = expression.indexOf("$", start);
+ return expression.substring(start, end);
+ }
+ return expression;
+ }
+
+ public UXAMonitor getMonitor() {
+ return monitor;
+ }
+
+ @Override
+ public void handleMethodCalled(Object... user_args) {
+ StackTraceElement[] stackTrace;
+ if(user_args[0] instanceof List &&
+ ((List) user_args[0]).get(0) instanceof DisplayInfo &&
+ user_args[1] instanceof StackTraceElement[] &&
+ monitor.getPhoebusConnection()!=null) {
+ List dst_src = (List) user_args[0];
+ stackTrace = (StackTraceElement[]) user_args[1];
+ for(StackTraceElement e: stackTrace){
+ String methodName = e.getMethodName();
+ if (methodName.equals("loadDisplayFile")) {
+ ResourceOpenSources source = getSourceOfOpen(stackTrace);
+ switch(source){
+ case ACTION_BUTTON:
+ //nothing, handled by handleAction
+ break;
+
+ case NAVIGATION_BUTTON:
+ case RELOAD:
+ monitor.getPhoebusConnection().handleDisplayOpen(dst_src.get(0), dst_src.get(1), source);
+ break;
+
+ case FILE_BROWSER:
+ case RESTORED:
+ case TOP_RESOURCES:
+ case UNKNOWN:
+ monitor.getPhoebusConnection().handleDisplayOpen(dst_src.get(0), null, source);
+ }
+ }
+ }
+ }
+ }
+}
+
diff --git a/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/backend/database/BackendConnection.java b/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/backend/database/BackendConnection.java
new file mode 100644
index 0000000000..446e189e50
--- /dev/null
+++ b/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/backend/database/BackendConnection.java
@@ -0,0 +1,53 @@
+package org.phoebus.applications.uxanalytics.monitor.backend.database;
+
+import org.csstudio.display.builder.model.Widget;
+import org.csstudio.display.builder.model.spi.ActionInfo;
+import org.csstudio.display.builder.runtime.app.DisplayInfo;
+import org.phoebus.applications.uxanalytics.monitor.backend.image.ImageClient;
+import org.phoebus.applications.uxanalytics.monitor.util.ResourceOpenSources;
+import org.phoebus.applications.uxanalytics.monitor.representation.ActiveTab;
+import org.phoebus.security.store.SecureStore;
+import org.phoebus.security.tokens.AuthenticationScope;
+import org.phoebus.security.tokens.ScopedAuthenticationToken;
+
+import java.util.logging.Logger;
+
+@FunctionalInterface
+public interface BackendConnection {
+ public Boolean connect(String hostOrRegion, Integer port, String usernameOrAccessKey, String passwordOrSecretKey);
+ public default boolean tryAutoConnect(AuthenticationScope scope){
+ //try to auto-connect with saved credentials
+ try{
+ SecureStore store = new SecureStore();
+ ScopedAuthenticationToken scopedAuthenticationToken = store.getScopedAuthenticationToken(scope);
+ if (scopedAuthenticationToken != null) {
+ String username = scopedAuthenticationToken.getUsername();
+ String password = scopedAuthenticationToken.getPassword();
+ return connect(null, null, username, password);
+ }
+ else{
+ //try anonymous login
+ if(!connect(null, null, "root", ""))
+ throw new Exception(this.getClass().toString());
+ return true;
+ }
+ }
+ catch(Exception e){
+ Logger.getAnonymousLogger().fine("Failed to auto-connect for UX Analytics backend connection: " + e.getMessage());
+ return false;
+ }
+ }
+ public default String getProtocol(){return "";}
+ public default String getHost(){return "localhost";}
+ public default String getPort(){return "";}
+ public default String getDefaultUsername(){return "";}
+ public default Integer tearDown(){return -1;}
+ public default void setImageClient(ImageClient imageClient){}
+ public default void handleClick(ActiveTab who, Widget widget, Integer x, Integer y){}
+ public default void handleClick(ActiveTab who, Integer x, Integer y){this.handleClick(who, null, x, y);}
+ public default void handleAction(ActiveTab who, Widget widget, ActionInfo info){}
+ public default void handlePVWrite(ActiveTab who, Widget widget, String PVName, String value){};
+ public default void handleDisplayOpen(DisplayInfo target, DisplayInfo src, ResourceOpenSources how){}
+ public default void consentToCollection(boolean consent){}
+
+}
diff --git a/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/backend/database/ServiceLayerConnection.java b/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/backend/database/ServiceLayerConnection.java
new file mode 100644
index 0000000000..0721ec16b3
--- /dev/null
+++ b/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/backend/database/ServiceLayerConnection.java
@@ -0,0 +1,322 @@
+package org.phoebus.applications.uxanalytics.monitor.backend.database;
+
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.logging.Logger;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.json.JsonMapper;
+import com.sun.jersey.api.client.Client;
+import com.sun.jersey.api.client.ClientResponse;
+import org.csstudio.display.actions.OpenDisplayAction;
+import org.csstudio.display.actions.WritePVAction;
+import org.csstudio.display.builder.model.Widget;
+import org.csstudio.display.builder.model.spi.ActionInfo;
+import org.csstudio.display.builder.model.util.ModelResourceUtil;
+import org.csstudio.display.builder.runtime.app.DisplayInfo;
+import org.phoebus.applications.uxanalytics.monitor.backend.image.ImageClient;
+import org.phoebus.applications.uxanalytics.monitor.representation.ActiveTab;
+import org.phoebus.applications.uxanalytics.monitor.util.FileUtils;
+import org.phoebus.applications.uxanalytics.monitor.util.ResourceOpenSources;
+import org.phoebus.framework.preferences.PhoebusPreferenceService;
+import org.phoebus.security.tokens.AuthenticationScope;
+
+import javax.ws.rs.core.MediaType;
+import java.util.Map;
+
+import static org.csstudio.display.actions.OpenDisplayAction.OPEN_DISPLAY;
+import static org.csstudio.display.actions.WritePVAction.WRITE_PV;
+
+public class ServiceLayerConnection implements BackendConnection{
+
+ //string names for "origin" sources (i.e., not another display)
+ public static final String SRC_FILE_BROWSER = ResourceOpenSources.FILE_BROWSER.name().toLowerCase();
+ public static final String SRC_TOP_RESOURCES = ResourceOpenSources.TOP_RESOURCES.name().toLowerCase();
+ public static final String SRC_RESTORATION = ResourceOpenSources.RESTORED.name().toLowerCase();
+ public static final String SRC_UNKNOWN = ResourceOpenSources.UNKNOWN.name().toLowerCase();
+
+ public static final String TYPE_ORIGIN = "origin_source";
+ public static final String TYPE_DISPLAY = "display";
+ public static final String TYPE_PV = "pv";
+
+ public static final String ACTION_WROTE = "wrote_to";
+ public static final String ACTION_OPENED = "opened";
+ public static final String ACTION_NAVIGATED = "navigation_button";
+ public static final String ACTION_RELOADED = "reloaded";
+
+ //track if last attempt failed, to prevent spamming the log
+ private boolean exceptionRaised = false;
+
+ static Logger logger = Logger.getLogger(ServiceLayerConnection.class.getName());
+
+ Client client = new Client();
+ private String endpoint;
+
+ private final JsonMapper mapper = new JsonMapper();
+
+ public static ServiceLayerConnection instance;
+ public static ServiceLayerConnection getInstance(){
+ if(instance == null){
+ instance = new ServiceLayerConnection();
+ }
+ return instance;
+ }
+
+ public void resetLogging(){
+ exceptionRaised = false;
+ }
+
+ public void killLogging(){
+ exceptionRaised = true;
+ }
+
+ private void logMessage(String msg){
+ if(!exceptionRaised) {
+ logger.warning(msg + "\nFuture messages will only be logged at FINE level until the connection is re-established.");
+ killLogging();
+ }
+ else{
+ logger.fine(msg);
+ }
+ }
+
+ static void logMessageForBadPath(String src, String target){
+ logger.fine("Source or target path do not have a sensible source root. " +
+ "Source: " + src + ", target: " + target + ".\n" +
+ "Not recording connection to service layer.\n" +
+ "If the target is a local file, there must be a SCM root to index from.\n " +
+ "If it's a web resource, the environment variable PHOEBUS_WEB_CONTENT_ROOT\n" +
+ "or the setting org.phoebus.applications.uxanalytics.monitor.web_content_root" +
+ "must be defined in settings.ini." );
+ }
+
+ private ServiceLayerConnection(){
+ tryAutoConnect(null);
+ }
+
+ private boolean checkConnection(){
+ try{
+ String response = client.resource(endpoint+"/checkConnection").get(String.class);
+ JsonNode node = new ObjectMapper().readTree(response);
+ boolean appStatus = node.get("applicationStatus").asText().equals("OK");
+ boolean dbStatus = node.get("mariaDatabaseStatus").asText().equals("OK");
+ boolean graphStatus = node.get("graphDatabaseStatus").asText().equals("OK");
+ if (!(appStatus && dbStatus && graphStatus)) {
+ logMessage("UX Analytics service layer connection failed. Application status: " + appStatus + ", MariaDB status: " + dbStatus + ", GraphDB status: " + graphStatus);
+ }
+ else {
+ resetLogging();
+ }
+ return appStatus && dbStatus && graphStatus;
+ }
+ catch(Exception e){
+ logMessage("Exception connecting to UX Analytics service layer: " + e.getMessage());
+ killLogging();
+ return false;
+ }
+ }
+
+ @Override
+ public Boolean connect(String hostOrRegion, Integer port, String usernameOrAccessKey, String passwordOrSecretKey) {
+ //user/password not used
+ if(hostOrRegion==null)
+ hostOrRegion = getHost();
+ if (port==null)
+ port = Integer.parseInt(getPort());
+ endpoint = getProtocol() + hostOrRegion + ":" + port + "/analytics";
+ return checkConnection();
+ }
+
+ @Override
+ public String getProtocol() {
+ return "http://";
+ }
+
+ @Override
+ public String getHost(){
+ return PhoebusPreferenceService.userNodeForClass(this.getClass()).get("host", "localhost");
+ }
+
+ @Override
+ public String getPort() {
+ return PhoebusPreferenceService.userNodeForClass(this.getClass()).get("port", "8080");
+ }
+
+ @Override
+ public boolean tryAutoConnect(AuthenticationScope scope) {
+ return BackendConnection.super.tryAutoConnect(scope);
+ }
+
+ @Override
+ public String getDefaultUsername() {
+ return ""; //not used
+ }
+
+ @Override
+ public Integer tearDown() {
+ client.destroy();
+ return 0;
+ }
+
+ @Override
+ public void setImageClient(ImageClient imageClient) {
+ return; //don't do anything with images yet
+ }
+
+ @Override
+ public void handleClick(ActiveTab who, Integer x, Integer y) {
+ //todo: check if image exists, store that response in a map so we don't have to check again
+ //if image exists, send in a separate API call
+
+ //for now just record a click with a POST request
+ HashMap click = new HashMap();
+ click.put("x", x.toString());
+ click.put("y", y.toString());
+ click.put("timestamp", Instant.now().toString());
+ try {
+ click.put("filename", FileUtils.analyticsPathForTab(who));
+ String json = mapper.writeValueAsString(click);
+ ClientResponse response = client.resource(endpoint + "/recordClick")
+ .type(MediaType.APPLICATION_JSON_TYPE)
+ .post(ClientResponse.class, json);
+ resetLogging();
+ }
+ catch(Exception e){
+ logMessage("Exception connecting to UX Analytics service layer: " + e.getMessage());
+ }
+ }
+
+ @Override
+ public void handleAction(ActiveTab who, Widget widget, ActionInfo info) {
+ String actionType = info.getType();
+ switch (actionType){
+ case WRITE_PV:
+ handlePVWrite(who, widget, ((WritePVAction) info).getPV(), ((WritePVAction) info).getValue());
+ break;
+ case OPEN_DISPLAY:
+ handleDisplayOpenViaActionButton(who, widget, (OpenDisplayAction)info );
+ break;
+ default:
+ break;
+ }
+ }
+
+ private void recordConnection(String srcType, String dstType, String srcName, String dstName, String action){
+ try {
+ Map connection = Map.of("srcName", srcName,
+ "srcType", srcType,
+ "dstName", dstName,
+ "dstType", dstType,
+ "action", action);
+ ClientResponse response = client.resource(endpoint + "/recordNavigation")
+ .type(MediaType.APPLICATION_JSON_TYPE)
+ .post(ClientResponse.class, mapper.writeValueAsString(connection));
+ resetLogging();
+ }
+ catch(Exception e){
+ logMessage("Exception connecting to UX Analytics service layer: " + e.getMessage());
+ }
+ }
+
+ private void recordConnection(String srcType, String dstType, String srcName, String dstName, String action, String via){
+ if(via==null) {
+ recordConnection(srcType, dstType, srcName, dstName, action);
+ return;
+ }
+ try {
+ Map connection = Map.of("srcName", srcName,
+ "srcType", srcType,
+ "dstName", dstName,
+ "dstType", dstType,
+ "action", action,
+ "via", via);
+ ClientResponse response = client.resource(endpoint + "/recordNavigation")
+ .type(MediaType.APPLICATION_JSON_TYPE)
+ .post(ClientResponse.class, mapper.writeValueAsString(connection));
+ resetLogging();
+ }
+ catch(Exception e){
+ logMessage("Exception connecting to UX Analytics service layer: " + e.getMessage());
+ }
+ }
+
+ @Override
+ public void handlePVWrite(ActiveTab who, Widget widget, String PVName, String value) {
+ String display = FileUtils.analyticsPathForTab(who);
+ String widgetID = widget.getName();
+ recordConnection(TYPE_DISPLAY, TYPE_PV, display, PVName, ACTION_WROTE, widgetID);
+ }
+
+ private void handleDisplayOpenViaActionButton(ActiveTab who, Widget widget, OpenDisplayAction openDisplayAction) {
+ try{
+ DisplayInfo currentDisplayInfo = who.getDisplayInfo();
+ String widgetID = widget.getName();
+ String sourcePath = who.getAnalyticsName();
+ String targetPath = FileUtils.getAnalyticsPathFor(
+ ModelResourceUtil.resolveResource(
+ currentDisplayInfo.getPath(),
+ openDisplayAction.getFile())
+ );
+ if(sourcePath != null && targetPath != null){
+ recordConnection(TYPE_DISPLAY, TYPE_DISPLAY, sourcePath, targetPath, ACTION_OPENED, widgetID);
+ resetLogging();
+ }
+ else{
+ logMessageForBadPath( currentDisplayInfo.getPath() ,openDisplayAction.getFile() );
+ }
+ }
+ catch(Exception e){
+ logMessage("Exception connecting to UX Analytics service layer: " + e.getMessage());
+ }
+ }
+
+ @Override
+ public void handleDisplayOpen(DisplayInfo target, DisplayInfo src, ResourceOpenSources how) {
+ String sourcePath = "UNKNOWN";
+ try {
+ String targetPath = FileUtils.getAnalyticsPathFor(target.getPath());
+ String sourceType = null;
+ String action = ACTION_OPENED;
+ switch (how) {
+ case RELOAD:
+ case NAVIGATION_BUTTON:
+ if (src != null) {
+ sourcePath = FileUtils.getAnalyticsPathFor(src.getPath());
+ sourceType = TYPE_DISPLAY;
+ if (sourcePath.equals(targetPath)) {
+ action = ACTION_RELOADED;
+ } else {
+ action = ACTION_NAVIGATED;
+ }
+ }
+ break;
+ case FILE_BROWSER:
+ sourcePath = SRC_FILE_BROWSER;
+ sourceType = TYPE_ORIGIN;
+ break;
+ case TOP_RESOURCES:
+ sourcePath = SRC_TOP_RESOURCES;
+ sourceType = TYPE_ORIGIN;
+ break;
+ case RESTORED:
+ sourcePath = SRC_RESTORATION;
+ sourceType = TYPE_ORIGIN;
+ break;
+ default:
+ sourcePath = SRC_UNKNOWN;
+ sourceType = TYPE_ORIGIN;
+ }
+ if(sourcePath != null && targetPath != null){
+ recordConnection(sourceType, TYPE_DISPLAY, sourcePath, targetPath, action, null);
+ }
+ else{
+ logMessageForBadPath(sourcePath, targetPath);
+ }
+ resetLogging();
+ }
+ catch(Exception e){
+ logMessage("Exception connecting to UX Analytics service layer: " + e.getMessage());
+ }
+ }
+}
diff --git a/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/backend/image/FilesystemImageClient.java b/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/backend/image/FilesystemImageClient.java
new file mode 100644
index 0000000000..7de92772b7
--- /dev/null
+++ b/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/backend/image/FilesystemImageClient.java
@@ -0,0 +1,62 @@
+package org.phoebus.applications.uxanalytics.monitor.backend.image;
+
+import org.phoebus.framework.preferences.PhoebusPreferenceService;
+
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.net.URI;
+import java.util.logging.Logger;
+
+public class FilesystemImageClient implements ImageClient {
+
+ static final Logger logger = Logger.getLogger(FilesystemImageClient.class.getName());
+
+ //Filesystem location to store images
+ private String imageLocation = "./images";
+
+ public static FilesystemImageClient instance;
+ public static FilesystemImageClient getInstance(){
+ if(instance == null){
+ instance = new FilesystemImageClient();
+ }
+ return instance;
+ }
+
+ private FilesystemImageClient(){
+ imageLocation = PhoebusPreferenceService.userNodeForClass(FilesystemImageClient.class).get("directory", "./images");
+ }
+
+ @Override
+ public Integer uploadImage(URI image, BufferedImage screenshot) {
+ try {
+ File outputfile = new File(imageLocation +"/"+ image.getPath()+".png");
+ //make directories if they don't exist
+ outputfile.getParentFile().mkdirs();
+ logger.info("Saving image to: " + outputfile.getAbsolutePath());
+ javax.imageio.ImageIO.write(screenshot, "png", outputfile);
+ return 0;
+ } catch (Exception e) {
+ e.printStackTrace();
+ return -1;
+ }
+ }
+
+ @Override
+ public boolean imageExists(URI image) {
+ return new File(imageLocation +"/"+ image.getPath()+".png").exists();
+ }
+
+ public boolean setImageLocation(String location) {
+ this.imageLocation = location;
+ File dir = new File(location);
+ try{
+ if (!dir.exists()) {
+ return dir.mkdirs();
+ }
+ return true;
+ } catch (Exception e) {
+ e.printStackTrace();
+ return false;
+ }
+ }
+}
diff --git a/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/backend/image/ImageClient.java b/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/backend/image/ImageClient.java
new file mode 100644
index 0000000000..214faeb4d1
--- /dev/null
+++ b/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/backend/image/ImageClient.java
@@ -0,0 +1,11 @@
+package org.phoebus.applications.uxanalytics.monitor.backend.image;
+
+import java.awt.image.BufferedImage;
+import java.net.URI;
+
+public interface ImageClient {
+ public Integer uploadImage(URI image, BufferedImage screenshot);
+ public boolean imageExists(URI image);
+ public default void connect(String username, String password){}
+ public default void disconnect(){}
+}
diff --git a/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/representation/ActiveTab.java b/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/representation/ActiveTab.java
new file mode 100644
index 0000000000..06a3301678
--- /dev/null
+++ b/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/representation/ActiveTab.java
@@ -0,0 +1,153 @@
+package org.phoebus.applications.uxanalytics.monitor.representation;
+
+import javafx.application.Platform;
+import javafx.scene.Node;
+import javafx.stage.Window;
+import org.csstudio.display.builder.model.Widget;
+import org.csstudio.display.builder.representation.ToolkitListener;
+import org.csstudio.display.builder.representation.javafx.JFXRepresentation;
+import org.csstudio.display.builder.runtime.app.DisplayInfo;
+import org.csstudio.display.builder.runtime.app.DisplayRuntimeInstance;
+import org.phoebus.applications.uxanalytics.monitor.UXAMouseMonitor;
+import org.phoebus.applications.uxanalytics.monitor.UXAToolkitListener;
+import org.phoebus.applications.uxanalytics.monitor.util.FileUtils;
+import org.phoebus.ui.docking.DockItemWithInput;
+
+import javafx.scene.input.MouseEvent;
+import org.phoebus.ui.docking.DockStage;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentLinkedDeque;
+import java.util.concurrent.Future;
+import java.util.function.Supplier;
+
+public class ActiveTab {
+
+ private final ConcurrentLinkedDeque widgets;
+ private final DockItemWithInput parentTab;
+ private final String parentWindowID;
+ private final ToolkitListener toolkitListener;
+ private final Node jfxNode;
+ private final UXAMouseMonitor mouseMonitor;
+ private boolean listenersAdded = false;
+ private String analyticsName;
+
+ public ActiveTab(DockItemWithInput tab, ActiveWindowsService activeWindowsService, String windowID){
+ widgets = new ConcurrentLinkedDeque<>();
+ parentTab = tab;
+ toolkitListener = new UXAToolkitListener();
+ ((UXAToolkitListener)toolkitListener).setTabWrapper(this);
+ jfxNode = tab.getContent();
+ mouseMonitor = new UXAMouseMonitor(this);
+ parentWindowID = windowID;
+ if(activeWindowsService.isActive())
+ this.addListeners();
+ if (windowID != null && !windowID.isBlank()){
+ final Supplier> ok_to_close = () -> {
+ this.detachListeners();
+ ActiveWindowsService.getInstance().getActiveWindowsAndTabs().get(parentWindowID).remove(this);
+ return CompletableFuture.completedFuture(true);
+ };
+ parentTab.addCloseCheck(ok_to_close);
+ }
+ }
+
+ public ActiveTab(DockItemWithInput tab, String windowID){
+ this(tab, ActiveWindowsService.getInstance(), windowID);
+ }
+
+ public ToolkitListener getToolkitListener(){
+ return toolkitListener;
+ }
+
+ public DisplayInfo getDisplayInfo(){
+ DisplayRuntimeInstance instance = parentTab.getApplication();
+ return instance.getDisplayInfo();
+ }
+
+ public synchronized void add(Widget widget){
+ widgets.add(widget);
+ }
+
+ public synchronized void remove(Widget widget){
+ widgets.remove(widget);
+ }
+
+ public synchronized void detachListeners(){
+ if(!listenersAdded) return;
+ DisplayRuntimeInstance instance = (DisplayRuntimeInstance) parentTab.getApplication();
+ if(instance != null) {
+ instance.removeListener(toolkitListener);
+ }
+ if(jfxNode != null) {
+ jfxNode.removeEventFilter(MouseEvent.MOUSE_CLICKED, mouseMonitor);
+ }
+ listenersAdded = false;
+ }
+
+ public synchronized void addListeners(){
+ if (listenersAdded) return;
+ DisplayRuntimeInstance instance = (DisplayRuntimeInstance) parentTab.getApplication();
+ if(instance != null)
+ instance.addListener(toolkitListener);
+ if(jfxNode != null)
+ jfxNode.addEventFilter(MouseEvent.MOUSE_CLICKED, mouseMonitor);
+ listenersAdded = true;
+ }
+
+ public boolean isListening(){
+ return listenersAdded;
+ }
+
+ public DockItemWithInput getParentTab() {
+ return parentTab;
+ }
+
+ public JFXRepresentation getJFXRepresentation() {return ((DisplayRuntimeInstance)parentTab.getApplication()).getRepresentation();}
+
+ public double getZoom(){
+ return getJFXRepresentation().getZoom();
+ }
+
+ public double getHeight(){
+ return getJFXRepresentation().getModelRoot().getHeight();
+ }
+
+ public double getWidth(){
+ return getJFXRepresentation().getModelRoot().getWidth();
+ }
+
+ public String getAnalyticsName(){
+ if(analyticsName == null){
+ analyticsName = FileUtils.analyticsPathForTab(this);
+ }
+ return analyticsName;
+ }
+
+ public UXAMouseMonitor getMouseMonitor() {
+ return mouseMonitor;
+ }
+
+ @Override
+ public String toString() {
+ return (String)parentTab.getProperties().get(DockStage.KEY_ID);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj instanceof String){
+ return this.toString().equals(obj);
+ }
+ else if (obj instanceof ActiveTab){
+ return this.toString().equals(((ActiveTab) obj).toString());
+ }
+ else if (obj instanceof DockItemWithInput){
+ return this.toString().equals(((DockItemWithInput) obj).getProperties().get(DockStage.KEY_ID));
+ }
+ return false;
+ }
+
+ public String getParentWindowID() {
+ return parentWindowID;
+ }
+}
diff --git a/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/representation/ActiveTabsOfWindow.java b/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/representation/ActiveTabsOfWindow.java
new file mode 100644
index 0000000000..4504f79445
--- /dev/null
+++ b/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/representation/ActiveTabsOfWindow.java
@@ -0,0 +1,60 @@
+package org.phoebus.applications.uxanalytics.monitor.representation;
+
+import javafx.stage.Window;
+import org.csstudio.display.builder.model.Widget;
+import org.phoebus.ui.docking.DockItemWithInput;
+import org.phoebus.ui.docking.DockStage;
+
+import java.util.concurrent.ConcurrentHashMap;
+
+public class ActiveTabsOfWindow {
+
+ private final ActiveWindowsService activeWindowsService;
+ private final Window parentWindow;
+ private final ConcurrentHashMap activeTabs = new ConcurrentHashMap<>();
+
+ public static String tabIDOf(DockItemWithInput tab){
+ return (String)tab.getProperties().get(DockStage.KEY_ID);
+ }
+
+ public ActiveTabsOfWindow(Window window){
+ this.parentWindow = window;
+ activeWindowsService = ActiveWindowsService.getInstance();
+ }
+
+ public void add(ActiveTab tab) throws Exception {
+ this.remove(tab);
+ activeTabs.putIfAbsent(tab.toString(), tab);
+ }
+
+ public void add(DockItemWithInput tab) throws Exception {
+ this.remove(tab);
+ activeTabs.putIfAbsent(tabIDOf(tab), new ActiveTab(tab, parentWindow.getProperties().get(DockStage.KEY_ID).toString()));
+ }
+
+ public void remove(DockItemWithInput tab){
+ if(activeTabs.containsKey(tabIDOf(tab))){
+ activeTabs.get(tabIDOf(tab)).detachListeners();
+ activeTabs.remove(tabIDOf(tab));
+ }
+ }
+
+ public void remove(ActiveTab tab){
+ if(activeTabs.containsKey(tab.toString())){
+ activeTabs.get(tab.toString()).detachListeners();
+ activeTabs.remove(tab.toString());
+ }
+ }
+
+ public boolean contains(DockItemWithInput tab){
+ return activeTabs.containsKey(tabIDOf(tab));
+ }
+
+ public synchronized void addWidget(DockItemWithInput tab, Widget widget){
+ activeTabs.get(tabIDOf(tab)).add(widget);
+ }
+
+ public ConcurrentHashMap getActiveTabs() {
+ return activeTabs;
+ }
+}
diff --git a/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/representation/ActiveWindowsService.java b/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/representation/ActiveWindowsService.java
new file mode 100644
index 0000000000..407e89a6da
--- /dev/null
+++ b/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/representation/ActiveWindowsService.java
@@ -0,0 +1,214 @@
+package org.phoebus.applications.uxanalytics.monitor.representation;
+
+import javafx.collections.*;
+import javafx.scene.control.Tab;
+import javafx.stage.Stage;
+import javafx.stage.Window;
+
+import org.csstudio.display.builder.runtime.app.DisplayRuntimeInstance;
+import org.phoebus.ui.docking.DockItem;
+import org.phoebus.ui.docking.DockItemWithInput;
+import org.phoebus.ui.docking.DockPane;
+import org.phoebus.ui.docking.DockStage;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.locks.ReentrantLock;
+
+/*
+ * This class is a singleton that keeps track of all active windows and their tabs.
+ * The state remains tracked, but if 'stop()' is called, the listeners are detached from the actual representation.
+ * This way, if the user consents to tracking, the listeners are reattached and the state is updated without restarting.
+ */
+public class ActiveWindowsService {
+
+ private boolean active = false;
+ private static ActiveWindowsService instance = null;
+ private final ConcurrentHashMap activeWindowsAndTabs = new ConcurrentHashMap<>();
+ private static final ReentrantLock lock = new ReentrantLock();
+
+ ConcurrentHashMap getActiveWindowsAndTabs() {
+ return activeWindowsAndTabs;
+ }
+
+ public void appendToMap(ActiveTab tab) throws Exception {
+ String tabID = (String) tab.getParentTab().getProperties().get(DockStage.KEY_ID);
+ String windowID = tab.getParentWindowID();
+ if(tabID != null && windowID != null){
+ activeWindowsAndTabs.get(windowID).add(tab);
+ }
+ }
+
+ public void removeFromMap(ActiveTab tab) throws Exception {
+ String tabID = (String) tab.getParentTab().getProperties().get(DockStage.KEY_ID);
+ String windowID = tab.getParentWindowID();
+ if(tabID != null && windowID != null){
+ activeWindowsAndTabs.get(windowID).remove(tab);
+ }
+ }
+
+ ListChangeListener UXATabChangeListener = new ListChangeListener<>() {
+ @Override
+ public void onChanged(Change extends Tab> change) {
+ while(change.next()){
+ if(change.wasAdded()){
+ for(Tab tab: change.getAddedSubList()){
+ Window window = change.getList().get(0).getTabPane().getScene().getWindow();
+ if(tab != null && tab.getProperties().get("application") instanceof DisplayRuntimeInstance && tab instanceof DockItemWithInput){
+
+ try {
+ //Creating the wrapper object first (in the application thread) attaches a listener ASAP
+ //we want to catch what caused the display to open
+ String windowID = (String) window.getProperties().get(DockStage.KEY_ID);
+ ActiveTab tabWrapper = new ActiveTab((DockItemWithInput) tab, windowID);
+ DisplayRuntimeInstance instance = (DisplayRuntimeInstance) tabWrapper.getParentTab().getProperties().get("application");
+ //When @DockItem s are initialized, their models aren't ready yet.
+ //On startup, the DockItemWithInput will show up but its DisplayModel will be null.
+ //block until the model is ready, in an individual background thread.
+ new Thread(() -> {
+ try {
+ //block until the model is ready
+ instance.getRepresentation_init().get();
+ lock.lock();
+ appendToMap(tabWrapper);
+ lock.unlock();
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }).start();
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+ }
+ }
+ }
+ };
+
+
+ ListChangeListener UXAWindowChangeListener = new ListChangeListener<>() {
+ @Override
+ public void onChanged(Change extends Window> change) {
+ while (change.next()) {
+ if (change.wasAdded()) {
+ for (javafx.stage.Window window : change.getAddedSubList()) {
+ if(window.getProperties().containsKey(DockStage.KEY_ID)){
+ String windowID = (String) window.getProperties().get(DockStage.KEY_ID);
+ lock.lock();
+ activeWindowsAndTabs.putIfAbsent(windowID, new ActiveTabsOfWindow(window));
+ for(DockPane item: DockStage.getDockPanes((Stage)window)){
+ item.getTabs().removeListener(UXATabChangeListener);
+ item.getTabs().addListener(UXATabChangeListener);
+ }
+ lock.unlock();
+ }
+ }
+ }
+ else if(change.wasRemoved()){
+ for(Window window: change.getRemoved()){
+ if(window.getProperties().containsKey(DockStage.KEY_ID)){
+ String windowID = (String) window.getProperties().get(DockStage.KEY_ID);
+ lock.lock();
+ activeWindowsAndTabs.remove(windowID);
+ lock.unlock();
+ }
+ }
+ }
+ }
+ }
+ };
+
+ private ActiveWindowsService() {
+ }
+
+ //this singleton will be the exclusive communicator with the window list
+ public static ActiveWindowsService getInstance() {
+ lock.lock();
+ if(instance == null){
+ instance = new ActiveWindowsService();
+ instance.addWindowChangeListener();
+ }
+ lock.unlock();
+ return instance;
+ }
+
+ public boolean isActive() {
+ return active;
+ }
+
+ public void addWindowChangeListener(){
+ javafx.stage.Window.getWindows().addListener(UXAWindowChangeListener);
+ }
+
+ //re-synchronize state representation if tracking resumes after application startup
+ private void reinitialize(){
+ clear();
+ for (javafx.stage.Window window : javafx.stage.Window.getWindows()) {
+ if (window.getProperties().containsKey(DockStage.KEY_ID)) {
+ String windowID = (String) window.getProperties().get(DockStage.KEY_ID);
+ activeWindowsAndTabs.putIfAbsent(windowID, new ActiveTabsOfWindow(window));
+ for (DockPane item : DockStage.getDockPanes((Stage) window)) {
+ item.getTabs().removeListener(UXATabChangeListener);
+ item.getTabs().addListener(UXATabChangeListener);
+ for(DockItem tab: item.getDockItems()){
+ try {
+ activeWindowsAndTabs.get(windowID).add((DockItemWithInput) tab);
+ }
+ catch (ClassCastException ignored){
+ //not a DockItemWithInput
+ }
+ catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ public void start() {
+ if(!active) {
+ reinitialize();
+ for(ActiveTabsOfWindow window: activeWindowsAndTabs.values()) {
+ for (ActiveTab tab : window.getActiveTabs().values()) {
+ tab.addListeners();
+ }
+ }
+ }
+ active = true;
+ }
+
+ public void stop() {
+ if(active){
+ clear();
+ }
+ active = false;
+ }
+
+ public static void setInstance(ActiveWindowsService instance) {
+ ActiveWindowsService.instance = instance;
+ }
+
+ public ActiveTabsOfWindow getTabsForWindow(Window window){
+ return activeWindowsAndTabs.get((String) window.getProperties().get(DockStage.KEY_ID));
+ }
+
+ public void clear(){
+ for(ActiveTabsOfWindow window: activeWindowsAndTabs.values()) {
+ for (ActiveTab tab : window.getActiveTabs().values()) {
+ tab.detachListeners();
+ }
+ }
+ activeWindowsAndTabs.clear();
+ }
+
+ public ActiveTabsOfWindow getTabsForWindow(String windowID){
+ return activeWindowsAndTabs.get(windowID);
+ }
+
+ public static ActiveTab getUXAWrapperFor(DockItemWithInput tab){
+ Window window = tab.getTabPane().getScene().getWindow();
+ return ActiveWindowsService.getInstance().getTabsForWindow(window).getActiveTabs().get(tab.toString());
+ }
+
+}
\ No newline at end of file
diff --git a/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/util/FileUtils.java b/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/util/FileUtils.java
new file mode 100644
index 0000000000..4232315e7b
--- /dev/null
+++ b/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/util/FileUtils.java
@@ -0,0 +1,198 @@
+package org.phoebus.applications.uxanalytics.monitor.util;
+
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.security.MessageDigest;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.prefs.Preferences;
+
+import javafx.embed.swing.SwingFXUtils;
+import javafx.scene.Node;
+import javafx.scene.SnapshotParameters;
+import javafx.scene.image.WritableImage;
+import org.codehaus.jackson.map.util.LRUMap;
+import org.csstudio.display.builder.model.util.ModelResourceUtil;
+import org.phoebus.applications.uxanalytics.monitor.representation.ActiveTab;
+import org.phoebus.framework.preferences.PhoebusPreferenceService;
+
+public class FileUtils {
+
+ public static final String GIT_METADATA_DIR = ".git";
+ public static final String SVN_METADATA_DIR = ".svn";
+ public static final String HG_METADATA_DIR = ".hg";
+ public static final String WEB_CONTENT_ROOT_ENV_VAR = "PHOEBUS_WEB_CONTENT_ROOT";
+ public static final String WEB_CONTENT_ROOT_SETTING_NAME = "web_content_root";
+ private static Map sha256_cache = new LRUMap<>(0, 100);
+
+
+ private static String getWebContentRootEnvVar(){
+ //check if PHOEBUS_WEB_CONTENT_ROOT is set
+ String webContentRoot;
+ webContentRoot = System.getenv(WEB_CONTENT_ROOT_ENV_VAR);
+ if(webContentRoot != null){
+ return webContentRoot;
+ }
+ return null;
+ }
+
+ private static String getWebContentRootFromSettings(){
+ String webContentRoot;
+ webContentRoot = PhoebusPreferenceService.userNodeForClass(FileUtils.class)
+ .get(WEB_CONTENT_ROOT_SETTING_NAME, null);
+ return webContentRoot;
+ }
+
+ private static String getWebContentRoot(){
+ String webContentRoot = getWebContentRootFromSettings();
+ if(webContentRoot != null){
+ return webContentRoot.substring(webContentRoot.indexOf("://") + 3);
+ }
+ return getWebContentRootEnvVar();
+ }
+
+ private static boolean isGitRepo(File fileObject){
+ return isRepo(fileObject, GIT_METADATA_DIR);
+ }
+
+ private static boolean isSVNRepo(File fileObject){
+ return isRepo(fileObject, SVN_METADATA_DIR);
+ }
+
+ private static boolean isMercurialRepo(File fileObject){
+ return isRepo(fileObject, HG_METADATA_DIR);
+ }
+
+ private static boolean isRepo(File fileObject, String repoType){
+ File file = new File(fileObject.getAbsolutePath() + File.separator + repoType);
+ return file.exists() && file.isDirectory();
+ }
+
+ public static boolean isSourceRoot(File fileObject){
+ return isGitRepo(fileObject) || isSVNRepo(fileObject) || isMercurialRepo(fileObject);
+ }
+
+ private static boolean isURL(final String path)
+ {
+ return path.startsWith("http://") ||
+ path.startsWith("https://") ||
+ path.startsWith("ftp://");
+ }
+
+ //path is, at this point, assumed to be valid
+ public static String findSourceRootOf(String path){
+ //first check if it's a url
+ if(isURL(path)){
+ String webRootNoProtocol = getWebContentRoot();
+ String pathNoProtocol = path.substring(path.indexOf("://") + 3);
+ if(pathNoProtocol.startsWith(webRootNoProtocol)){
+ return getWebContentRoot();
+ }
+ return null;
+ }
+ else{
+ File sourceRoot = findSourceRootOf(new File(path));
+ if (sourceRoot == null){
+ return null;
+ }
+ return sourceRoot.getAbsolutePath();
+ }
+ }
+
+ public static File findSourceRootOf(File fileObject) {
+ File directory = fileObject.getParentFile();
+ while (directory != null) {
+ if (isSourceRoot(directory)) {
+ return directory.getParentFile();
+ }
+ directory = directory.getParentFile();
+ }
+ return null;
+ }
+
+ //re-implement here with Java MessageDigest, so we don't need to depend on elog
+ public static String getFileSHA256(String path){
+ InputStream fis;
+ try {
+ MessageDigest sha256Digest = MessageDigest.getInstance("SHA-256");
+ fis = ModelResourceUtil.openResourceStream(path);
+ byte[] byteArray = new byte[1024];
+ int bytesCount = 0;
+ while ((bytesCount = fis.read(byteArray)) != -1) {
+ sha256Digest.update(byteArray, 0, bytesCount);
+ }
+ byte[] digestBytes = sha256Digest.digest();
+ StringBuilder sb = new StringBuilder();
+ for (byte b : digestBytes) {
+ sb.append(String.format("%02x", b));
+ }
+ return sb.toString();
+ } catch (IOException e) {
+ e.printStackTrace();
+ return null;
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+
+ public static String getPathWithoutSourceRoot(String path){
+ String pathNoProtocol = path;
+ if(isURL(path)){
+ pathNoProtocol = path.substring(path.indexOf("://") + 3);
+ String sourceRootNoProtocol = getWebContentRoot();
+ if(pathNoProtocol.startsWith(sourceRootNoProtocol)){
+ return ModelResourceUtil.normalize(pathNoProtocol.substring(sourceRootNoProtocol.length()));
+ }
+ return null;
+ }
+ else{
+ String sourceRoot = findSourceRootOf(path);
+ if(sourceRoot == null){
+ return null;
+ }
+ return ModelResourceUtil.normalize(new File(path).getAbsolutePath().substring(sourceRoot.length()+1));
+ }
+ }
+
+ private static String getSHA256Suffix(String path){
+ try{
+ return getFileSHA256(path).substring(0, 8);
+ }
+ catch(NullPointerException e){
+ return null;
+ }
+ }
+
+ public static String getAnalyticsPathFor(String path){
+ String cached = sha256_cache.get(path);
+ if(cached != null){
+ return cached;
+ }
+ String pathWithoutRoot = getPathWithoutSourceRoot(path);
+ if(pathWithoutRoot == null){
+ return null;
+ }
+ String first8OfSHA256 = getSHA256Suffix(ModelResourceUtil.normalize(path));
+ if(first8OfSHA256 == null){
+ return null;
+ }
+ String analyticsPath = pathWithoutRoot + "_" + first8OfSHA256;
+ sha256_cache.put(path, analyticsPath);
+ return pathWithoutRoot + "_" + first8OfSHA256;
+ }
+
+ public static String analyticsPathForTab(ActiveTab tab){
+ String path = tab.getDisplayInfo().getPath();
+ return getAnalyticsPathFor(path);
+ }
+
+ public static BufferedImage getSnapshot(ActiveTab who) {
+ Node jfxNode = who.getParentTab().getContent();
+ SnapshotParameters params = new SnapshotParameters();
+ WritableImage snapshot = jfxNode.snapshot(params, null);
+ return SwingFXUtils.fromFXImage(snapshot, null);
+ }
+}
diff --git a/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/util/ResourceOpenSources.java b/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/util/ResourceOpenSources.java
new file mode 100644
index 0000000000..44f2b9aafa
--- /dev/null
+++ b/app/ux-analytics/monitor/src/main/java/org/phoebus/applications/uxanalytics/monitor/util/ResourceOpenSources.java
@@ -0,0 +1,11 @@
+package org.phoebus.applications.uxanalytics.monitor.util;
+
+public enum ResourceOpenSources {
+ UNKNOWN,
+ ACTION_BUTTON,
+ FILE_BROWSER,
+ NAVIGATION_BUTTON,
+ RESTORED,
+ TOP_RESOURCES,
+ RELOAD
+}
diff --git a/app/ux-analytics/monitor/src/main/resources/META-INF/services/org.phoebus.security.authorization.ServiceAuthenticationProvider b/app/ux-analytics/monitor/src/main/resources/META-INF/services/org.phoebus.security.authorization.ServiceAuthenticationProvider
new file mode 100644
index 0000000000..0d5b46d94f
--- /dev/null
+++ b/app/ux-analytics/monitor/src/main/resources/META-INF/services/org.phoebus.security.authorization.ServiceAuthenticationProvider
@@ -0,0 +1,4 @@
+org.phoebus.applications.uxanalytics.monitor.backend.image.authentication.S3AuthenticationProvider
+org.phoebus.applications.uxanalytics.monitor.backend.database.authentication.MongoDBAuthenticationProvider
+org.phoebus.applications.uxanalytics.monitor.backend.database.authentication.Neo4JAuthenticationProvider
+org.phoebus.applications.uxanalytics.monitor.backend.database.authentication.MariaDBAuthenticationProvider
diff --git a/app/ux-analytics/monitor/src/test/java/org/phoebus/applications/uxanalytics/monitor/backend/database/MockActionHandler.java b/app/ux-analytics/monitor/src/test/java/org/phoebus/applications/uxanalytics/monitor/backend/database/MockActionHandler.java
new file mode 100644
index 0000000000..4399039260
--- /dev/null
+++ b/app/ux-analytics/monitor/src/test/java/org/phoebus/applications/uxanalytics/monitor/backend/database/MockActionHandler.java
@@ -0,0 +1,36 @@
+package org.phoebus.applications.uxanalytics.monitor.backend.database;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.util.concurrent.FutureTask;
+
+public class MockActionHandler implements MockHandler{
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+ public boolean good = true;
+ public String received_body = null;
+ public FutureTask received = new FutureTask<>(() -> {
+ return true;
+ });
+
+ @Override
+ public void handle(com.sun.net.httpserver.HttpExchange exchange) {
+ try {
+ exchange.getResponseHeaders().set("Content-Type", "application/json");
+ byte[] requestBody = exchange.getRequestBody().readAllBytes();
+ received_body = new String(requestBody);
+ received.run();
+ if (good) {
+ exchange.sendResponseHeaders(200, 0);
+ exchange.getResponseBody().write("{\"nodesCreated\":1,\"nodesDeleted\":0,\"relationshipsCreated\":1,\"relationshipsDeleted\":0,\"propertiesSet\":3,\"labelsAdded\":1,\"labelsRemoved\":0,\"indexesAdded\":0,\"indexesRemoved\":0,\"constraintsAdded\":0,\"constraintsRemoved\":0,\"systemUpdates\":0}".getBytes());
+ }
+ else {
+ exchange.sendResponseHeaders(500, 0);
+ exchange.getResponseBody().write("{\"error\": \"Internal Server Error\"}".getBytes());
+ }
+ exchange.close();
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+}
diff --git a/app/ux-analytics/monitor/src/test/java/org/phoebus/applications/uxanalytics/monitor/backend/database/MockApplication.java b/app/ux-analytics/monitor/src/test/java/org/phoebus/applications/uxanalytics/monitor/backend/database/MockApplication.java
new file mode 100644
index 0000000000..67d9daa297
--- /dev/null
+++ b/app/ux-analytics/monitor/src/test/java/org/phoebus/applications/uxanalytics/monitor/backend/database/MockApplication.java
@@ -0,0 +1,26 @@
+package org.phoebus.applications.uxanalytics.monitor.backend.database;
+
+import javafx.application.Application;
+import javafx.stage.Stage;
+import org.csstudio.display.builder.representation.javafx.JFXRepresentation;
+import org.csstudio.display.builder.representation.javafx.JFXStageRepresentation;
+import org.csstudio.display.builder.runtime.RuntimeUtil;
+import org.csstudio.display.builder.runtime.app.DisplayRuntimeApplication;
+import org.csstudio.display.builder.runtime.app.DisplayRuntimeInstance;
+import org.phoebus.ui.docking.DockItemWithInput;
+import org.phoebus.ui.docking.DockPane;
+import org.testfx.framework.junit.ApplicationTest;
+
+public class MockApplication extends ApplicationTest {
+
+ private DisplayRuntimeApplication app;
+ private DockItemWithInput dockItem;
+
+
+
+ @Override
+ public void start(Stage stage) throws Exception {
+ String display_path = ServiceLayerConnectionTest.class.getResource("/test.bob").getPath().replace("file:", "");
+ //final DockItemWithInput
+ }
+}
diff --git a/app/ux-analytics/monitor/src/test/java/org/phoebus/applications/uxanalytics/monitor/backend/database/MockCheckConnectionHandler.java b/app/ux-analytics/monitor/src/test/java/org/phoebus/applications/uxanalytics/monitor/backend/database/MockCheckConnectionHandler.java
new file mode 100644
index 0000000000..051f47c558
--- /dev/null
+++ b/app/ux-analytics/monitor/src/test/java/org/phoebus/applications/uxanalytics/monitor/backend/database/MockCheckConnectionHandler.java
@@ -0,0 +1,31 @@
+package org.phoebus.applications.uxanalytics.monitor.backend.database;
+
+public class MockCheckConnectionHandler implements MockHandler {
+
+ boolean appStatus;
+ boolean sqlStatus;
+ boolean graphDatabaseStatus;
+
+ public MockCheckConnectionHandler(boolean appStatus, boolean sqlStatus, boolean graphDatabaseStatus) {
+ this.appStatus = appStatus;
+ this.sqlStatus = sqlStatus;
+ this.graphDatabaseStatus = graphDatabaseStatus;
+ }
+
+ String generateConnectionStatus(boolean app, boolean sql, boolean graph){
+ return "{\"applicationStatus\":\""+(appStatus?"OK":"NOK")+"\",\"mariaDatabaseStatus\":\""+(sqlStatus?"OK":"NOK")+"\",\"graphDatabaseStatus\":\""+(graphDatabaseStatus?"OK":"NOK")+"\"}";
+ }
+
+ @Override
+ public void handle(com.sun.net.httpserver.HttpExchange exchange) {
+ try {
+ exchange.getResponseHeaders().set("Content-Type", "application/json");
+ exchange.sendResponseHeaders(200, 0);
+ exchange.getResponseBody().write(generateConnectionStatus(appStatus, sqlStatus, graphDatabaseStatus).getBytes());
+ exchange.close();
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+
+ }
+}
diff --git a/app/ux-analytics/monitor/src/test/java/org/phoebus/applications/uxanalytics/monitor/backend/database/MockClickHandler.java b/app/ux-analytics/monitor/src/test/java/org/phoebus/applications/uxanalytics/monitor/backend/database/MockClickHandler.java
new file mode 100644
index 0000000000..a5f6591cf9
--- /dev/null
+++ b/app/ux-analytics/monitor/src/test/java/org/phoebus/applications/uxanalytics/monitor/backend/database/MockClickHandler.java
@@ -0,0 +1,37 @@
+package org.phoebus.applications.uxanalytics.monitor.backend.database;
+
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.Future;
+import java.util.concurrent.FutureTask;
+
+public class MockClickHandler implements MockHandler{
+
+ boolean good = true;
+ String received_body = null;
+ FutureTask received = new FutureTask<>(() -> {
+ return true;
+ });
+
+ @Override
+ public void handle(com.sun.net.httpserver.HttpExchange exchange) {
+
+ try {
+ byte[] requestBody = exchange.getRequestBody().readAllBytes();
+ received_body = new String(requestBody, StandardCharsets.UTF_8);
+ received.run();
+ if(good){
+ exchange.getResponseHeaders().set("Content-Type", "application/json");
+ exchange.sendResponseHeaders(200, 0);
+ exchange.getResponseBody().write("{\"id\": 424242}".getBytes());
+ }
+ else{
+ exchange.getResponseHeaders().set("Content-Type", "application/json");
+ exchange.sendResponseHeaders(500, 0);
+ exchange.getResponseBody().write("{\"error\": \"Internal Server Error\"}".getBytes());
+ }
+ exchange.close();
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+}
diff --git a/app/ux-analytics/monitor/src/test/java/org/phoebus/applications/uxanalytics/monitor/backend/database/MockEndpoint.java b/app/ux-analytics/monitor/src/test/java/org/phoebus/applications/uxanalytics/monitor/backend/database/MockEndpoint.java
new file mode 100644
index 0000000000..cbea3a5154
--- /dev/null
+++ b/app/ux-analytics/monitor/src/test/java/org/phoebus/applications/uxanalytics/monitor/backend/database/MockEndpoint.java
@@ -0,0 +1,49 @@
+package org.phoebus.applications.uxanalytics.monitor.backend.database;
+
+import com.sun.net.httpserver.HttpServer;
+import com.sun.net.httpserver.HttpHandler;
+
+import java.net.InetSocketAddress;
+
+public class MockEndpoint {
+
+private HttpServer server;
+ private MockCheckConnectionHandler checkConnectionHandler = new MockCheckConnectionHandler(true, true, true);
+ private MockClickHandler clickHandler = new MockClickHandler();
+ private MockActionHandler actionHandler = new MockActionHandler();
+
+
+ public MockEndpoint() {
+
+ }
+
+ public void setCheckConnectionHandler(MockCheckConnectionHandler checkConnectionHandler) {
+ this.checkConnectionHandler = checkConnectionHandler;
+ }
+
+ public void setClickHandler(MockClickHandler clickHandler) {
+ this.clickHandler = clickHandler;
+ }
+
+ public void setActionHandler(MockActionHandler actionHandler) {
+ this.actionHandler = actionHandler;
+ }
+
+ public void start() {
+ try {
+ InetSocketAddress address = new InetSocketAddress(11111);
+ server = HttpServer.create();
+ server.createContext("/analytics/checkConnection", checkConnectionHandler);
+ server.createContext("/analytics/recordClick", clickHandler);
+ server.createContext("/analytics/recordNavigation",actionHandler);
+ server.bind(address, 0);
+ server.start();
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+
+ public void stop() {
+ server.stop(0);
+ }
+}
diff --git a/app/ux-analytics/monitor/src/test/java/org/phoebus/applications/uxanalytics/monitor/backend/database/MockHandler.java b/app/ux-analytics/monitor/src/test/java/org/phoebus/applications/uxanalytics/monitor/backend/database/MockHandler.java
new file mode 100644
index 0000000000..6e9a738a61
--- /dev/null
+++ b/app/ux-analytics/monitor/src/test/java/org/phoebus/applications/uxanalytics/monitor/backend/database/MockHandler.java
@@ -0,0 +1,4 @@
+package org.phoebus.applications.uxanalytics.monitor.backend.database;
+
+public interface MockHandler extends com.sun.net.httpserver.HttpHandler{
+}
diff --git a/app/ux-analytics/monitor/src/test/java/org/phoebus/applications/uxanalytics/monitor/backend/database/ServiceLayerConnectionTest.java b/app/ux-analytics/monitor/src/test/java/org/phoebus/applications/uxanalytics/monitor/backend/database/ServiceLayerConnectionTest.java
new file mode 100644
index 0000000000..289588b390
--- /dev/null
+++ b/app/ux-analytics/monitor/src/test/java/org/phoebus/applications/uxanalytics/monitor/backend/database/ServiceLayerConnectionTest.java
@@ -0,0 +1,507 @@
+package org.phoebus.applications.uxanalytics.monitor.backend.database;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import javafx.application.Platform;
+import javafx.scene.layout.BorderPane;
+import org.csstudio.display.actions.OpenDisplayAction;
+import org.csstudio.display.actions.WritePVAction;
+import org.csstudio.display.builder.model.Widget;
+
+import org.csstudio.display.builder.model.widgets.ActionButtonWidget;
+import org.csstudio.display.builder.representation.javafx.JFXRepresentation;
+import org.csstudio.display.builder.runtime.app.DisplayInfo;
+import org.csstudio.display.builder.runtime.app.DisplayRuntimeApplication;
+import org.csstudio.display.builder.runtime.app.DisplayRuntimeInstance;
+
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.Mock;
+import org.phoebus.applications.uxanalytics.monitor.representation.ActiveTab;
+import org.phoebus.applications.uxanalytics.monitor.util.FileUtils;
+import org.phoebus.applications.uxanalytics.monitor.util.ResourceOpenSources;
+import org.phoebus.framework.macros.Macros;
+import org.phoebus.ui.docking.DockItemWithInput;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.PrintStream;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.HashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.logging.Level;
+import java.util.logging.LogRecord;
+import java.util.logging.Logger;
+import java.util.logging.StreamHandler;
+import java.util.stream.Stream;
+
+import static org.csstudio.display.actions.OpenDisplayAction.OPEN_DISPLAY;
+import static org.mockito.Mockito.*;
+import static org.phoebus.applications.uxanalytics.monitor.backend.database.ServiceLayerConnection.*;
+
+public class ServiceLayerConnectionTest {
+
+ private DisplayRuntimeApplication app;
+ private DisplayRuntimeInstance instance;
+ private DisplayRuntimeInstance other_instance;
+ private final String display_path = ServiceLayerConnectionTest.class.getResource("/test.bob").getPath().replace("file:", "");;
+ private final String other_display_path = ServiceLayerConnectionTest.class.getResource("/test2.bob").getPath().replace("file:", "");
+
+ @BeforeAll
+ public static void initJFX(){
+ try {
+ Platform.startup(() -> {});
+ }
+ catch (IllegalStateException e) {
+ // JFX already started
+ }
+ }
+
+ public void setupUI(){
+ app = mock(DisplayRuntimeApplication.class);
+ instance = mock(DisplayRuntimeInstance.class);
+ other_instance = mock(DisplayRuntimeInstance.class);
+ String dummyPath = "";
+ try{
+ //this probably lives in a git repository if you're running this test
+ //ignore result, just make sure it can be done
+ Assertions.assertNotNull(FileUtils.getAnalyticsPathFor(display_path));
+ Assertions.assertNotNull(FileUtils.getAnalyticsPathFor(other_display_path));
+ }
+ catch(NullPointerException e){
+ //and if it isn't, temporarily create a dummy .git directory adjacent to the test.bob file
+ File gitdir = new File(".git");
+ gitdir.mkdir();
+ //It doesn't matter what the path is, as long as there's a .git directory somewhere up the hierarchy.
+ }
+
+ when(app.create()).thenReturn(instance);
+ when(app.create(URI.create(display_path))).thenReturn(instance);
+ when(app.create(URI.create(other_display_path))).thenReturn(other_instance);
+ when(instance.getRepresentation()).thenReturn(mock(JFXRepresentation.class));
+ when(instance.getAppDescriptor()).thenReturn(app);
+ when(instance.getDisplayInfo()).thenReturn(mock(DisplayInfo.class));
+ when(instance.getDisplayInfo().getName()).thenReturn("test");
+ when(instance.getDisplayInfo().getPath()).thenReturn(display_path);
+ when(other_instance.getRepresentation()).thenReturn(mock(JFXRepresentation.class));
+ when(other_instance.getAppDescriptor()).thenReturn(app);
+ when(other_instance.getDisplayInfo()).thenReturn(mock(DisplayInfo.class));
+ when(other_instance.getDisplayInfo().getName()).thenReturn("test2");
+ when(other_instance.getDisplayInfo().getPath()).thenReturn(other_display_path);
+ }
+
+
+ @Test
+ public void testMockServerWorks(){
+ MockCheckConnectionHandler handler = new MockCheckConnectionHandler(true, true, true);
+ MockEndpoint endpoint = new MockEndpoint();
+ endpoint.setCheckConnectionHandler(handler);
+ endpoint.start();
+ try {
+ HttpClient bareClient = HttpClient.newHttpClient();
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:11111/analytics/checkConnection"))
+ .GET()
+ .build();
+ HttpResponse response = bareClient.send(request, HttpResponse.BodyHandlers.ofString());
+ Assertions.assertEquals(handler.generateConnectionStatus(true, true, true), response.body());
+ }
+ catch (Exception e){
+ Assertions.fail("Failed to send bare request to localhost:11111/analytics/checkConnection");
+ }
+ endpoint.stop();
+ }
+
+ @Test
+ public void testConnectionAllOK(){
+ MockCheckConnectionHandler handler = new MockCheckConnectionHandler(true, true, true);
+ ServiceLayerConnection serviceLayerConnection = ServiceLayerConnection.getInstance();
+ MockEndpoint endpoint = new MockEndpoint();
+ endpoint.setCheckConnectionHandler(handler);
+ endpoint.start();
+ try {
+ Assertions.assertTrue(serviceLayerConnection.connect("localhost", 11111, null, null));
+ }
+ catch (Exception e){
+ Assertions.fail("Failed to get proper response from localhost:11111/analytics/checkConnection");
+ }
+ finally{
+ endpoint.stop();
+ }
+ }
+
+ static Stream generateConnectionTestCases() {
+ return Stream.of(
+ new boolean[]{false, false, false},
+ new boolean[]{false, false, true},
+ new boolean[]{false, true, false},
+ new boolean[]{false, true, true},
+ new boolean[]{true, false, false},
+ new boolean[]{true, false, true},
+ new boolean[]{true, true, false}
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource("generateConnectionTestCases")
+ void testConnectionBadCases(boolean[] args){
+ MockCheckConnectionHandler handler = new MockCheckConnectionHandler(args[0], args[1], args[2]);
+ ServiceLayerConnection serviceLayerConnection = ServiceLayerConnection.getInstance();
+ MockEndpoint endpoint = new MockEndpoint();
+ endpoint.setCheckConnectionHandler(handler);
+ endpoint.start();
+ try {
+ Assertions.assertFalse(serviceLayerConnection.connect("localhost", 11111, null, null));
+ }
+ catch (Exception e){
+ Assertions.fail("Failed to get proper response from localhost:11111/analytics/checkConnection");
+ }
+ finally{
+ endpoint.stop();
+ }
+ }
+
+
+ static Stream trueThenFalse() {
+ return Stream.of(
+ Boolean.TRUE,
+ Boolean.FALSE
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource("trueThenFalse")
+ public void testHandleClick(Boolean handlerStatus){
+ MockClickHandler handler = new MockClickHandler();
+ handler.good=handlerStatus;
+ MockEndpoint endpoint = new MockEndpoint();
+ endpoint.setClickHandler(handler);
+ endpoint.start();
+ setupUI();
+ BorderPane content = mock(BorderPane.class);
+ DockItemWithInput dockItem = new DockItemWithInput(instance, content,URI.create(display_path),null,null);
+ ActiveTab tab = new ActiveTab(dockItem, null);
+ String filename = FileUtils.getAnalyticsPathFor(display_path);
+ ServiceLayerConnection serviceLayerConnection = ServiceLayerConnection.getInstance();
+ serviceLayerConnection.connect("localhost", 11111, null, null);
+ serviceLayerConnection.handleClick(tab,42,24);
+ try{
+ handler.received.get(1000, TimeUnit.MILLISECONDS);
+ ObjectMapper mapper = new ObjectMapper();
+ HashMap map = mapper.readValue(handler.received_body, HashMap.class);
+ Assertions.assertEquals("42", map.get("x"));
+ Assertions.assertEquals("24", map.get("y"));
+ Assertions.assertEquals(filename, map.get("filename"));
+ }
+ catch(Exception failure){
+ if(handlerStatus)
+ Assertions.fail("Failed to get proper response from localhost:11111/analytics/click");
+ else
+ Assertions.assertEquals("{\"error\": \"Internal Server Error\"}", handler.received_body);
+ }
+ finally {
+ serviceLayerConnection.resetLogging();
+ endpoint.stop();
+ }
+
+ }
+
+ @Test
+ public void testWritePV(){
+ MockActionHandler handler = new MockActionHandler();
+ MockEndpoint endpoint = new MockEndpoint();
+ endpoint.setActionHandler(handler);
+ endpoint.start();
+ setupUI();
+ BorderPane content = mock(BorderPane.class);
+ DockItemWithInput dockItem = new DockItemWithInput(instance, content,URI.create(display_path),null,null);
+ ActiveTab tab = new ActiveTab(dockItem,null);
+ String filename = FileUtils.getAnalyticsPathFor(display_path);
+ ServiceLayerConnection serviceLayerConnection = ServiceLayerConnection.getInstance();
+ serviceLayerConnection.resetLogging();
+ serviceLayerConnection.connect("localhost", 11111, null, null);
+ String pvName = "testPV";
+ WritePVAction action = new WritePVAction(null, pvName, "123");
+ Widget widget = new ActionButtonWidget();
+ try {
+ widget.setPropertyValue("name", "testWidget");
+ serviceLayerConnection.handleAction(tab, widget, action);
+ handler.received.get(1000, TimeUnit.MILLISECONDS);
+ ObjectMapper mapper = new ObjectMapper();
+ HashMap map = mapper.readValue(handler.received_body, HashMap.class);
+ Assertions.assertEquals(filename, map.get("srcName"));
+ Assertions.assertEquals(TYPE_DISPLAY, map.get("srcType"));
+ Assertions.assertEquals(ACTION_WROTE, map.get("action"));
+ Assertions.assertEquals("testPV", map.get("dstName"));
+ Assertions.assertEquals(TYPE_PV, map.get("dstType"));
+ Assertions.assertEquals("testWidget", map.get("via"));
+ }
+ catch(Exception e){
+ Assertions.fail("Failed to set property value");
+ }
+ }
+
+
+ @Test
+ public void testHandleDisplayOpenViaActionButton(){
+ MockActionHandler handler = new MockActionHandler();
+ MockEndpoint endpoint = new MockEndpoint();
+ endpoint.setActionHandler(handler);
+ endpoint.start();
+ setupUI();
+ BorderPane content = mock(BorderPane.class);
+ DockItemWithInput dockItem = new DockItemWithInput(instance, content,URI.create(display_path),null,null);
+ ActiveTab tab = new ActiveTab(dockItem,null);
+ String filename = FileUtils.getAnalyticsPathFor(display_path);
+ String other_filename = FileUtils.getAnalyticsPathFor(other_display_path);
+ DockItemWithInput other_dockItem = new DockItemWithInput(other_instance, content,URI.create(other_display_path),null,null);
+ ActiveTab other_tab = new ActiveTab(other_dockItem,null);
+ ServiceLayerConnection serviceLayerConnection = ServiceLayerConnection.getInstance();
+ serviceLayerConnection.resetLogging();
+ serviceLayerConnection.connect("localhost", 11111, null, null);
+ OpenDisplayAction action = mock(OpenDisplayAction.class);
+ when(action.getType()).thenReturn(OPEN_DISPLAY);
+ DisplayInfo displayInfo = new DisplayInfo(display_path, null, new Macros(), true );
+ when(action.getFile()).thenReturn("test2.bob");
+ ActionButtonWidget widget = new ActionButtonWidget();
+ try {
+ widget.setPropertyValue("name", "testWidget");
+ serviceLayerConnection.handleAction(tab, widget, action);
+ handler.received.get(1000, TimeUnit.MILLISECONDS);
+ ObjectMapper mapper = new ObjectMapper();
+ HashMap map = mapper.readValue(handler.received_body, HashMap.class);
+ Assertions.assertEquals(other_filename, map.get("dstName"));
+ Assertions.assertEquals(filename, map.get("srcName"));
+ Assertions.assertEquals("testWidget", map.get("via"));
+ Assertions.assertEquals(ACTION_OPENED, map.get("action"));
+ Assertions.assertEquals(TYPE_DISPLAY, map.get("dstType"));
+ Assertions.assertEquals(TYPE_DISPLAY, map.get("srcType"));
+
+ }
+ catch (Exception e) {
+ Assertions.fail("Failed to properly record action");
+ }
+ finally{
+ endpoint.stop();
+ serviceLayerConnection.resetLogging();
+ };
+ }
+
+ static Stream otherSources() {
+ return Stream.of(
+ ResourceOpenSources.FILE_BROWSER,
+ ResourceOpenSources.TOP_RESOURCES,
+ ResourceOpenSources.RESTORED,
+ ResourceOpenSources.UNKNOWN
+ );
+
+ }
+
+ @ParameterizedTest
+ @MethodSource("otherSources")
+ public void testHandleDisplayOpenViaApplication(ResourceOpenSources source){
+ MockActionHandler handler = new MockActionHandler();
+ MockEndpoint endpoint = new MockEndpoint();
+ endpoint.setActionHandler(handler);
+ endpoint.start();
+ setupUI();
+
+ DisplayInfo info = new DisplayInfo(display_path, null, new Macros(), true );
+ String filename = FileUtils.getAnalyticsPathFor(display_path);
+ ServiceLayerConnection serviceLayerConnection = ServiceLayerConnection.getInstance();
+ serviceLayerConnection.resetLogging();
+ serviceLayerConnection.connect("localhost", 11111, null, null);
+ try {
+ serviceLayerConnection.handleDisplayOpen(info, null, source);
+ handler.received.get(1000, TimeUnit.MILLISECONDS);
+ ObjectMapper mapper = new ObjectMapper();
+ HashMap map = mapper.readValue(handler.received_body, HashMap.class);
+ Assertions.assertEquals(source.name().toLowerCase(), map.get("srcName"));
+ Assertions.assertEquals("origin_source", map.get("srcType"));
+ Assertions.assertEquals(filename, map.get("dstName"));
+ Assertions.assertEquals(TYPE_DISPLAY, map.get("dstType"));
+ Assertions.assertEquals(ACTION_OPENED, map.get("action"));
+ Assertions.assertNull(map.get("via"));
+ }
+ catch (Exception e) {
+ Assertions.fail("Failed to properly record action");
+ }
+ finally{
+ endpoint.stop();
+ serviceLayerConnection.resetLogging();
+ };
+ }
+
+ static Stream navigationSources() {
+ return Stream.of(
+ ResourceOpenSources.NAVIGATION_BUTTON,
+ ResourceOpenSources.RELOAD
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource("navigationSources")
+ public void testHandleDisplayOpenViaNavigation(ResourceOpenSources source){
+ MockActionHandler handler = new MockActionHandler();
+ MockEndpoint endpoint = new MockEndpoint();
+ endpoint.setActionHandler(handler);
+ endpoint.start();
+ setupUI();
+
+ DisplayInfo info = new DisplayInfo(display_path, null, new Macros(), true );
+ String filename = FileUtils.getAnalyticsPathFor(display_path);
+ DisplayInfo other_info = (source==ResourceOpenSources.RELOAD)?info:new DisplayInfo(other_display_path, null, new Macros(), true );
+ String other_filename = (source==ResourceOpenSources.RELOAD)?filename:FileUtils.getAnalyticsPathFor(other_display_path);
+ String expected_action = (source==ResourceOpenSources.RELOAD)?ACTION_RELOADED:ACTION_NAVIGATED;
+
+
+ ServiceLayerConnection serviceLayerConnection = ServiceLayerConnection.getInstance();
+ serviceLayerConnection.resetLogging();
+ serviceLayerConnection.connect("localhost", 11111, null, null);
+ try {
+ serviceLayerConnection.handleDisplayOpen(other_info, info, source);
+ handler.received.get(1000, TimeUnit.MILLISECONDS);
+ ObjectMapper mapper = new ObjectMapper();
+ HashMap map = mapper.readValue(handler.received_body, HashMap.class);
+ Assertions.assertEquals(filename, map.get("srcName"));
+ Assertions.assertEquals(TYPE_DISPLAY, map.get("srcType"));
+ Assertions.assertEquals(other_filename, map.get("dstName"));
+ Assertions.assertEquals(TYPE_DISPLAY, map.get("dstType"));
+ Assertions.assertEquals(expected_action, map.get("action"));
+ Assertions.assertNull(map.get("via"));
+ }
+ catch (Exception e) {
+ Assertions.fail("Failed to properly record action");
+ }
+ finally{
+ endpoint.stop();
+ serviceLayerConnection.resetLogging();
+ };
+ }
+
+ @Test
+ public void testLoggingInhibitedForConnectionCheck(){
+ ServiceLayerConnection connection = ServiceLayerConnection.getInstance();
+ connection.resetLogging();
+ ByteArrayOutputStream logCaptureStream = new ByteArrayOutputStream();
+ StreamHandler testHandler = new StreamHandler(new PrintStream(logCaptureStream), new java.util.logging.SimpleFormatter()) {
+ @Override
+ public synchronized void publish(LogRecord record) {
+ super.publish(record);
+ flush();
+ }
+ };
+ Logger logger = Logger.getLogger(ServiceLayerConnection.class.getName());
+ logger.addHandler(testHandler);
+ logger.setLevel(Level.INFO);
+ connection.connect("bogus", 12345, null, null);
+ Assertions.assertTrue(logCaptureStream.size()>0);
+ logCaptureStream.reset();
+ connection.connect("bogus", 12345, null, null);
+ Assertions.assertEquals(0, logCaptureStream.size());
+ logCaptureStream.reset();
+ logger.setLevel(Level.FINE);
+ testHandler.setLevel(Level.FINE);
+ connection.connect("bogus", 12345, null, null);
+ Assertions.assertTrue(logCaptureStream.size()>0);
+ connection.resetLogging();
+ logger.removeHandler(testHandler);
+ }
+
+ @Test
+ public void testLoggingInhibitedForClickHandle(){
+ ServiceLayerConnection connection = ServiceLayerConnection.getInstance();
+ connection.connect("bogus", 12345,null,null);
+ connection.resetLogging();
+ ByteArrayOutputStream logCaptureStream = new ByteArrayOutputStream();
+ StreamHandler testHandler = new StreamHandler(new PrintStream(logCaptureStream), new java.util.logging.SimpleFormatter()) {
+ @Override
+ public synchronized void publish(LogRecord record) {
+ super.publish(record);
+ flush();
+ }
+ };
+ Logger logger = Logger.getLogger(ServiceLayerConnection.class.getName());
+ logger.addHandler(testHandler);
+ logger.setLevel(Level.INFO);
+ connection.handleClick(null, 0, 0);
+ Assertions.assertTrue(logCaptureStream.size()>0);
+ logCaptureStream.reset();
+ connection.handleClick(null, 0, 0);
+ Assertions.assertEquals(0, logCaptureStream.size());
+ logCaptureStream.reset();
+ logger.setLevel(Level.FINE);
+ testHandler.setLevel(Level.FINE);
+ connection.handleClick(null, 0, 0);
+ Assertions.assertTrue(logCaptureStream.size()>0);
+ connection.resetLogging();
+ logger.removeHandler(testHandler);
+ }
+
+ @Test
+ public void testLoggingInhibitedForActionHandle(){
+ ServiceLayerConnection connection = ServiceLayerConnection.getInstance();
+ connection.connect("bogus", 12345,null,null);
+ connection.resetLogging();
+ ByteArrayOutputStream logCaptureStream = new ByteArrayOutputStream();
+ OpenDisplayAction info = mock(OpenDisplayAction.class);
+ when(info.getType()).thenReturn(OPEN_DISPLAY);
+ StreamHandler testHandler = new StreamHandler(new PrintStream(logCaptureStream), new java.util.logging.SimpleFormatter()) {
+ @Override
+ public synchronized void publish(LogRecord record) {
+ super.publish(record);
+ flush();
+ }
+ };
+ Logger logger = Logger.getLogger(ServiceLayerConnection.class.getName());
+ logger.addHandler(testHandler);
+ logger.setLevel(Level.INFO);
+ connection.handleAction(null, null, info);
+ Assertions.assertTrue(logCaptureStream.size()>0);
+ logCaptureStream.reset();
+ connection.handleAction(null, null, info);
+ Assertions.assertEquals(0, logCaptureStream.size());
+ logCaptureStream.reset();
+ logger.setLevel(Level.FINE);
+ testHandler.setLevel(Level.FINE);
+ connection.handleAction(null, null, info);
+ Assertions.assertTrue(logCaptureStream.size()>0);
+ connection.resetLogging();
+ logger.removeHandler(testHandler);
+ }
+
+ @Test
+ public void testLoggingInhibitedForOtherSourceDisplayOpened(){
+ ServiceLayerConnection connection = ServiceLayerConnection.getInstance();
+ connection.connect("bogus", 12345,null,null);
+ connection.resetLogging();
+ ByteArrayOutputStream logCaptureStream = new ByteArrayOutputStream();
+ ResourceOpenSources src = ResourceOpenSources.FILE_BROWSER;
+ StreamHandler testHandler = new StreamHandler(new PrintStream(logCaptureStream), new java.util.logging.SimpleFormatter()) {
+ @Override
+ public synchronized void publish(LogRecord record) {
+ super.publish(record);
+ flush();
+ }
+ };
+ Logger logger = Logger.getLogger(ServiceLayerConnection.class.getName());
+ logger.addHandler(testHandler);
+ logger.setLevel(Level.INFO);
+ connection.handleDisplayOpen(null, null, src);
+ Assertions.assertTrue(logCaptureStream.size()>0);
+ logCaptureStream.reset();
+ connection.handleDisplayOpen(null, null, src);
+ Assertions.assertEquals(0, logCaptureStream.size());
+ logCaptureStream.reset();
+ logger.setLevel(Level.FINE);
+ testHandler.setLevel(Level.FINE);
+ connection.handleDisplayOpen(null, null, src);
+ Assertions.assertTrue(logCaptureStream.size()>0);
+ connection.resetLogging();
+ logger.removeHandler(testHandler);
+ }
+
+}
diff --git a/app/ux-analytics/monitor/src/test/java/org/phoebus/applications/uxanalytics/monitor/representation/ActiveTabTest.java b/app/ux-analytics/monitor/src/test/java/org/phoebus/applications/uxanalytics/monitor/representation/ActiveTabTest.java
new file mode 100644
index 0000000000..8edcd97612
--- /dev/null
+++ b/app/ux-analytics/monitor/src/test/java/org/phoebus/applications/uxanalytics/monitor/representation/ActiveTabTest.java
@@ -0,0 +1,131 @@
+package org.phoebus.applications.uxanalytics.monitor.representation;
+
+import javafx.application.Platform;
+import javafx.event.Event;
+import javafx.event.EventHandler;
+import javafx.event.EventType;
+import javafx.scene.input.MouseEvent;
+import javafx.scene.layout.BorderPane;
+import javafx.util.Pair;
+import net.bytebuddy.dynamic.TypeResolutionStrategy;
+import org.csstudio.display.builder.model.DisplayModel;
+import org.csstudio.display.builder.model.persist.ModelReader;
+import org.csstudio.display.builder.representation.ToolkitListener;
+import org.csstudio.display.builder.runtime.app.DisplayInfo;
+import org.csstudio.display.builder.runtime.app.DisplayRuntimeApplication;
+import org.csstudio.display.builder.runtime.app.DisplayRuntimeInstance;
+import org.epics.vtype.Display;
+import org.junit.jupiter.api.*;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito.*;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+import org.phoebus.applications.uxanalytics.monitor.UXAMouseMonitor;
+import org.phoebus.applications.uxanalytics.monitor.UXAToolkitListener;
+import org.phoebus.applications.viewer3d.ResourceUtil;
+import org.phoebus.framework.macros.Macros;
+import org.phoebus.ui.docking.DockItemWithInput;
+import org.phoebus.ui.docking.DockPane;
+import org.phoebus.ui.docking.DockStage;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+public class ActiveTabTest {
+ private DisplayRuntimeApplication app;
+ private DisplayRuntimeInstance instance;
+ private DockItemWithInput tab;
+ private static final String WINDOW_ID = "testWindowID";
+
+ @BeforeAll
+ public static void startJFX()
+ {
+ try {
+ Platform.startup(() -> {});
+ }
+ catch (IllegalStateException e) {
+ // JFX already started
+ }
+ }
+
+ @BeforeEach
+ public void setUp(){
+ app = mock(DisplayRuntimeApplication.class);
+ when(app.getDisplayName()).thenReturn("test");
+ instance = mock(DisplayRuntimeInstance.class);
+ when(instance.getAppDescriptor()).thenReturn(app);
+ BorderPane pane = spy(new BorderPane());
+ List listeners = new ArrayList<>();
+ doAnswer(invocation->{
+ UXAToolkitListener listener = invocation.getArgument(0);
+ listeners.add(listener);
+ return null;
+ }).when(instance).addListener(any(UXAToolkitListener.class));
+
+ doAnswer(invocation->{
+ listeners.remove((UXAToolkitListener) invocation.getArgument(0));
+ return null;
+ }).when(instance).removeListener(any(UXAToolkitListener.class));
+
+
+ when(app.getName()).thenReturn("test");
+ when(app.getDisplayName()).thenReturn("test");
+ when(app.create()).thenReturn(instance);
+ try{
+ String filename = ActiveTabTest.class.getResource("/test.bob").getPath();
+ InputStream test_display = ResourceUtil.openResource(filename);
+ ModelReader rdr = new ModelReader(test_display, filename);
+ DisplayModel mdl = rdr.readModel();
+ DisplayInfo info = new DisplayInfo("test", filename, new Macros(), false);
+ tab = new DockItemWithInput(instance, pane, URI.create(filename),null,null);
+ when(instance.getDockItem()).thenReturn(tab);
+ }
+ catch(Exception e){
+ e.printStackTrace();
+ }
+
+ }
+
+ @Test
+ public void testConstructorAddsListenersExactlyOnce(){
+ ActiveWindowsService svc = mock(ActiveWindowsService.class);
+ when(svc.isActive()).thenReturn(true);
+ ActiveTab activeTab = spy(new ActiveTab(tab,svc,WINDOW_ID));
+ Assertions.assertNotNull(activeTab.getMouseMonitor());
+ Assertions.assertNotNull(activeTab.getParentTab().getContent());
+ activeTab.addListeners();//should do nothing, already done in constructor, for total of one listener-add
+ verify(instance, times(1)).addListener(any(UXAToolkitListener.class));
+ verify(activeTab.getParentTab().getContent(), times(1)).addEventFilter(eq(MouseEvent.MOUSE_CLICKED), any(UXAMouseMonitor.class));
+ }
+
+ @Test
+ public void testConstructorPopulatesFields(){
+ ActiveWindowsService svc = mock(ActiveWindowsService.class);
+ when(svc.isActive()).thenReturn(true);
+ ActiveTab activeTab = new ActiveTab(tab, svc, WINDOW_ID);
+ Assertions.assertNotNull(activeTab.getMouseMonitor());
+ Assertions.assertNotNull(activeTab.getParentTab().getContent());
+ Assertions.assertTrue(activeTab.isListening());
+ }
+
+ @Test
+ public void testDetachListeners(){
+ ActiveWindowsService svc = mock(ActiveWindowsService.class);
+ when(svc.isActive()).thenReturn(true);
+ ActiveTab activeTab = spy(new ActiveTab(tab,svc, WINDOW_ID));
+ verify(instance, times(1)).addListener(any(UXAToolkitListener.class));
+ verify(activeTab.getParentTab().getContent(), times(1)).addEventFilter(eq(MouseEvent.MOUSE_CLICKED), any(UXAMouseMonitor.class));
+ activeTab.detachListeners();
+ verify(instance, times(1)).removeListener(any(UXAToolkitListener.class));
+ verify(activeTab.getParentTab().getContent(), times(1)).removeEventFilter(eq(MouseEvent.MOUSE_CLICKED), any(UXAMouseMonitor.class));
+ Assertions.assertFalse(activeTab.isListening());
+ }
+
+
+}
diff --git a/app/ux-analytics/monitor/src/test/java/org/phoebus/applications/uxanalytics/monitor/representation/ActiveWindowsServiceTest.java b/app/ux-analytics/monitor/src/test/java/org/phoebus/applications/uxanalytics/monitor/representation/ActiveWindowsServiceTest.java
new file mode 100644
index 0000000000..d449e3d722
--- /dev/null
+++ b/app/ux-analytics/monitor/src/test/java/org/phoebus/applications/uxanalytics/monitor/representation/ActiveWindowsServiceTest.java
@@ -0,0 +1,179 @@
+package org.phoebus.applications.uxanalytics.monitor.representation;
+
+import javafx.application.Platform;
+import javafx.scene.Scene;
+import javafx.scene.layout.BorderPane;
+import javafx.stage.Stage;
+import org.csstudio.display.builder.runtime.app.DisplayInfo;
+import org.csstudio.display.builder.runtime.app.DisplayRuntimeApplication;
+import org.csstudio.display.builder.runtime.app.DisplayRuntimeInstance;
+import org.csstudio.display.builder.runtime.internal.DisplayRuntime;
+import org.epics.vtype.Display;
+import org.junit.jupiter.api.*;
+import org.phoebus.applications.uxanalytics.monitor.UXAMonitor;
+import org.phoebus.applications.uxanalytics.monitor.backend.database.ServiceLayerConnection;
+import org.phoebus.ui.docking.DockItemWithInput;
+import org.phoebus.ui.docking.DockPane;
+import org.phoebus.ui.docking.DockStage;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.FutureTask;
+
+import static org.mockito.Mockito.mock;
+
+public class ActiveWindowsServiceTest {
+
+ static ActiveWindowsService activeWindowsService;
+ static Stage stage;
+ static DockPane pane;
+ static DisplayRuntimeApplication app;
+ static DisplayRuntimeInstance displayRuntimeInstance;
+ static FutureTask loadedTask = new FutureTask<>(() -> true);
+
+
+ @BeforeAll
+ static void setUp() {
+ try {
+ Platform.startup(() -> {});
+ }
+ catch (IllegalStateException e) {
+ // JFX already started
+ }
+ Platform.runLater(()->{
+ app = new DisplayRuntimeApplication();
+ stage = new Stage();
+ Scene scene = new Scene(new BorderPane(), 800, 600);
+ stage.setScene(scene);
+ pane = DockStage.configureStage(stage);
+ DockStage.setActiveDockStage(stage);
+ try {
+ URI testResource = ActiveWindowsService.class.getResource("/test.bob").toURI();
+ displayRuntimeInstance = app.create(testResource);
+ activeWindowsService = ActiveWindowsService.getInstance();
+ loadedTask.run();
+ } catch (URISyntaxException e) {
+ throw new RuntimeException(e);
+ }
+
+ });
+ }
+
+ @AfterEach
+ void reset() {
+ if(activeWindowsService != null)
+ activeWindowsService.clear();
+ }
+
+ @Test
+ synchronized void testWindowIsPresentInActiveWindowsServiceWhenActive(){
+ try {
+ loadedTask.get();
+ DockStage.deferUntilAllPanesOfStageHaveScenes(
+ stage,
+ () -> {
+ activeWindowsService.stop();
+ activeWindowsService.start();
+ String windowID = (String) stage.getProperties().get(DockStage.KEY_ID);
+ ConcurrentHashMap activeWindowsAndTabs = activeWindowsService.getActiveWindowsAndTabs();
+ DockItemWithInput dockItem = (DockItemWithInput)displayRuntimeInstance.getDockItem();
+ Assertions.assertTrue(activeWindowsAndTabs.containsKey(windowID));
+ Assertions.assertEquals(1, activeWindowsAndTabs.size());
+ Assertions.assertTrue(activeWindowsService.getTabsForWindow(windowID).contains(dockItem));
+ });
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Test
+ synchronized void testWindowIsNotPresentInActiveWindowsServiceWhenInactive(){
+ try {
+ loadedTask.get();
+ DockStage.deferUntilAllPanesOfStageHaveScenes(
+ stage,
+ () -> {
+ activeWindowsService.stop();
+ activeWindowsService.start();
+ activeWindowsService.stop();
+ Assertions.assertFalse(activeWindowsService.isActive());
+ String windowID = (String) stage.getProperties().get(DockStage.KEY_ID);
+ ConcurrentHashMap activeWindowsAndTabs = activeWindowsService.getActiveWindowsAndTabs();
+ Assertions.assertFalse(activeWindowsAndTabs.containsKey(windowID));
+ });
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Test
+ synchronized void testStartIsIdempotent(){
+ try {
+ loadedTask.get();
+ DockStage.deferUntilAllPanesOfStageHaveScenes(
+ stage,
+ () -> {
+ activeWindowsService.stop();
+ activeWindowsService.start();
+ activeWindowsService.start();
+ activeWindowsService.start();
+ String windowID = (String) stage.getProperties().get(DockStage.KEY_ID);
+ ConcurrentHashMap activeWindowsAndTabs = activeWindowsService.getActiveWindowsAndTabs();
+ Assertions.assertTrue(activeWindowsAndTabs.containsKey(windowID));
+ Assertions.assertEquals(1, activeWindowsAndTabs.size());
+ });
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Test
+ synchronized void testAddingATabToAnActiveWindow() {
+ try {
+ loadedTask.get();
+ DockStage.deferUntilAllPanesOfStageHaveScenes(
+ stage,
+ () -> {
+ activeWindowsService.stop();
+ activeWindowsService.start();
+ String windowID = (String) stage.getProperties().get(DockStage.KEY_ID);
+ ConcurrentHashMap activeWindowsAndTabs = activeWindowsService.getActiveWindowsAndTabs();
+ assert(activeWindowsAndTabs.containsKey(windowID));
+ try {
+ URI uri = ActiveWindowsService.class.getResource("/thirdThing.bob").toURI();
+ DisplayInfo info = DisplayInfo.forURI(uri);
+ CompletableFuture future = new CompletableFuture<>();
+ Platform.runLater(() -> {
+ DisplayRuntimeInstance otherInstance = app.create(uri);
+ future.complete(otherInstance);
+ });
+ DisplayRuntimeInstance otherInstance = future.get();
+ Assertions.assertEquals(2, activeWindowsAndTabs.get(windowID).getActiveTabs().size());
+ CompletableFuture closeFuture = new CompletableFuture<>();
+ Thread.sleep(2000);
+ otherInstance.getDockItem().prepareToClose();
+ Platform.runLater(()-> {
+ try {
+ otherInstance.getDockItem().close();
+ closeFuture.complete(null);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ });
+ closeFuture.get();
+ Assertions.assertEquals(1, activeWindowsAndTabs.get(windowID).getActiveTabs().size());
+
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ });
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+
+}
diff --git a/app/ux-analytics/monitor/src/test/java/org/phoebus/applications/uxanalytics/monitor/util/FileUtilsTest.java b/app/ux-analytics/monitor/src/test/java/org/phoebus/applications/uxanalytics/monitor/util/FileUtilsTest.java
new file mode 100644
index 0000000000..b479a0af01
--- /dev/null
+++ b/app/ux-analytics/monitor/src/test/java/org/phoebus/applications/uxanalytics/monitor/util/FileUtilsTest.java
@@ -0,0 +1,107 @@
+package org.phoebus.applications.uxanalytics.monitor.util;
+
+import org.junit.Rule;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.phoebus.applications.uxanalytics.monitor.util.FileUtils;
+import org.phoebus.framework.preferences.PhoebusPreferenceService;
+
+import java.io.File;
+import java.lang.reflect.Field;
+import java.util.Collections;
+import java.util.Map;
+
+import static org.phoebus.applications.uxanalytics.monitor.util.FileUtils.WEB_CONTENT_ROOT_ENV_VAR;
+
+public class FileUtilsTest {
+
+ private static String oldWebContentRootSetting = null;
+ private static final String resourcesRoot = FileUtilsTest.class.getResource("/test.bob").getFile();
+ static File gitDir = null;
+
+ @BeforeAll
+ public static void mockWebContentRootSetting(){
+ oldWebContentRootSetting = PhoebusPreferenceService.userNodeForClass(FileUtils.class)
+ .get(FileUtils.WEB_CONTENT_ROOT_SETTING_NAME, null);
+ PhoebusPreferenceService.userNodeForClass(FileUtils.class).put(FileUtils.WEB_CONTENT_ROOT_SETTING_NAME, "http://myserver/bobfiles/");
+ //make a directory called .git in the resources directory
+ File resourcesDir = new File(resourcesRoot).getParentFile();
+ gitDir = new File(resourcesDir, ".git");
+ if(!gitDir.exists()){
+ gitDir.mkdir();
+ }
+ }
+
+ @AfterAll
+ public static void restoreWebContentRootSetting() {
+ if (oldWebContentRootSetting != null) {
+ PhoebusPreferenceService.userNodeForClass(FileUtils.class).put(FileUtils.WEB_CONTENT_ROOT_SETTING_NAME, oldWebContentRootSetting);
+ }
+ if(gitDir.exists()){
+ gitDir.delete();
+ }
+ }
+
+ @Test
+ public void testSourceRootOfURL(){
+ String url = "http://myserver/bobfiles/something/test.bob";
+ String expected = "myserver/bobfiles/";
+ String result = FileUtils.findSourceRootOf(url);
+ Assertions.assertEquals(expected, result);
+ }
+
+ @Test
+ public void testGetPathWithoutSourceRootWeb(){
+ String url = "http://myserver/bobfiles/something/test.bob";
+ String expected = "something/test.bob";
+ String result = FileUtils.getPathWithoutSourceRoot(url);
+ Assertions.assertEquals(expected, result);
+ String badUrl = "http://somethingelse/something/test.bob";
+ String badResult = FileUtils.getPathWithoutSourceRoot(badUrl);
+ Assertions.assertNull(badResult);
+ }
+
+ @Test
+ public void testSourceRootLocalFileSystem(){
+ String path = FileUtilsTest.class.getResource("/test.bob").getPath();
+ // .git/../ is the source root, go up one more level to preserve source root when recording
+ String expected = gitDir.getParentFile().getParentFile().getPath();
+ String result = FileUtils.findSourceRootOf(path);
+ Assertions.assertEquals(expected, result);
+ }
+
+ @Test
+ public void testGetPathWithoutSourceRootLocalFileSystem(){
+ String path = FileUtilsTest.class.getResource("/test.bob").getPath();
+ // .git/../ is the source root, go up one more level to preserve source root when recording
+ String expected = "test-classes/test.bob";
+ String result = FileUtils.getPathWithoutSourceRoot(path);
+ Assertions.assertEquals(expected, result);
+ }
+
+ @Test
+ public void testSHA256Calculation(){
+ //calculated using GNU coreutils implementation of sha256sum
+ final String expected = "4ab717e75c8a1285d87a651939cb1aa5ab2a5d2e3c7aeeec2e3bb392d3825992";
+ String path = FileUtilsTest.class.getResource("/test.bob").getPath();
+ String got = FileUtils.getFileSHA256(path);
+ Assertions.assertEquals(expected, got);
+ }
+
+ @Test
+ public void testAnalyticsPathResolution(){
+ String path = FileUtilsTest.class.getResource("/test.bob").getPath();
+ String expected = "test-classes/test.bob_4ab717e7";
+ String got = FileUtils.getAnalyticsPathFor(path);
+ Assertions.assertEquals(expected, got);
+ }
+
+ @Test
+ public void testPathOffOfSourceTreeReturnsNull(){
+ String bogusPath = "/something/else/idk/test.bob";
+ Assertions.assertNull(FileUtils.getAnalyticsPathFor(bogusPath));
+ }
+}
diff --git a/app/ux-analytics/monitor/src/test/resources/.hg/dummy.txt b/app/ux-analytics/monitor/src/test/resources/.hg/dummy.txt
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/app/ux-analytics/monitor/src/test/resources/SomeOtherDisplay.bob b/app/ux-analytics/monitor/src/test/resources/SomeOtherDisplay.bob
new file mode 100644
index 0000000000..5bf3a1eb38
--- /dev/null
+++ b/app/ux-analytics/monitor/src/test/resources/SomeOtherDisplay.bob
@@ -0,0 +1,57 @@
+
+
+ Display
+
+ Label
+ TITLE
+ My Display
+ 0
+ 0
+ 550
+ 31
+
+
+
+
+
+
+
+
+ true
+
+
+ Label_1
+ Some Value:
+ 50
+
+
+ Text Update
+ sim://sine
+ 100
+ 50
+
+
+ Label_2
+ COMMENT
+ Some comment.
+ 220
+ 50
+ 140
+
+
+
+
+
+
+
+
+ true
+ true
+
+
+ Text Entry
+ loc://butt
+ 40
+ 100
+
+
diff --git a/app/ux-analytics/monitor/src/test/resources/someEmbeddedThingy.bob b/app/ux-analytics/monitor/src/test/resources/someEmbeddedThingy.bob
new file mode 100644
index 0000000000..373ebaa445
--- /dev/null
+++ b/app/ux-analytics/monitor/src/test/resources/someEmbeddedThingy.bob
@@ -0,0 +1,51 @@
+
+
+ Display
+
+ Label
+ TITLE
+ My Display
+ 0
+ 0
+ 550
+ 31
+
+
+
+
+
+
+
+
+ true
+
+
+ Label_1
+ Some Value:
+ 50
+
+
+ Text Update
+ sim://sine
+ 100
+ 50
+
+
+ Label_2
+ COMMENT
+ Some comment.
+ 220
+ 50
+ 140
+
+
+
+
+
+
+
+
+ true
+ true
+
+
diff --git a/app/ux-analytics/monitor/src/test/resources/test.bob b/app/ux-analytics/monitor/src/test/resources/test.bob
new file mode 100644
index 0000000000..e29bf61b8f
--- /dev/null
+++ b/app/ux-analytics/monitor/src/test/resources/test.bob
@@ -0,0 +1,147 @@
+
+
+
+ Display
+
+
+ SomeOtherDisplay.bob
+ tab
+ New Tab
+
+
+
+ Label
+ TITLE
+ My Display
+ 0
+ 0
+ 550
+ 31
+
+
+
+
+
+
+
+
+ true
+
+
+ Label_1
+ Some Value:
+ 50
+
+
+ Text Update
+ sim://sine
+ 100
+ 50
+
+
+ Label_2
+ COMMENT
+ Some comment.
+ 220
+ 50
+ 140
+
+
+
+
+
+
+
+
+ true
+ true
+
+
+ Action Button
+
+
+ SomeOtherDisplay.bob
+ tab
+ New Tab
+
+
+ 100
+ 220
+ $(actions)
+
+
+ Boolean Button
+ 80
+ 150
+ 60
+
+
+ Action Button_1
+
+
+ SomeOtherDisplay.bob
+ window
+ New Window
+
+
+ 100
+ 250
+ $(actions)
+
+
+ Embedded Display
+ someEmbeddedThingy.bob
+ 290
+ 150
+ 800
+ 600
+ 2
+
+
+ Text Entry
+ loc://foo
+ 30
+ 90
+ 160
+ 30
+
+
+ Action Button_2
+
+
+ thirdThing.bob
+ tab
+ Third Thing
+
+
+ 100
+ 280
+ $(actions)
+
+
+ Action Button_3
+
+
+ thirdThing.bob
+ replace
+ Replace
+
+
+ 100
+ 310
+ $(actions)
+
+
+ Action Button_4
+
+
+ asdfasdfasdfasdfasfd.bob
+ tab
+ Nonexistent
+
+
+ 100
+ 340
+ $(actions)
+
+
diff --git a/app/ux-analytics/monitor/src/test/resources/test2.bob b/app/ux-analytics/monitor/src/test/resources/test2.bob
new file mode 100644
index 0000000000..af42c44cd4
--- /dev/null
+++ b/app/ux-analytics/monitor/src/test/resources/test2.bob
@@ -0,0 +1,148 @@
+
+
+
+
+ Display
+
+
+ SomeOtherDisplay.bob
+ tab
+ New Tab
+
+
+
+ Label
+ TITLE
+ My Display
+ 0
+ 0
+ 550
+ 31
+
+
+
+
+
+
+
+
+ true
+
+
+ Label_1
+ Some Value:
+ 50
+
+
+ Text Update
+ sim://sine
+ 100
+ 50
+
+
+ Label_2
+ COMMENT
+ Some comment.
+ 220
+ 50
+ 140
+
+
+
+
+
+
+
+
+ true
+ true
+
+
+ Action Button
+
+
+ SomeOtherDisplay.bob
+ tab
+ New Tab
+
+
+ 100
+ 220
+ $(actions)
+
+
+ Boolean Button
+ 80
+ 150
+ 60
+
+
+ Action Button_1
+
+
+ SomeOtherDisplay.bob
+ window
+ New Window
+
+
+ 100
+ 250
+ $(actions)
+
+
+ Embedded Display
+ someEmbeddedThingy.bob
+ 290
+ 150
+ 800
+ 600
+ 2
+
+
+ Text Entry
+ loc://foo
+ 30
+ 90
+ 160
+ 30
+
+
+ Action Button_2
+
+
+ thirdThing.bob
+ tab
+ Third Thing
+
+
+ 100
+ 280
+ $(actions)
+
+
+ Action Button_3
+
+
+ thirdThing.bob
+ replace
+ Replace
+
+
+ 100
+ 310
+ $(actions)
+
+
+ Action Button_4
+
+
+ asdfasdfasdfasdfasfd.bob
+ tab
+ Nonexistent
+
+
+ 100
+ 340
+ $(actions)
+
+
diff --git a/app/ux-analytics/monitor/src/test/resources/thirdThing.bob b/app/ux-analytics/monitor/src/test/resources/thirdThing.bob
new file mode 100644
index 0000000000..b78e024c4c
--- /dev/null
+++ b/app/ux-analytics/monitor/src/test/resources/thirdThing.bob
@@ -0,0 +1,52 @@
+
+
+
+ Display
+
+ Label
+ TITLE
+ A third thing
+ 0
+ 0
+ 550
+ 31
+
+
+
+
+
+
+
+
+ true
+
+
+ Label_1
+ Some Value:
+ 50
+
+
+ Text Update
+ sim://sine
+ 100
+ 50
+
+
+ Label_2
+ COMMENT
+ Some comment.
+ 220
+ 50
+ 140
+
+
+
+
+
+
+
+
+ true
+ true
+
+
diff --git a/app/ux-analytics/pom.xml b/app/ux-analytics/pom.xml
new file mode 100644
index 0000000000..e8a43b3bfc
--- /dev/null
+++ b/app/ux-analytics/pom.xml
@@ -0,0 +1,26 @@
+
+
+ 4.0.0
+ 4.7.4-SNAPSHOT
+ app-ux-analytics
+ pom
+
+ org.phoebus
+ app
+ 4.7.4-SNAPSHOT
+
+
+
+ ui
+ monitor
+
+
+
+ 22
+ 22
+ UTF-8
+
+
+
\ No newline at end of file
diff --git a/app/ux-analytics/ui/.classpath b/app/ux-analytics/ui/.classpath
new file mode 100644
index 0000000000..ca125d983c
--- /dev/null
+++ b/app/ux-analytics/ui/.classpath
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/ux-analytics/ui/build.xml b/app/ux-analytics/ui/build.xml
new file mode 100644
index 0000000000..fad90485ad
--- /dev/null
+++ b/app/ux-analytics/ui/build.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/ux-analytics/ui/pom.xml b/app/ux-analytics/ui/pom.xml
new file mode 100644
index 0000000000..7edb8f6294
--- /dev/null
+++ b/app/ux-analytics/ui/pom.xml
@@ -0,0 +1,94 @@
+
+
+
+
+ org.phoebus
+ app-ux-analytics
+ 4.7.4-SNAPSHOT
+
+ 4.0.0
+ app-analytics-ui
+
+
+ 22
+ 22
+ UTF-8
+
+
+
+ org.openjfx
+ javafx-graphics
+ 19
+ compile
+
+
+ org.openjfx
+ javafx-fxml
+ 19
+ compile
+
+
+ org.phoebus
+ core-framework
+ 4.7.4-SNAPSHOT
+ compile
+
+
+ org.neo4j.driver
+ neo4j-java-driver
+ 5.19.0
+
+
+ org.slf4j
+ slf4j-jdk14
+
+
+
+
+ org.mongodb
+ mongodb-driver-sync
+ 5.1.0
+
+
+
+
+ org.apache.httpcomponents
+ httpclient
+ 4.5.14
+
+
+
+ org.phoebus
+ core-ui
+ 4.7.4-SNAPSHOT
+ compile
+
+
+ org.phoebus
+ app-analytics-monitor
+ 4.7.4-SNAPSHOT
+ compile
+
+
+ org.phoebus
+ core-logbook
+ 4.7.4-SNAPSHOT
+
+
+
+
+
+
+ src/main/resources
+
+
+ src/main/java
+
+ **/*.fxml
+
+
+
+
+
\ No newline at end of file
diff --git a/app/ux-analytics/ui/src/main/java/org/phoebus/applications/uxanalytics/ui/ConsentPersistence.java b/app/ux-analytics/ui/src/main/java/org/phoebus/applications/uxanalytics/ui/ConsentPersistence.java
new file mode 100644
index 0000000000..7abf71b456
--- /dev/null
+++ b/app/ux-analytics/ui/src/main/java/org/phoebus/applications/uxanalytics/ui/ConsentPersistence.java
@@ -0,0 +1,63 @@
+package org.phoebus.applications.uxanalytics.ui;
+
+import org.phoebus.framework.workbench.Locations;
+
+import java.io.*;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+class ConsentPersistence {
+
+ static final String CONSENT_FILE = Locations.user().getAbsolutePath()+".phoebus-analytics-consent";
+
+ static boolean consentIsPersistent(){
+ return new File(CONSENT_FILE).exists();
+ }
+
+ static boolean getConsent(){
+ File file = new File(CONSENT_FILE);
+ if (!file.exists()){
+ return false;
+ }
+ try {
+ FileInputStream fis = new FileInputStream(file);
+ byte[] data = new byte[(int) file.length()];
+ fis.read(data);
+ fis.close();
+ return new String(data).equals("1");
+ }
+ catch (IOException e){
+ Logger.getLogger(ConsentPersistence.class.getPackageName()).log(Level.WARNING, "Error reading consent:", e);
+ return false;
+ }
+ }
+
+ private static void writeConsent(String c){
+ try {
+ FileWriter fileWriter = new FileWriter(CONSENT_FILE);
+ fileWriter.write(c);
+ fileWriter.close();
+ } catch (IOException e) {
+ Logger.getLogger(ConsentPersistence.class.getPackageName()).log(Level.WARNING, "Error storing consent:", e);
+ }
+ }
+
+ static void storeConsent(){
+ if (!getConsent()){
+ writeConsent("1");
+ }
+ }
+
+ static void revokeConsent(){
+ if(getConsent()){
+ writeConsent("0");
+ }
+ }
+
+ static void deleteConsent(){
+ File file = new File(CONSENT_FILE);
+ if (file.exists()){
+ file.delete();
+ }
+ }
+}
diff --git a/app/ux-analytics/ui/src/main/java/org/phoebus/applications/uxanalytics/ui/CreateUXAMenuEntry.java b/app/ux-analytics/ui/src/main/java/org/phoebus/applications/uxanalytics/ui/CreateUXAMenuEntry.java
new file mode 100644
index 0000000000..83c11f11cf
--- /dev/null
+++ b/app/ux-analytics/ui/src/main/java/org/phoebus/applications/uxanalytics/ui/CreateUXAMenuEntry.java
@@ -0,0 +1,22 @@
+package org.phoebus.applications.uxanalytics.ui;
+
+import org.phoebus.framework.workbench.ApplicationService;
+import org.phoebus.ui.spi.MenuEntry;
+
+public class CreateUXAMenuEntry implements MenuEntry {
+
+ @Override
+ public String getName() { return UXAnalyticsMain.DISPLAY_NAME;}
+
+ @Override
+ public Void call() throws Exception {
+ ApplicationService.createInstance(UXAnalyticsMain.NAME);
+ return null;
+ }
+
+ @Override
+ public String getMenuPath() {
+ return "Utility";
+ }
+
+}
\ No newline at end of file
diff --git a/app/ux-analytics/ui/src/main/java/org/phoebus/applications/uxanalytics/ui/UXAController.java b/app/ux-analytics/ui/src/main/java/org/phoebus/applications/uxanalytics/ui/UXAController.java
new file mode 100644
index 0000000000..4e4ecba5db
--- /dev/null
+++ b/app/ux-analytics/ui/src/main/java/org/phoebus/applications/uxanalytics/ui/UXAController.java
@@ -0,0 +1,70 @@
+package org.phoebus.applications.uxanalytics.ui;
+
+import javafx.event.ActionEvent;
+import javafx.event.Event;
+import javafx.fxml.FXML;
+import javafx.scene.control.*;
+
+import org.phoebus.applications.uxanalytics.monitor.backend.database.BackendConnection;
+import org.phoebus.applications.uxanalytics.monitor.UXAMonitor;
+import org.phoebus.framework.preferences.PhoebusPreferenceService;
+
+import java.util.logging.Level;
+
+import static org.phoebus.applications.uxanalytics.ui.UXAnalyticsMain.logger;
+
+public class UXAController {
+
+ UXAMonitor observer;
+
+ public void setObserver(UXAMonitor observer) {
+ this.observer = observer;
+ }
+
+ BackendConnection connectionLogic;
+
+ @FXML
+ Button btnAgree;
+ @FXML
+ Button buttonDisagree;
+ @FXML
+ CheckBox chkRemember;
+
+ String host;
+ String protocol;
+
+
+ public UXAController(BackendConnection connectionLogic) {
+ this.connectionLogic = connectionLogic;
+ }
+
+ @FXML
+ public void initialize() {
+ chkRemember.setSelected(ConsentPersistence.getConsent());
+ }
+
+ @FXML
+ public void onAgree(ActionEvent event) {
+ observer.enableTracking();
+ if (chkRemember.isSelected()) {
+ ConsentPersistence.storeConsent();
+ }
+ else{
+ ConsentPersistence.deleteConsent();
+ }
+ ((Button) event.getSource()).getScene().getWindow().hide();
+ }
+
+ @FXML
+ public void onDisagree(ActionEvent event) {
+ observer.disableTracking();
+ if (chkRemember.isSelected()){
+ ConsentPersistence.revokeConsent();
+ }
+ else{
+ ConsentPersistence.deleteConsent();
+ }
+ ((Button) event.getSource()).getScene().getWindow().hide();
+ }
+
+}
diff --git a/app/ux-analytics/ui/src/main/java/org/phoebus/applications/uxanalytics/ui/UXAProperties.java b/app/ux-analytics/ui/src/main/java/org/phoebus/applications/uxanalytics/ui/UXAProperties.java
new file mode 100644
index 0000000000..3b51f733b3
--- /dev/null
+++ b/app/ux-analytics/ui/src/main/java/org/phoebus/applications/uxanalytics/ui/UXAProperties.java
@@ -0,0 +1,4 @@
+package org.phoebus.applications.uxanalytics.ui;
+
+public class UXAProperties {
+}
diff --git a/app/ux-analytics/ui/src/main/java/org/phoebus/applications/uxanalytics/ui/UXAnalyticsMain.java b/app/ux-analytics/ui/src/main/java/org/phoebus/applications/uxanalytics/ui/UXAnalyticsMain.java
new file mode 100644
index 0000000000..1891329372
--- /dev/null
+++ b/app/ux-analytics/ui/src/main/java/org/phoebus/applications/uxanalytics/ui/UXAnalyticsMain.java
@@ -0,0 +1,86 @@
+package org.phoebus.applications.uxanalytics.ui;
+
+import java.net.URI;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import javafx.application.Platform;
+import javafx.fxml.FXMLLoader;
+import javafx.scene.Parent;
+import javafx.scene.Scene;
+import javafx.stage.Stage;
+import org.phoebus.applications.uxanalytics.monitor.*;
+import org.phoebus.applications.uxanalytics.monitor.backend.database.BackendConnection;
+import org.phoebus.applications.uxanalytics.monitor.backend.database.ServiceLayerConnection;
+import org.phoebus.framework.preferences.PhoebusPreferenceService;
+import org.phoebus.framework.spi.AppInstance;
+import org.phoebus.framework.spi.AppResourceDescriptor;
+import org.phoebus.framework.workbench.ApplicationService;
+
+/**
+ * @author Evan Daykin
+ */
+
+public class UXAnalyticsMain implements AppResourceDescriptor {
+ public static final Logger logger = Logger.getLogger(UXAnalyticsMain.class.getPackageName());
+ public static final String NAME = "uxanalyticsconfig";
+ public static final String DISPLAY_NAME = "UX Analytics Config";
+ private BackendConnection phoebusConnection = ServiceLayerConnection.getInstance();
+ private BackendConnection jfxConnection = ServiceLayerConnection.getInstance();
+ private final UXAMonitor monitor = UXAMonitor.getInstance();
+ @Override
+ public AppInstance create(URI resource) {
+ return create();
+ }
+
+ @Override
+ public String getName() {
+ return NAME;
+ }
+
+ @Override
+ public String getDisplayName() {
+ return DISPLAY_NAME;
+ }
+
+ @Override
+ public void start(){
+ boolean consent = ConsentPersistence.getConsent();
+ if(!ConsentPersistence.consentIsPersistent()){
+ Platform.runLater(() -> {
+ ApplicationService.createInstance(NAME);
+ });
+ }
+ monitor.setPhoebusConnection(phoebusConnection);
+ monitor.setJfxConnection(jfxConnection);
+ if(consent){
+ monitor.enableTracking();
+ }
+ else{
+ monitor.disableTracking();
+ }
+ logger.log(Level.FINE, "Loaded UX Analytics plugin with consent: " + consent);
+ }
+
+ @Override
+ public AppInstance create() {
+ try{
+ final FXMLLoader loader = new FXMLLoader();
+ loader.setLocation(UXAnalyticsMain.class.getResource("/org/phoebus/applications/uxanalytics/ui/uxa-settings-dialog.fxml"));
+ loader.setController(new UXAController(phoebusConnection));
+ Parent root = loader.load();
+ final UXAController controller = loader.getController();
+ controller.setObserver(monitor);
+ Scene scene = new Scene(root,400,200);
+ Stage stage = new Stage();
+ stage.setTitle("Analytics Opt-In");
+ stage.setScene(scene);
+ stage.show();
+ stage.toFront();
+ }
+ catch (Exception e){
+ logger.log(Level.WARNING, "Failed to create UX Analytics dialog", e);
+ }
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/app/ux-analytics/ui/src/main/resources/META-INF/services/org.phoebus.framework.spi.AppDescriptor b/app/ux-analytics/ui/src/main/resources/META-INF/services/org.phoebus.framework.spi.AppDescriptor
new file mode 100644
index 0000000000..b2c22aa3e0
--- /dev/null
+++ b/app/ux-analytics/ui/src/main/resources/META-INF/services/org.phoebus.framework.spi.AppDescriptor
@@ -0,0 +1 @@
+org.phoebus.applications.uxanalytics.ui.UXAnalyticsMain
diff --git a/app/ux-analytics/ui/src/main/resources/META-INF/services/org.phoebus.ui.spi.MenuEntry b/app/ux-analytics/ui/src/main/resources/META-INF/services/org.phoebus.ui.spi.MenuEntry
new file mode 100644
index 0000000000..39d1712863
--- /dev/null
+++ b/app/ux-analytics/ui/src/main/resources/META-INF/services/org.phoebus.ui.spi.MenuEntry
@@ -0,0 +1 @@
+org.phoebus.applications.uxanalytics.ui.CreateUXAMenuEntry
\ No newline at end of file
diff --git a/app/ux-analytics/ui/src/main/resources/org/phoebus/applications/uxanalytics/ui/uxa-settings-dialog.fxml b/app/ux-analytics/ui/src/main/resources/org/phoebus/applications/uxanalytics/ui/uxa-settings-dialog.fxml
new file mode 100644
index 0000000000..ba8f0a11ea
--- /dev/null
+++ b/app/ux-analytics/ui/src/main/resources/org/phoebus/applications/uxanalytics/ui/uxa-settings-dialog.fxml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/security/src/main/java/org/phoebus/security/tokens/AuthenticationScope.java b/core/security/src/main/java/org/phoebus/security/tokens/AuthenticationScope.java
index a91367ef8c..68a5ebcaa2 100644
--- a/core/security/src/main/java/org/phoebus/security/tokens/AuthenticationScope.java
+++ b/core/security/src/main/java/org/phoebus/security/tokens/AuthenticationScope.java
@@ -32,7 +32,13 @@
public enum AuthenticationScope {
LOGBOOK("logbook"),
- SAVE_AND_RESTORE("save-and-restore");
+ SAVE_AND_RESTORE("save-and-restore"),
+ NEO4J("graph-database"),
+ S3("aws-image-bucket"),
+ MONGODB("mongodb-ux"),
+ MARIADB("mariadb-ux");
+
+
private String name = null;
diff --git a/phoebus-product/.classpath b/phoebus-product/.classpath
index 0ee9f7c14a..31318b4462 100644
--- a/phoebus-product/.classpath
+++ b/phoebus-product/.classpath
@@ -56,9 +56,9 @@
-
diff --git a/phoebus-product/pom.xml b/phoebus-product/pom.xml
index eb11e5b2c4..9cd5346fd9 100644
--- a/phoebus-product/pom.xml
+++ b/phoebus-product/pom.xml
@@ -287,6 +287,16 @@
core-pv-jackie
5.0.1-SNAPSHOT
+
+ org.phoebus
+ app-analytics-ui
+ 4.7.4-SNAPSHOT
+
+
+ org.phoebus
+ app-analytics-monitor
+ 4.7.4-SNAPSHOT
+