diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 5b6f0b4..3126a91 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- [JHP-82]: Enables support for JupyterHub named servers. This feature allows users to start multiple Jupyter notebooks + or Dashboards from the XNAT UI. A new preference has been added to the plugin to limit the number of named + servers a user can start. The default is 1. May require browser cache to be cleared. + ### Changed - [JHP-89]: Changed the action labels to `Start Jupyter Notebook` and `Start Jupyter Dashboard` for clarity and @@ -65,5 +71,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [JHP-73]: https://radiologics.atlassian.net/jira/software/c/projects/JHP/issues/JHP-73 [JHP-74]: https://radiologics.atlassian.net/jira/software/c/projects/JHP/issues/JHP-74 [JHP-77]: https://radiologics.atlassian.net/jira/software/c/projects/JHP/issues/JHP-77 +[JHP-82]: https://radiologics.atlassian.net/jira/software/c/projects/JHP/issues/JHP-82 [JHP-83]: https://radiologics.atlassian.net/jira/software/c/projects/JHP/issues/JHP-83 [JHP-88]: https://radiologics.atlassian.net/jira/software/c/projects/JHP/issues/JHP-88 diff --git a/src/main/java/org/nrg/xnatx/plugins/jupyterhub/client/DefaultJupyterHubClient.java b/src/main/java/org/nrg/xnatx/plugins/jupyterhub/client/DefaultJupyterHubClient.java index cf7a838..839b5bd 100644 --- a/src/main/java/org/nrg/xnatx/plugins/jupyterhub/client/DefaultJupyterHubClient.java +++ b/src/main/java/org/nrg/xnatx/plugins/jupyterhub/client/DefaultJupyterHubClient.java @@ -12,6 +12,7 @@ import org.springframework.web.client.RestTemplate; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; @@ -246,10 +247,12 @@ public void stopServer(String username, String servername) { RestTemplate restTemplate = new RestTemplate(); + Map requestBody = Collections.singletonMap("remove", true); + MultiValueMap headers = new HttpHeaders(); headers.add(HttpHeaders.AUTHORIZATION, "token " + jupyterHubApiToken); headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); - HttpEntity request = new HttpEntity<>(null, headers); + HttpEntity> request = new HttpEntity<>(requestBody, headers); try { ResponseEntity response = restTemplate.exchange(serverUrl(username, servername), diff --git a/src/main/java/org/nrg/xnatx/plugins/jupyterhub/preferences/JupyterHubPreferences.java b/src/main/java/org/nrg/xnatx/plugins/jupyterhub/preferences/JupyterHubPreferences.java index cee7b4d..769515b 100644 --- a/src/main/java/org/nrg/xnatx/plugins/jupyterhub/preferences/JupyterHubPreferences.java +++ b/src/main/java/org/nrg/xnatx/plugins/jupyterhub/preferences/JupyterHubPreferences.java @@ -33,6 +33,7 @@ public class JupyterHubPreferences extends AbstractPreferenceBean { public static final String RESOURCE_SPEC_MEM_RESERVATION_PREF_ID = "resourceSpecMemReservation"; public static final String INACTIVITY_TIMEOUT_PREF_ID = "inactivityTimeout"; public static final String MAX_SERVER_LIFETIME_PREF_ID = "maxServerLifetime"; + public static final String MAX_NAMED_SERVERS_PREF_ID = "maxNamedServers"; public static final String SHARED_PROJECT_STRING = "jupyter-notebooks"; @@ -336,6 +337,23 @@ public void setMaxServerLifetime(final long maxServerLifetime) { } } + @NrgPreference(defaultValue = "1") + public int getMaxNamedServers() { + return getIntegerValue(MAX_NAMED_SERVERS_PREF_ID); + } + + public void setMaxNamedServers(final int maxNamedServers) { + try { + if (maxNamedServers < 1) { + throw new IllegalArgumentException("Max named servers must be at least 1."); + } + + setIntegerValue(maxNamedServers, MAX_NAMED_SERVERS_PREF_ID); + } catch (InvalidPreferenceName e) { + log.error("Invalid preference name 'maxNamedServers': something is very wrong here.", e); + } + } + @NrgPreference(defaultValue = "false") public boolean getAllUsersCanStartJupyter() { return getBooleanValue(ALL_USERS_JUPYTER); diff --git a/src/main/java/org/nrg/xnatx/plugins/jupyterhub/rest/JupyterHubApi.java b/src/main/java/org/nrg/xnatx/plugins/jupyterhub/rest/JupyterHubApi.java index 5af0436..ac1473e 100644 --- a/src/main/java/org/nrg/xnatx/plugins/jupyterhub/rest/JupyterHubApi.java +++ b/src/main/java/org/nrg/xnatx/plugins/jupyterhub/rest/JupyterHubApi.java @@ -40,7 +40,6 @@ import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; import static org.springframework.web.bind.annotation.RequestMethod.*; -@SuppressWarnings("DefaultAnnotationParam") @Api("JupyterHub Plugin API") @XapiRestController @RequestMapping("/jupyterhub") @@ -133,7 +132,7 @@ public Server getServer(@ApiParam(value = "username", required = true) @PathVari return jupyterHubService.getServer(getUserI(username)).orElse(null); } - @ApiOperation(value = "Get Jupyter Server details for a user.", hidden = true) + @ApiOperation(value = "Get Jupyter Server details for a user.") @ApiResponses({@ApiResponse(code = 200, message = "Server found."), @ApiResponse(code = 401, message = "Must be authenticated to access the XNAT REST API."), @ApiResponse(code = 403, message = "Not authorized."), @@ -165,8 +164,7 @@ public void startServer(@ApiParam(value = "username", required = true) @PathVari @ApiOperation(value = "Starts a Jupyter server for the user", - notes = "Use the Event Tracking API to track progress.", - hidden = true) + notes = "Use the Event Tracking API to track progress.") @ApiResponses({@ApiResponse(code = 200, message = "Jupyter server successfully started"), @ApiResponse(code = 401, message = "Must be authenticated to access the XNAT REST API."), @ApiResponse(code = 403, message = "Not authorized."), @@ -202,8 +200,7 @@ public XnatUserOptions getUserOptions(@ApiParam(value = "username", required = t } @ApiOperation(value = "Returns the last known user options for the named server", - response = XnatUserOptions.class, - hidden = true) + response = XnatUserOptions.class) @ApiResponses({@ApiResponse(code = 200, message = "Successfully retrieved user options."), @ApiResponse(code = 401, message = "Must be authenticated to access the XNAT REST API."), @ApiResponse(code = 403, message = "Not authorized to access site configuration properties."), @@ -247,8 +244,7 @@ public void stopServer(@ApiParam(value = "username", required = true) @PathVaria } @ApiOperation(value = "Stops a users named Jupyter server", - notes = "Use the Event Tracking API to track progress.", - hidden = true) + notes = "Use the Event Tracking API to track progress.") @ApiResponses({@ApiResponse(code = 200, message = "Jupyter server successfully stopped."), @ApiResponse(code = 401, message = "Must be authenticated to access the XNAT REST API."), @ApiResponse(code = 403, message = "Not authorized."), diff --git a/src/main/java/org/nrg/xnatx/plugins/jupyterhub/rest/JupyterHubPreferencesApi.java b/src/main/java/org/nrg/xnatx/plugins/jupyterhub/rest/JupyterHubPreferencesApi.java index 16802a8..ebf0847 100644 --- a/src/main/java/org/nrg/xnatx/plugins/jupyterhub/rest/JupyterHubPreferencesApi.java +++ b/src/main/java/org/nrg/xnatx/plugins/jupyterhub/rest/JupyterHubPreferencesApi.java @@ -119,6 +119,10 @@ public Map getSpecifiedPreference(@ApiParam(value = "The Jupyter value = jupyterHubPreferences.getMaxServerLifetime(); break; } + case (JupyterHubPreferences.MAX_NAMED_SERVERS_PREF_ID): { + value = jupyterHubPreferences.getMaxNamedServers(); + break; + } case (JupyterHubPreferences.ALL_USERS_JUPYTER): { value = jupyterHubPreferences.getAllUsersCanStartJupyter(); break; diff --git a/src/main/java/org/nrg/xnatx/plugins/jupyterhub/services/UserOptionsService.java b/src/main/java/org/nrg/xnatx/plugins/jupyterhub/services/UserOptionsService.java index c0408dc..4e7f2ad 100644 --- a/src/main/java/org/nrg/xnatx/plugins/jupyterhub/services/UserOptionsService.java +++ b/src/main/java/org/nrg/xnatx/plugins/jupyterhub/services/UserOptionsService.java @@ -27,5 +27,6 @@ public interface UserOptionsService { Optional retrieveUserOptions(UserI user); Optional retrieveUserOptions(UserI user, String servername); void storeUserOptions(UserI user, String servername, String xsiType, String id, String projectId, Long computeEnvironmentConfigId, Long hardwareConfigId, Long dashboardConfigId, String eventTrackingId) throws BaseXnatExperimentdata.UnknownPrimaryProjectException, DBPoolException, SQLException, InvalidArchiveStructure, IOException; + void removeUserOptions(UserI user, String servername); } diff --git a/src/main/java/org/nrg/xnatx/plugins/jupyterhub/services/impl/DefaultJupyterHubService.java b/src/main/java/org/nrg/xnatx/plugins/jupyterhub/services/impl/DefaultJupyterHubService.java index 3cc2bd1..0f843aa 100644 --- a/src/main/java/org/nrg/xnatx/plugins/jupyterhub/services/impl/DefaultJupyterHubService.java +++ b/src/main/java/org/nrg/xnatx/plugins/jupyterhub/services/impl/DefaultJupyterHubService.java @@ -241,17 +241,15 @@ public void startServer(final UserI user, final ServerStartRequest startRequest) JupyterServerEventI.Operation.Start, 0, "Checking for existing Jupyter servers.")); - boolean hasRunningServer = jupyterHubClient.getUser(user.getUsername()) - .orElseGet(() -> createUser(user)) - .getServers() - .containsKey(servername); - if (hasRunningServer) { - eventService.triggerEvent(JupyterServerEvent.failed(eventTrackingId, - user.getID(), xsiType, itemId, + int maxNamedServers = jupyterHubPreferences.getMaxNamedServers(); + boolean hasMaxNamedServers = jupyterHubClient.getUser(user.getUsername()) + .orElseGet(() -> createUser(user)) + .getServers() + .size() >= maxNamedServers; + if (hasMaxNamedServers) { + eventService.triggerEvent(JupyterServerEvent.failed(eventTrackingId, user.getID(), xsiType, itemId, JupyterServerEventI.Operation.Start, - "Failed to launch " + application + ". " + - "There is already a dashboard or Jupyter notebook running. " + - "Please stop the running dashboard or notebook before starting a new one.")); + "Failed to launch " + application + ". Maximum number of running Jupyter servers reached.")); return; } @@ -448,6 +446,8 @@ public void stopServer(final UserI user, final String servername, String eventTr if (!server.isPresent()) { log.info("Jupyter server stopped for user {}", user.getUsername()); + log.debug("Removing user options for user {} and server {}", user.getUsername(), servername); + userOptionsService.removeUserOptions(user, servername); eventService.triggerEvent(JupyterServerEvent.completed(eventTrackingId, user.getID(), JupyterServerEventI.Operation.Stop, "Jupyter Server Stopped.")); diff --git a/src/main/java/org/nrg/xnatx/plugins/jupyterhub/services/impl/DefaultUserOptionsService.java b/src/main/java/org/nrg/xnatx/plugins/jupyterhub/services/impl/DefaultUserOptionsService.java index 1edd75b..21cdf20 100644 --- a/src/main/java/org/nrg/xnatx/plugins/jupyterhub/services/impl/DefaultUserOptionsService.java +++ b/src/main/java/org/nrg/xnatx/plugins/jupyterhub/services/impl/DefaultUserOptionsService.java @@ -425,6 +425,13 @@ public void storeUserOptions(UserI user, String servername, String xsiType, Stri userOptionsEntityService.createOrUpdate(userOptionsEntity); } + @Override + public void removeUserOptions(UserI user, String servername) { + log.debug("Removing user options for user '{}' server '{}'", user.getUsername(), servername); + Optional userOptionsEntity = userOptionsEntityService.find(user.getID(), servername); + userOptionsEntity.ifPresent(userOptionsEntityService::delete); + } + /** * Translate paths within the archive to paths within the docker container * @param path the path to translate, must be the archive path or a subdirectory of the archive path diff --git a/src/main/resources/META-INF/resources/scripts/xnat/plugin/jupyterhub/jupyterhub-servers.js b/src/main/resources/META-INF/resources/scripts/xnat/plugin/jupyterhub/jupyterhub-servers.js index c8ade18..c8ba428 100644 --- a/src/main/resources/META-INF/resources/scripts/xnat/plugin/jupyterhub/jupyterhub-servers.js +++ b/src/main/resources/META-INF/resources/scripts/xnat/plugin/jupyterhub/jupyterhub-servers.js @@ -29,12 +29,7 @@ XNAT.compute.computeEnvironmentConfigs = getObject(XNAT.compute.computeEnvironme let restUrl = XNAT.url.restUrl; let newServerUrl = XNAT.plugin.jupyterhub.servers.newServerUrl = function (username, servername) { - let url = `/xapi/jupyterhub/users/${username}/server`; - - // if (servername && servername !== "") { - // url = `${url}/${servername}`; - // } - + let url = `/xapi/jupyterhub/users/${username}/server/${servername}`; return restUrl(url); } @@ -72,27 +67,24 @@ XNAT.compute.computeEnvironmentConfigs = getObject(XNAT.compute.computeEnvironme } XNAT.plugin.jupyterhub.servers.startServerForProject = function(username = window.username, - servername = XNAT.data.context.projectID, xsiType = XNAT.data.context.xsiType, itemId = XNAT.data.context.projectID, projectId = XNAT.data.context.projectID, eventTrackingId = generateEventTrackingId()) { console.debug(`jupyterhub-servers.js: XNAT.plugin.jupyterhub.servers.startServerForProject`); - startServer(username, servername, xsiType, itemId, projectId, projectId, eventTrackingId) + startServer(username, eventTrackingId, xsiType, itemId, projectId, projectId, eventTrackingId) } XNAT.plugin.jupyterhub.servers.startDashboardForProject = function(username = window.username, - servername = XNAT.data.context.projectID, xsiType = XNAT.data.context.xsiType, itemId = XNAT.data.context.projectID, projectId = XNAT.data.context.projectID, eventTrackingId = generateEventTrackingId()) { console.debug(`jupyterhub-servers.js: XNAT.plugin.jupyterhub.servers.startDashboardForProject`); - startDashboard(username, servername, xsiType, itemId, projectId, projectId, eventTrackingId); + startDashboard(username, eventTrackingId, xsiType, itemId, projectId, projectId, eventTrackingId); } XNAT.plugin.jupyterhub.servers.startServerForSubject = function(username = window.username, - servername = XNAT.data.context.ID, xsiType = XNAT.data.context.xsiType, itemId = XNAT.data.context.ID, projectId = XNAT.data.context.projectID, @@ -116,13 +108,12 @@ XNAT.compute.computeEnvironmentConfigs = getObject(XNAT.compute.computeEnvironme ] }); } else { - getSubjectLabel(itemId).then(subjectLabel => startServer(username, servername, xsiType, itemId, + getSubjectLabel(itemId).then(subjectLabel => startServer(username, eventTrackingId, xsiType, itemId, subjectLabel, projectId, eventTrackingId)) } } XNAT.plugin.jupyterhub.servers.startDashboardForSubject = function(username = window.username, - servername = XNAT.data.context.ID, xsiType = XNAT.data.context.xsiType, itemId = XNAT.data.context.ID, projectId = XNAT.data.context.projectID, @@ -147,7 +138,7 @@ XNAT.compute.computeEnvironmentConfigs = getObject(XNAT.compute.computeEnvironme }); } else { getSubjectLabel(itemId).then(subjectLabel => { - startDashboard(username, servername, xsiType, itemId, subjectLabel, projectId, eventTrackingId) + startDashboard(username, eventTrackingId, xsiType, itemId, subjectLabel, projectId, eventTrackingId) }).catch(e => { console.error(`Error getting subject label: ${e}`); }); @@ -155,7 +146,6 @@ XNAT.compute.computeEnvironmentConfigs = getObject(XNAT.compute.computeEnvironme } XNAT.plugin.jupyterhub.servers.startServerForExperiment = function(username = window.username, - servername = XNAT.data.context.ID, xsiType = "xnat:experimentData", itemId = XNAT.data.context.ID, projectId = XNAT.data.context.projectID, @@ -179,13 +169,12 @@ XNAT.compute.computeEnvironmentConfigs = getObject(XNAT.compute.computeEnvironme ] }); } else { - getExperimentLabel(itemId).then(experimentLabel => startServer(username, servername, xsiType, itemId, + getExperimentLabel(itemId).then(experimentLabel => startServer(username, eventTrackingId, xsiType, itemId, experimentLabel, projectId, eventTrackingId)) } } XNAT.plugin.jupyterhub.servers.startDashboardForExperiment = function(username = window.username, - servername = XNAT.data.context.ID, xsiType = XNAT.data.context.xsiType, itemId = XNAT.data.context.ID, projectId = XNAT.data.context.projectID, @@ -210,7 +199,7 @@ XNAT.compute.computeEnvironmentConfigs = getObject(XNAT.compute.computeEnvironme }); } else { getExperimentLabel(itemId).then(experimentLabel => { - startDashboard(username, servername, xsiType, itemId, experimentLabel, projectId, eventTrackingId) + startDashboard(username, eventTrackingId, xsiType, itemId, experimentLabel, projectId, eventTrackingId) }).catch(e => { console.error(`Error getting experiment label: ${e}`); }); @@ -220,7 +209,7 @@ XNAT.compute.computeEnvironmentConfigs = getObject(XNAT.compute.computeEnvironme XNAT.plugin.jupyterhub.servers.startServerForStoredSearch = function(username, servername, xsiType, itemId, itemLabel, projectId, eventTrackingId) { console.debug(`jupyterhub-servers.js: XNAT.plugin.jupyterhub.servers.startServerForStoredSearch`); - startServer(username, servername, xsiType, itemId, itemLabel, projectId, eventTrackingId) + startServer(username, eventTrackingId, xsiType, itemId, itemLabel, projectId, eventTrackingId) } @@ -311,7 +300,7 @@ XNAT.compute.computeEnvironmentConfigs = getObject(XNAT.compute.computeEnvironme const serverStartRequest = { 'username': username, - 'servername': '', // Not supported yet + 'servername': servername, 'xsiType': xsiType, 'itemId': itemId, 'itemLabel': itemLabel, @@ -347,10 +336,63 @@ XNAT.compute.computeEnvironmentConfigs = getObject(XNAT.compute.computeEnvironme XNAT.dialog.open({ title: 'Start Jupyter Notebook', - content: spawn('form#server-start-request-form'), + content: spawn('div', [ + spawn('div#warning-message.warning', {style: {display: 'none'}}), + spawn('form#server-start-request-form') + ] + ), maxBtn: true, width: 500, beforeShow: function(obj) { + // Show message if user is already running a server for this xsiType and itemId + Promise.all([ + XNAT.plugin.jupyterhub.preferences.get('maxNamedServers'), + XNAT.plugin.jupyterhub.users.getUser(username) + ]).then(([maxNamedServers, user]) => { + let servers = user['servers'] || {}; + + if (Object.keys(servers).length >= maxNamedServers) { + XNAT.dialog.open({ + width: 450, + title: "Error", + content: spawn('div.warning', [ + spawn('p', 'You have reached the maximum number of running Jupyter servers per user. Please stop a server from the Jupyter navigation menu before starting a new one.') + ]), + buttons: [ + { + label: 'OK', + isDefault: true, + close: true, + action: function() { + xmodal.closeAll(); + XNAT.ui.dialog.closeAll(); + } + } + ] + }); + return; + } + + let duplicateServers = Object.entries(servers).filter(([serverName, server]) => { + return server['user_options']['xsiType'] === xsiType + && server['user_options']['itemId'] === itemId + && (server['user_options']['dashboardConfigId'] === undefined || + server['user_options']['dashboardConfigId'] === null || + server['user_options']['dashboardConfigId'] === ''); + }); + + if (duplicateServers.length === 1) { + document.getElementById('warning-message').style.display = 'block'; + document.getElementById('warning-message').innerHTML = + "You're already running a Jupyter notebook on this data. " + + " XNAT.ui.dialog.closeAll())'>Access it here " + + "or from the Jupyter navigation menu."; + } else if (duplicateServers.length > 1) { + document.getElementById('warning-message').style.display = 'block'; + document.getElementById('warning-message').innerHTML = 'You are already running multiple Jupyter servers for this data. They can be accessed from the Jupyter navigation menu.'; + } + }) + const form = document.getElementById('server-start-request-form'); form.classList.add('panel'); @@ -498,7 +540,7 @@ XNAT.compute.computeEnvironmentConfigs = getObject(XNAT.compute.computeEnvironme const serverStartRequest = { 'username': username, - 'servername': '', // Not supported yet + 'servername': servername, 'xsiType': xsiType, 'itemId': itemId, 'itemLabel': itemLabel, @@ -535,11 +577,64 @@ XNAT.compute.computeEnvironmentConfigs = getObject(XNAT.compute.computeEnvironme XNAT.dialog.open({ title: 'Start Jupyter Dashboard', - content: spawn('form#server-start-request-form'), + content: spawn('div', [ + spawn('div#warning-message.warning', {style: {display: 'none'}}), + spawn('form#server-start-request-form') + ] + ), maxBtn: true, width: 400, height: 500, beforeShow: function(obj) { + // Show message if user is already running a server for this xsiType and itemId + Promise.all([ + XNAT.plugin.jupyterhub.preferences.get('maxNamedServers'), + XNAT.plugin.jupyterhub.users.getUser(username) + ]).then(([maxNamedServers, user]) => { + let servers = user['servers'] || {}; + + if (Object.keys(servers).length >= maxNamedServers) { + XNAT.dialog.open({ + width: 450, + title: "Error", + content: spawn('div.warning', [ + spawn('p', 'You have reached the maximum number of running Jupyter servers per user. Please stop a server from the Jupyter navigation menu before starting a new one.') + ]), + buttons: [ + { + label: 'OK', + isDefault: true, + close: true, + action: function() { + xmodal.closeAll(); + XNAT.ui.dialog.closeAll(); + } + } + ] + }); + return; + } + + let duplicateServers = Object.entries(servers).filter(([serverName, server]) => { + return server['user_options']['xsiType'] === xsiType + && server['user_options']['itemId'] === itemId + && (server['user_options']['dashboardConfigId'] !== undefined && + server['user_options']['dashboardConfigId'] !== null && + server['user_options']['dashboardConfigId'] !== ''); + }); + + if (duplicateServers.length === 1) { + document.getElementById('warning-message').style.display = 'block'; + document.getElementById('warning-message').innerHTML = + "You're already running a Jupyter dashboard on this data. " + + " XNAT.ui.dialog.closeAll())'>Access it here " + + "or from the Jupyter navigation menu."; + } else if (duplicateServers.length > 1) { + document.getElementById('warning-message').style.display = 'block'; + document.getElementById('warning-message').innerHTML = 'You are already running multiple Dashboards on this data. They can be accessed from the Jupyter navigation menu.'; + } + }) + const form = document.getElementById('server-start-request-form'); form.classList.add('panel'); @@ -712,7 +807,7 @@ XNAT.compute.computeEnvironmentConfigs = getObject(XNAT.compute.computeEnvironme } XNAT.plugin.jupyterhub.servers.goTo = function(server_url) { - Promise.all([ + return Promise.all([ XNAT.plugin.jupyterhub.preferences.get('jupyterHubHostUrl'), XNAT.plugin.jupyterhub.users.tokens.create() ]).then(([jupyterHubHostUrl, token]) => { diff --git a/src/main/resources/META-INF/resources/scripts/xnat/plugin/jupyterhub/jupyterhub-users.js b/src/main/resources/META-INF/resources/scripts/xnat/plugin/jupyterhub/jupyterhub-users.js index 00cf194..e917990 100644 --- a/src/main/resources/META-INF/resources/scripts/xnat/plugin/jupyterhub/jupyterhub-users.js +++ b/src/main/resources/META-INF/resources/scripts/xnat/plugin/jupyterhub/jupyterhub-users.js @@ -186,26 +186,28 @@ XNAT.plugin.jupyterhub.users.tokens = getObject(XNAT.plugin.jupyterhub.users.tok XNAT.plugin.jupyterhub.users.getUsers().then(users => { let noRunningServers = true; - users.forEach(user => { - let name = user['name']; - let admin = user['admin'] ? 'admin' : ''; - let servers = user['servers']; - let hasServer = '' in servers; // TODO: handle multiple servers, '' is the default server name - let url = hasServer ? servers['']['url'] : ''; - let ready = hasServer ? servers['']['ready'] : ''; - let started = hasServer ? new Date(servers['']['started']) : ''; - let lastActivity = hasServer ? new Date(servers['']['last_activity']) : ''; - - if (hasServer) { - noRunningServers = false; - usersTable.tr() - .td([spawn('div.left', [name])]) - .td([spawn('div.center', [hasServer ? serverDialog(user['name'], servers['']) : ''])]) - .td([spawn('div.center', [ready ? spawn('i.fa.fa-check') : spawn('i.fa.fa-gear')])]) - .td([spawn('div.center', [started.toLocaleString()])]) - .td([spawn('div.center', [lastActivity.toLocaleString()])]) - .td([spawn('div.center', [hasServer ? stopServerButton(name, '') : ''])]); - } + users.sort((a, b) => a['name'].localeCompare(b['name'])) + .forEach(user => { + let name = user['name']; + let admin = user['admin'] ? 'admin' : ''; + let servers = user['servers']; + + for (let serverName in servers) { + let server = servers[serverName]; + let url = server['url'] || ''; + let ready = server['ready'] || ''; + let started = server['started'] ? new Date(server['started']) : ''; + let lastActivity = server['last_activity'] ? new Date(server['last_activity']) : ''; + + noRunningServers = false; + usersTable.tr() + .td([spawn('div.left', [name])]) + .td([spawn('div.center', [serverDialog(user['name'], server)])]) + .td([spawn('div.center', [ready ? spawn('i.fa.fa-check') : spawn('i.fa.fa-gear')])]) + .td([spawn('div.center', [started.toLocaleString()])]) + .td([spawn('div.center', [lastActivity.toLocaleString()])]) + .td([spawn('div.center', [stopServerButton(name, serverName)])]); + } }) if (noRunningServers) { diff --git a/src/main/resources/META-INF/xnat/spawner/jupyterhub/site-settings.yaml b/src/main/resources/META-INF/xnat/spawner/jupyterhub/site-settings.yaml index 3f13ae9..3d2b177 100644 --- a/src/main/resources/META-INF/xnat/spawner/jupyterhub/site-settings.yaml +++ b/src/main/resources/META-INF/xnat/spawner/jupyterhub/site-settings.yaml @@ -153,6 +153,16 @@ maxServerLifetime: description: > Automatically shut down Jupyter notebook servers after a certain amount of time regardless of activity. Set to 0 to not shut down any long running servers. +maxNamedServers: + kind: panel.input.number + id: maxNamedServers + name: maxNamedServers + label: Max Servers Per User + afterElement: server(s) + validation: integer gte:1 onblur + description: > + The maximum number of servers a user can have running at once on JupyterHub. At least one server per user is required. + jupyterHubSetup: kind: panel name: jupyterHubSetup @@ -286,6 +296,7 @@ jupyterhubPreferences: ${stopTimeout} ${inactivityTimeout} ${maxServerLifetime} + ${maxNamedServers} ${workspacePath} ${pathTranslationArchivePrefix} ${pathTranslationArchiveDockerPrefix} diff --git a/src/test/java/org/nrg/xnatx/plugins/jupyterhub/services/impl/DefaultJupyterHubServiceTest.java b/src/test/java/org/nrg/xnatx/plugins/jupyterhub/services/impl/DefaultJupyterHubServiceTest.java index e60a36e..fcd25c4 100644 --- a/src/test/java/org/nrg/xnatx/plugins/jupyterhub/services/impl/DefaultJupyterHubServiceTest.java +++ b/src/test/java/org/nrg/xnatx/plugins/jupyterhub/services/impl/DefaultJupyterHubServiceTest.java @@ -64,12 +64,11 @@ public class DefaultJupyterHubServiceTest { private UserI user; private String username; - private final String servername = ""; - private final String eventTrackingId = "eventTrackingId"; + private final String servername = "20240513T143619676Z"; + private final String eventTrackingId = "20240513T143619676Z"; private final String projectId = "TestProject"; private final Long computeEnvironmentConfigId = 3L; private final Long hardwareConfigId = 2L; - private User userWithServers; private User userNoServers; private ServerStartRequest startProjectRequest; @@ -85,15 +84,6 @@ public void before() throws org.nrg.xdat.security.user.exceptions.UserNotFoundEx when(user.getUsername()).thenReturn(username); when(mockUserManagementServiceI.getUser(eq(username))).thenReturn(user); - Server server = Server.builder() - .name(servername) - .build(); - - userWithServers = User.builder() - .name(username) - .servers(Collections.singletonMap("", server)) - .build(); - userNoServers = User.builder() .name(username) .servers(Collections.emptyMap()) @@ -160,6 +150,9 @@ public void before() throws org.nrg.xdat.security.user.exceptions.UserNotFoundEx when(mockJupyterHubPreferences.getStartPollingInterval()).thenReturn(1); when(mockJupyterHubPreferences.getStopTimeout()).thenReturn(2); when(mockJupyterHubPreferences.getStopPollingInterval()).thenReturn(1); + + // Default preferences + when(mockJupyterHubPreferences.getMaxNamedServers()).thenReturn(1); } @After @@ -167,6 +160,7 @@ public void after() { Mockito.reset(mockJupyterHubClient); Mockito.reset(mockEventService); Mockito.reset(mockPermissionsHelper); + Mockito.reset(mockUserOptionsService); Mockito.reset(mockUserOptionsEntityService); Mockito.reset(mockUserManagementServiceI); Mockito.reset(mockJobTemplateService); @@ -323,6 +317,7 @@ public void testStartServer_cantReadScan() throws Exception { public void testStartServer_HubOffline() throws Exception { // Grant permissions when(mockPermissionsHelper.canRead(any(), anyString(), anyString(), anyString())).thenReturn(true); + when(mockJobTemplateService.isAvailable(any(), any(), any())).thenReturn(true); // Connection to JupyterHub failed when(mockJupyterHubClient.getVersion()).thenThrow(RuntimeException.class); @@ -348,6 +343,7 @@ public void testStartServer_HubOffline() throws Exception { public void testStartServer_HubOnlineButXNATCantConnect() throws Exception { // Grant permissions when(mockPermissionsHelper.canRead(any(), anyString(), anyString(), anyString())).thenReturn(true); + when(mockJobTemplateService.isAvailable(any(), any(), any())).thenReturn(true); // Can't getInfo from JupyterHub, but connection to JupyterHub succeeded when(mockJupyterHubClient.getVersion()).thenReturn(null); @@ -371,13 +367,21 @@ public void testStartServer_HubOnlineButXNATCantConnect() throws Exception { } @Test(timeout = 2000) - public void testStartServer_serverAlreadyRunning() throws Exception { + public void testStartServer_serverLimitReached() throws Exception { // Grant permissions when(mockPermissionsHelper.canRead(any(), anyString(), anyString(), anyString())).thenReturn(true); + when(mockJobTemplateService.isAvailable(any(), any(), any())).thenReturn(true); - // Setup existing server + // Setup existing servers + Map servers = new HashMap<>(); + servers.put("20240513T143619676Z", Server.builder().build()); + servers.put("20240513T143720921Z", Server.builder().build()); + User userWithServers = User.builder().name(username).servers(servers).build(); when(mockJupyterHubClient.getUser(anyString())).thenReturn(Optional.of(userWithServers)); + // Update max named servers to 2 + when(mockJupyterHubPreferences.getMaxNamedServers()).thenReturn(2); + // Test jupyterHubService.startServer(user, startProjectRequest); Thread.sleep(1000); // Async call, need to wait. Is there a better way to test this? @@ -464,15 +468,15 @@ public void testStartServer_Timeout() throws Exception { Thread.sleep(3000); // Async call, need to wait. Is there a better way to test this? // Verify user options are stored - verify(mockUserOptionsService, times(1)).storeUserOptions(eq(user), eq(""), eq(XnatProjectdata.SCHEMA_ELEMENT_NAME), + verify(mockUserOptionsService, times(1)).storeUserOptions(eq(user), eq(servername), eq(XnatProjectdata.SCHEMA_ELEMENT_NAME), eq(projectId), eq(projectId), eq(computeEnvironmentConfigId), eq(hardwareConfigId), isNull(), eq(eventTrackingId)); // Verify JupyterHub start server request sent - verify(mockJupyterHubClient, times(1)).startServer(eq(username), eq(""), any(UserOptions.class)); + verify(mockJupyterHubClient, times(1)).startServer(eq(username), eq(servername), any(UserOptions.class)); // Verify 2 attempts to get server from JupyterHub with polling rate and timeout - verify(mockJupyterHubClient, times(2)).getServer(eq(username), eq("")); + verify(mockJupyterHubClient, times(2)).getServer(eq(username), eq(servername)); // Verify failure to start event occurred verify(mockEventService, atLeastOnce()).triggerEvent(jupyterServerEventCaptor.capture()); @@ -499,12 +503,12 @@ public void testStartServer_Success() throws Exception { Thread.sleep(2500); // Async call, need to wait. Is there a better way to test this? // Verify user options are stored - verify(mockUserOptionsService, times(1)).storeUserOptions(eq(user), eq(""), eq(XnatProjectdata.SCHEMA_ELEMENT_NAME), + verify(mockUserOptionsService, times(1)).storeUserOptions(eq(user), eq(servername), eq(XnatProjectdata.SCHEMA_ELEMENT_NAME), eq(projectId), eq(projectId), eq(computeEnvironmentConfigId), eq(hardwareConfigId), isNull(), eq(eventTrackingId)); // Verify JupyterHub start server request sent - verify(mockJupyterHubClient, times(1)).startServer(eq(username), eq(""), any(UserOptions.class)); + verify(mockJupyterHubClient, times(1)).startServer(eq(username), eq(servername), any(UserOptions.class)); // Verify start completed event occurred verify(mockEventService, atLeastOnce()).triggerEvent(jupyterServerEventCaptor.capture()); @@ -536,12 +540,12 @@ public void testStartServer_CreateUser_Success() throws Exception { verify(mockJupyterHubClient, times(1)).createUser(anyString()); // Verify user options are stored - verify(mockUserOptionsService, times(1)).storeUserOptions(eq(user), eq(""), eq(XnatProjectdata.SCHEMA_ELEMENT_NAME), + verify(mockUserOptionsService, times(1)).storeUserOptions(eq(user), eq(servername), eq(XnatProjectdata.SCHEMA_ELEMENT_NAME), eq(projectId), eq(projectId), eq(computeEnvironmentConfigId), eq(hardwareConfigId), isNull(), eq(eventTrackingId)); // Verify JupyterHub start server request sent - verify(mockJupyterHubClient, times(1)).startServer(eq(username), eq(""), any(UserOptions.class)); + verify(mockJupyterHubClient, times(1)).startServer(eq(username), eq(servername), any(UserOptions.class)); // Verify start completed event occurred verify(mockEventService, atLeastOnce()).triggerEvent(jupyterServerEventCaptor.capture()); @@ -558,7 +562,7 @@ public void testStopSever_Failure() throws Exception { .thenReturn(Optional.of(Server.builder().build())); // Test - jupyterHubService.stopServer(user, eventTrackingId); + jupyterHubService.stopServer(user, servername, eventTrackingId); Thread.sleep(2500); // Async call, need to wait. Is there a better way to test this? // Verify one attempt to stop the sever @@ -580,7 +584,7 @@ public void testStopSever_Success() throws Exception { when(mockJupyterHubClient.getServer(anyString(), anyString())).thenReturn(Optional.empty()); // Test - jupyterHubService.stopServer(user, eventTrackingId); + jupyterHubService.stopServer(user, servername, eventTrackingId); Thread.sleep(2000); // Async call, need to wait. Is there a better way to test this? // Verify one attempt to stop the sever @@ -589,6 +593,9 @@ public void testStopSever_Success() throws Exception { // Verify at least one attempt to see if server stopped verify(mockJupyterHubClient, atLeastOnce()).getServer(username, servername); + // Verify user options are removed + verify(mockUserOptionsService, times(1)).removeUserOptions(eq(user), eq(servername)); + // Verify stop completed event occurred verify(mockEventService, atLeastOnce()).triggerEvent(jupyterServerEventCaptor.capture()); JupyterServerEventI capturedEvent = jupyterServerEventCaptor.getValue(); @@ -633,12 +640,12 @@ public void testCullIdleServers_Cull() throws Exception { servers.put("server_active", server_active); servers.put("server_inactive", server_inactive); - User user = User.builder() + User jupyterUser = User.builder() .name(username) .servers(servers) .build(); - when(mockJupyterHubClient.getUsers()).thenReturn(Collections.singletonList(user)); + when(mockJupyterHubClient.getUsers()).thenReturn(Collections.singletonList(jupyterUser)); // Test jupyterHubService.cullInactiveServers(); @@ -646,9 +653,11 @@ public void testCullIdleServers_Cull() throws Exception { // Verify active server not stopped verify(mockJupyterHubClient, never()).stopServer(eq(username), eq(server_active.getName())); + verify(mockUserOptionsService, never()).removeUserOptions(eq(user), eq(server_active.getName())); // Verify inactive server stopped verify(mockJupyterHubClient, times(1)).stopServer(eq(username), eq(server_inactive.getName())); + verify(mockUserOptionsService, times(1)).removeUserOptions(eq(user), eq(server_inactive.getName())); } @Test(timeout = 3000) @@ -662,6 +671,7 @@ public void testCullIdleServers_NoCull() throws InterruptedException { // Verify no servers stopped verify(mockJupyterHubClient, never()).stopServer(any(), any()); + verify(mockUserOptionsService, never()).removeUserOptions(any(), any()); } @Test(timeout = 3000) @@ -676,6 +686,7 @@ public void testCullIdleServers_Exception() throws InterruptedException { // Verify no servers stopped verify(mockJupyterHubClient, never()).stopServer(any(), any()); + verify(mockUserOptionsService, never()).removeUserOptions(any(), any()); } @Test(timeout = 3000) @@ -697,12 +708,12 @@ public void testCullLongRunningServers_Cull() throws InterruptedException { servers.put(server_active.getName(), server_active); servers.put(server_long_running.getName(), server_long_running); - User user = User.builder() + User jupyterUser = User.builder() .name(username) .servers(servers) .build(); - when(mockJupyterHubClient.getUsers()).thenReturn(Collections.singletonList(user)); + when(mockJupyterHubClient.getUsers()).thenReturn(Collections.singletonList(jupyterUser)); // Test jupyterHubService.cullLongRunningServers(); @@ -710,9 +721,11 @@ public void testCullLongRunningServers_Cull() throws InterruptedException { // Verify active server not stopped verify(mockJupyterHubClient, never()).stopServer(eq(username), eq(server_active.getName())); + verify(mockUserOptionsService, never()).removeUserOptions(eq(user), eq(server_active.getName())); // Verify inactive server stopped verify(mockJupyterHubClient, times(1)).stopServer(eq(username), eq(server_long_running.getName())); + verify(mockUserOptionsService, times(1)).removeUserOptions(eq(user), eq(server_long_running.getName())); } @Test(timeout = 3000) @@ -726,6 +739,7 @@ public void testCullLongRunningServers_NoCull_Disabled() throws InterruptedExcep // Verify servers not stopped verify(mockJupyterHubClient, never()).stopServer(any(), any()); + verify(mockUserOptionsService, never()).removeUserOptions(any(), any()); } @Test(timeout = 3000) @@ -748,12 +762,12 @@ public void testCullLongRunningServers_NoCull() throws InterruptedException { servers.put(server_active.getName(), server_active); servers.put(server_long_running.getName(), server_long_running); - User user = User.builder() + User jupyterUser = User.builder() .name(username) .servers(servers) .build(); - when(mockJupyterHubClient.getUsers()).thenReturn(Collections.singletonList(user)); + when(mockJupyterHubClient.getUsers()).thenReturn(Collections.singletonList(jupyterUser)); // Test jupyterHubService.cullLongRunningServers(); @@ -761,9 +775,11 @@ public void testCullLongRunningServers_NoCull() throws InterruptedException { // Verify active server not stopped verify(mockJupyterHubClient, never()).stopServer(eq(username), eq(server_active.getName())); + verify(mockUserOptionsService, never()).removeUserOptions(eq(user), eq(server_active.getName())); // Verify inactive server not stopped. Timeout is set to zero verify(mockJupyterHubClient, never()).stopServer(eq(username), eq(server_long_running.getName())); + verify(mockUserOptionsService, never()).removeUserOptions(eq(user), eq(server_long_running.getName())); } @Test(timeout = 3000) @@ -778,6 +794,6 @@ public void testCullLongRunningServers_Exception() throws InterruptedException { // Verify no server stopped verify(mockJupyterHubClient, never()).stopServer(any(), any()); - + verify(mockUserOptionsService, never()).removeUserOptions(any(), any()); } } \ No newline at end of file