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 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 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 @@ + + + + + + + + + + +