diff --git a/docs/requirements.txt b/docs/requirements.txt index 63f4be10..079fe1d7 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -2,5 +2,6 @@ myst-parser sphinx-autobuild sphinx-book-theme sphinx-copybutton +sphinxcontrib-openapi sphinxext-opengraph sphinxext-rediraffe diff --git a/docs/source/_static/images/labextension-manager.gif b/docs/source/_static/images/labextension-manager.gif new file mode 100644 index 00000000..6c64ecc5 Binary files /dev/null and b/docs/source/_static/images/labextension-manager.gif differ diff --git a/docs/source/_static/images/labextension-settings.png b/docs/source/_static/images/labextension-settings.png new file mode 100644 index 00000000..12069907 Binary files /dev/null and b/docs/source/_static/images/labextension-settings.png differ diff --git a/docs/source/api.rst b/docs/source/api.rst new file mode 100644 index 00000000..36e04545 --- /dev/null +++ b/docs/source/api.rst @@ -0,0 +1,8 @@ +.. _api: + +============= +REST API +============= + +.. openapi:: ../../jupyter_server_proxy/api.yml + :examples: diff --git a/docs/source/conf.py b/docs/source/conf.py index 97320dee..2a29182b 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -23,9 +23,10 @@ "sphinx_copybutton", "sphinxext.opengraph", "sphinxext.rediraffe", + "sphinxcontrib.openapi", ] root_doc = "index" -source_suffix = [".md"] +source_suffix = [".md", ".rst"] exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] diff --git a/docs/source/index.md b/docs/source/index.md index 1ece9d14..0f6ccdb7 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -30,6 +30,7 @@ install server-process launchers arbitrary-ports-hosts +api ``` ## Convenience packages for popular applications diff --git a/docs/source/launchers.md b/docs/source/launchers.md index 822efd89..9705a184 100644 --- a/docs/source/launchers.md +++ b/docs/source/launchers.md @@ -27,3 +27,36 @@ buttons in JupyterLab's Launcher panel for registered server processes. ``` Clicking on them opens the proxied application in a new browser window. + +Currently running proxy applications are shown in the running sessions tab +under "Server Proxy Apps" section. + +```{image} _static/images/labextension-manager.gif + +``` + +As shown in the GIF, users can consult the metadata of each running proxy +application by hovering over the name of the proxy. It is also possible to +terminate the proxy application using Shut Down button. + +```{note} +When the user clicks Shut Down button to terminate a proxy application, +a `SIGTERM` signal is sent to the application. It is the user's responsibility +to ensure that the application exits cleanly with a `SIGTERM` signal. There +are certain applications (like MLflow) that cannot be terminted with `SIGTERM` +signal and in those cases, the users can setup wrapper scripts to trap the +signal and ensure clean teardown of the application. +``` + +The lab extension manager will poll for running proxy applications at a +given interval which can be configured using Jupyter Server Proxy settings. +By default this is set to 10 seconds. Users can change +this interval by changing `Auto-refresh rate` in `Jupyter Server Proxy` +section in `Advanced Settings Editor` in JupyterLab UI. + +```{image} _static/images/labextension-settings.png + +``` + +Only proxy applications that are started by `jupyter-server-proxy` are shown +in the running Server Proxy Apps section. diff --git a/jupyter_server_proxy/__init__.py b/jupyter_server_proxy/__init__.py index bb3d0f00..c2a2cd0a 100644 --- a/jupyter_server_proxy/__init__.py +++ b/jupyter_server_proxy/__init__.py @@ -1,9 +1,10 @@ -from jupyter_server.utils import url_path_join as ujoin +import traitlets -from .api import IconHandler, ServersInfoHandler +from .api import setup_api_handlers from .config import ServerProxy as ServerProxyConfig from .config import get_entrypoint_server_processes, make_handlers, make_server_process from .handlers import setup_handlers +from .manager import ServerProxyAppManager # Jupyter Extension points @@ -38,14 +39,31 @@ def _jupyter_labextension_paths(): def _load_jupyter_server_extension(nbapp): # Set up handlers picked up via config base_url = nbapp.web_app.settings["base_url"] + + # Add server_proxy_manager trait to ServerApp and Instantiate a manager + nbapp.add_traits(server_proxy_manager=traitlets.Instance(ServerProxyAppManager)) + manager = nbapp.server_proxy_manager = ServerProxyAppManager() serverproxy_config = ServerProxyConfig(parent=nbapp) + # Add a long running background task that monitors the running proxies + try: + nbapp.io_loop.call_later( + serverproxy_config.monitor_interval, + manager.monitor, + serverproxy_config.monitor_interval, + ) + except AttributeError: + nbapp.log.debug( + "[jupyter-server-proxy] Server proxy manager is only supportted " + "for Notebook >= 7", + ) + server_processes = [ make_server_process(name, server_process_config, serverproxy_config) for name, server_process_config in serverproxy_config.servers.items() ] server_processes += get_entrypoint_server_processes(serverproxy_config) - server_handlers = make_handlers(base_url, server_processes) + server_handlers = make_handlers(base_url, manager, server_processes) nbapp.web_app.add_handlers(".*", server_handlers) # Set up default non-server handler @@ -54,21 +72,10 @@ def _load_jupyter_server_extension(nbapp): serverproxy_config, ) - icons = {} - for sp in server_processes: - if sp.launcher_entry.enabled and sp.launcher_entry.icon_path: - icons[sp.name] = sp.launcher_entry.icon_path - - nbapp.web_app.add_handlers( - ".*", - [ - ( - ujoin(base_url, "server-proxy/servers-info"), - ServersInfoHandler, - {"server_processes": server_processes}, - ), - (ujoin(base_url, "server-proxy/icon/(.*)"), IconHandler, {"icons": icons}), - ], + setup_api_handlers( + nbapp.web_app, + manager, + server_processes, ) nbapp.log.debug( diff --git a/jupyter_server_proxy/api.py b/jupyter_server_proxy/api.py index d183a53f..ebe7c44a 100644 --- a/jupyter_server_proxy/api.py +++ b/jupyter_server_proxy/api.py @@ -1,4 +1,4 @@ -import mimetypes +import json from jupyter_server.base.handlers import JupyterHandler from jupyter_server.utils import url_path_join as ujoin @@ -34,39 +34,94 @@ async def get(self): self.write({"server_processes": data}) -# FIXME: Should be a StaticFileHandler subclass -class IconHandler(JupyterHandler): - """ - Serve launcher icons - """ +# IconHandler has been copied from JupyterHub's IconHandler: +# https://github.com/jupyterhub/jupyterhub/blob/4.0.0b2/jupyterhub/handlers/static.py#L22-L31 +class ServersIconHandler(web.StaticFileHandler): + """A singular handler for serving the icon.""" - def initialize(self, icons): - """ - icons is a dict of titles to paths - """ - self.icons = icons + def get(self): + return super().get("") + @classmethod + def get_absolute_path(cls, root, path): + """We only serve one file, ignore relative path""" + import os + + return os.path.abspath(root) + + +class ServersAPIHandler(JupyterHandler): + """Handler to get metadata or terminate of a given server or all servers""" + + def initialize(self, manager): + self.manager = manager + + @web.authenticated + async def delete(self, name): + """Delete a server proxy by name""" + if not name: + raise web.HTTPError( + 403, + "Please set the name of a running server proxy that " + "user wishes to terminate", + ) + + try: + val = await self.manager.terminate_server_proxy_app(name) + if val is None: + raise Exception( + f"Proxy {name} not found. Are you sure the {name} " + f"is managed by jupyter-server-proxy?" + ) + else: + self.set_status(204) + self.finish() + except Exception as e: + raise web.HTTPError(404, str(e)) + + @web.authenticated async def get(self, name): - if name not in self.icons: - raise web.HTTPError(404) - path = self.icons[name] - - # Guess mimetype appropriately - # Stolen from https://github.com/tornadoweb/tornado/blob/b399a9d19c45951e4561e6e580d7e8cf396ef9ff/tornado/web.py#L2881 - mime_type, encoding = mimetypes.guess_type(path) - if encoding == "gzip": - content_type = "application/gzip" - # As of 2015-07-21 there is no bzip2 encoding defined at - # http://www.iana.org/assignments/media-types/media-types.xhtml - # So for that (and any other encoding), use octet-stream. - elif encoding is not None: - content_type = "application/octet-stream" - elif mime_type is not None: - content_type = mime_type - # if mime_type not detected, use application/octet-stream + """Get meta data of a running server proxy""" + if name: + apps = self.manager.get_server_proxy_app(name)._asdict() + # If no server proxy found this will be a dict with empty values + if not apps["name"]: + raise web.HTTPError(404, f"Server proxy {name} not found") else: - content_type = "application/octet-stream" + apps = [app._asdict() for app in self.manager.list_server_proxy_apps()] + + self.set_status(200) + self.finish(json.dumps(apps)) + + +def setup_api_handlers(web_app, manager, server_processes): + base_url = web_app.settings["base_url"] + + # Make a list of icon handlers + icon_handlers = [] + for sp in server_processes: + if sp.launcher_entry.enabled and sp.launcher_entry.icon_path: + icon_handlers.append( + ( + ujoin(base_url, f"server-proxy/icon/{sp.name}"), + ServersIconHandler, + {"path": sp.launcher_entry.icon_path}, + ) + ) - with open(self.icons[name]) as f: - self.write(f.read()) - self.set_header("Content-Type", content_type) + web_app.add_handlers( + ".*", + [ + ( + ujoin(base_url, "server-proxy/api/servers-info"), + ServersInfoHandler, + {"server_processes": server_processes}, + ), + ( + ujoin(base_url, r"server-proxy/api/servers/(?P.*)"), + ServersAPIHandler, + {"manager": manager}, + ), + ] + + icon_handlers, + ) diff --git a/jupyter_server_proxy/api.yml b/jupyter_server_proxy/api.yml new file mode 100644 index 00000000..75a6a1bc --- /dev/null +++ b/jupyter_server_proxy/api.yml @@ -0,0 +1,126 @@ +openapi: "3.1.0" +info: + title: Jupyter Server Proxy + description: The REST API for Jupyter Server Proxy Package + version: 4.0.0 + license: + name: BSD-3-Clause + +paths: + /server-proxy/api/servers-info: + get: + summary: Get List of Server Proxy Entrypoints + description: | + Gets the list of server proxy entrypoints and their launcher meta data + responses: + "200": + description: Server Proxy Entrypoints + content: + application/json: + schema: + properties: + server_processes: + type: array + description: list of server proxy entrypoints + items: + $ref: "#/components/schemas/ListEntryPoints" + + /server-proxy/icon/{server_name}: + get: + summary: Get Icon of Server Proxy Application + description: | + Gets the icon of server proxy application + responses: + "200": + description: Server Proxy Application's Icon + content: + image/*: + schema: + type: string + description: encoded string of server proxy application's icon image + format: binary + + /server-proxy/api/servers/: + get: + summary: Get Currently Running Server Proxy Applications + description: | + Gets the list of all running server proxy applications along with their metadata + responses: + "200": + description: Active Server Proxy Applications + content: + application/json: + schema: + type: array + description: list of running server proxy applications and their metadata + items: + $ref: "#/components/schemas/Server" + + /server-proxy/api/servers/{server_name}: + parameters: + - name: server_name + description: Server Proxy Application Name + in: path + required: true + schema: + type: string + get: + summary: Get Metadata of a given Server Proxy Application + description: | + Gets metadata for a given server proxy application + responses: + "200": + description: Metadata of Server Proxy Application + content: + application/json: + schema: + $ref: "#/components/schemas/Server" + "404": + description: Server proxy application not found + delete: + summary: Delete a Managed Server Proxy Application + description: | + Terminates and deletes a given managed server proxy application + responses: + "204": + description: Succesfully terminated server proxy application + "403": + description: Forbidden. No server proxy application name set + "404": + description: Server proxy application not found + +components: + schemas: + ListEntryPoints: + type: object + properties: + name: + type: string + launcher_entry: + type: object + properties: + enabled: + type: string + title: + type: string + path_info: + type: string + icon_url: + type: string + new_browser_tab: + type: string + Server: + type: object + properties: + name: + type: string + url: + type: string + cmd: + type: string + port: + type: string + managed: + type: string + unix_socket: + type: string diff --git a/jupyter_server_proxy/config.py b/jupyter_server_proxy/config.py index f0c1348f..b8b1178b 100644 --- a/jupyter_server_proxy/config.py +++ b/jupyter_server_proxy/config.py @@ -11,7 +11,7 @@ from importlib.metadata import entry_points from jupyter_server.utils import url_path_join as ujoin -from traitlets import Dict, List, Tuple, Union, default, observe +from traitlets import Dict, Int, List, Tuple, Union, default, observe from traitlets.config import Configurable from .handlers import AddSlashHandler, NamedLocalProxyHandler, SuperviseAndProxyHandler @@ -102,7 +102,7 @@ def get_entrypoint_server_processes(serverproxy_config): return sps -def make_handlers(base_url, server_processes): +def make_handlers(base_url, manager, server_processes): """ Get tornado handlers for registered server_processes """ @@ -110,7 +110,7 @@ def make_handlers(base_url, server_processes): for sp in server_processes: if sp.command: handler = _make_supervisedproxy_handler(sp) - kwargs = dict(state={}) + kwargs = dict(state={}, manager=manager) else: if not (sp.port or isinstance(sp.unix_socket, str)): warn( @@ -341,3 +341,13 @@ def _host_whitelist_deprecated(self, change): ) ) self.host_allowlist = change.new + + monitor_interval = Int( + 10, + help=""" + Proxy polling interval in seconds. The server proxy manager will keep + polling the status of running servers with a frequency set by this + interval. + """, + config=True, + ) diff --git a/jupyter_server_proxy/handlers.py b/jupyter_server_proxy/handlers.py index a253e1b5..79e3c426 100644 --- a/jupyter_server_proxy/handlers.py +++ b/jupyter_server_proxy/handlers.py @@ -711,8 +711,9 @@ def __init__(self, *args, **kwargs): self.command = list() super().__init__(*args, **kwargs) - def initialize(self, state): + def initialize(self, state, manager): self.state = state + self.manager = manager if "proc_lock" not in state: state["proc_lock"] = Lock() @@ -800,9 +801,16 @@ async def ensure_process(self): # Invariant here should be: when lock isn't being held, either 'proc' is in state & # running, or not. async with self.state["proc_lock"]: + # If the server process is terminated via Runningsessions or killed + # outside of jsp, we should be able to restart the process. If + # process is not in running state, remove proc object and restart + # the process + if "proc" in self.state: + if not self.state["proc"].running: + del self.state["proc"] + if "proc" not in self.state: # FIXME: Prevent races here - # FIXME: Handle graceful exits of spawned processes here # When command option isn't truthy, it means its a process not # to be managed/started by jupyter-server-proxy. This means we @@ -836,6 +844,11 @@ async def ensure_process(self): if not is_ready: await proc.kill() raise web.HTTPError(500, f"could not start {self.name} in time") + + # If process started succesfully, add it to manager + self.manager.add_server_proxy_app( + self.name, self.base_url, cmd, self.port, proc, self.unix_socket + ) except: # Make sure we remove proc from state in any error condition del self.state["proc"] diff --git a/jupyter_server_proxy/manager.py b/jupyter_server_proxy/manager.py new file mode 100644 index 00000000..43dc3068 --- /dev/null +++ b/jupyter_server_proxy/manager.py @@ -0,0 +1,132 @@ +"""Manager for jupyter server proxy""" + +import asyncio +from collections import namedtuple + +from jupyter_server.utils import url_path_join as ujoin +from traitlets import Int, List +from traitlets.config import LoggingConfigurable + +ServerProxy = namedtuple( + "ServerProxy", + ["name", "url", "cmd", "port", "managed", "unix_socket"], + defaults=[""] * 6, +) +ServerProxyProc = namedtuple("ServerProxyProc", ["name", "proc"], defaults=[""] * 2) + + +class ServerProxyAppManager(LoggingConfigurable): + """ + A class for listing and stopping server proxies that are started + by jupyter server proxy. + """ + + server_proxy_apps = List(help="List of server proxy apps") + + _server_proxy_procs = List(help="List of server proxy app proc objects") + + num_active_server_proxy_apps = Int( + 0, help="Total number of currently running proxy apps" + ) + + def add_server_proxy_app(self, name, base_url, cmd, port, proc, unix_socket): + """Add a launched proxy server to list""" + self.num_active_server_proxy_apps += 1 + + # Add proxy server metadata + self.server_proxy_apps.append( + ServerProxy( + name=name, + url=ujoin(base_url, name), + cmd=" ".join(cmd), + port=port, + managed=True if proc else False, + unix_socket=unix_socket if unix_socket is not None else "", + ) + ) + + # Add proxy server proc object so that we can send SIGTERM + # when user chooses to shut it down + self._server_proxy_procs.append( + ServerProxyProc( + name=name, + proc=proc, + ) + ) + self.log.debug("Server proxy %s added to server proxy manager" % name) + + def del_server_proxy_app(self, name): + """Remove a launched proxy server from list""" + self.server_proxy_apps = [ + app for app in self.server_proxy_apps if app.name != name + ] + self._server_proxy_procs = [ + app for app in self._server_proxy_procs if app.name != name + ] + self.num_active_server_proxy_apps = len(self.server_proxy_apps) + + def get_server_proxy_app(self, name): + """Get a given server proxy app""" + return next( + (app for app in self.server_proxy_apps if app.name == name), ServerProxy() + ) + + def _get_server_proxy_proc(self, name): + """Get a given server proxy app""" + return next( + (app for app in self._server_proxy_procs if app.name == name), + ServerProxyProc(), + ) + + def list_server_proxy_apps(self): + """List all active server proxy apps""" + return self.server_proxy_apps + + def _list_server_proxy_procs(self): + """List all active server proxy proc objs""" + return self._server_proxy_procs + + async def terminate_server_proxy_app(self, name): + """Terminate a server proxy by sending SIGTERM""" + app = self._get_server_proxy_proc(name) + try: + # Here we send SIGTERM signal to terminate proxy app + # graciously so we can restart it if needed. Note that + # some servers may not get stopped by sending SIGTERM + # signal (example is mlflow server). In this case, it is + # user's responsibility to write wrapper scripts around + # proxy app's executable to terminate them cleanly using + # TERM signal. It is also important to set exit code to 0 + # when using such wrappers when proxy apps shutdown. + await app.proc.terminate() + + # Remove proxy app from list + self.del_server_proxy_app(name) + + self.log.debug("Server proxy %s removed from server proxy manager" % name) + + return True + except (KeyError, AttributeError): + self.log.warning("Server proxy %s not found in server proxy manager" % name) + return None + + async def terminate_all(self): + """Close all server proxy and cleanup""" + for app in self.server_proxy_apps: + await self.terminate_server_proxy_app(app) + + async def monitor(self, monitor_interval): + while True: + procs = self._list_server_proxy_procs() + + # Check if processes are running + for proc in procs: + running = proc.proc.running + if not running: + self.log.warning( + "Server proxy %s is not running anymore. " + "Removing from server proxy manager" % proc.name + ) + self.del_server_proxy_app(proc.name) + + await asyncio.sleep(monitor_interval) diff --git a/jupyter_server_proxy/static/tree.js b/jupyter_server_proxy/static/tree.js index 41725aba..1252e3e3 100644 --- a/jupyter_server_proxy/static/tree.js +++ b/jupyter_server_proxy/static/tree.js @@ -12,7 +12,7 @@ define(["jquery", "base/js/namespace", "base/js/utils"], function ( function load() { if (!Jupyter.notebook_list) return; - var servers_info_url = base_url + "server-proxy/servers-info"; + var servers_info_url = base_url + "server-proxy/api/servers-info"; $.get(servers_info_url, function (data) { /* locate the right-side dropdown menu of apps and notebooks */ var $menu = $(".tree-buttons").find(".dropdown-menu"); diff --git a/labextension/package.json b/labextension/package.json index 331bc37a..efbcf7b5 100644 --- a/labextension/package.json +++ b/labextension/package.json @@ -18,7 +18,7 @@ }, "files": [ "LICENSE", - "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}" + "{lib,style}/**/*.{d.ts,css,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}" ], "main": "lib/index.js", "types": "lib/index.d.ts", @@ -44,13 +44,11 @@ }, "dependencies": { "@jupyterlab/application": "^2.0 || ^3.0", - "@jupyterlab/launcher": "^2.0 || ^3.0" - }, - "resolutions": { - "loader-utils": ">=2.0.3" + "@jupyterlab/launcher": "^2.0 || ^3.0", + "@jupyterlab/running": "^2.0 || ^3.0" }, "devDependencies": { - "@jupyterlab/builder": "^3.2.4", + "@jupyterlab/builder": "^3.6.3", "rimraf": "^3.0.2", "typescript": "~4.8.4", "yarn-deduplicate": "^6.0.0", @@ -58,6 +56,7 @@ }, "jupyterlab": { "extension": true, + "schemaDir": "schema", "outputDir": "../jupyter_server_proxy/labextension" } } diff --git a/labextension/schema/add-launcher-entries.json b/labextension/schema/add-launcher-entries.json new file mode 100644 index 00000000..36b9ab7b --- /dev/null +++ b/labextension/schema/add-launcher-entries.json @@ -0,0 +1,14 @@ +{ + "title": "Jupyter Server Proxy", + "description": "Jupyter Server Proxy Manager settings", + "properties": { + "refreshInterval": { + "type": "number", + "title": "Auto-refresh rate", + "description": "Time to wait (in ms) in between auto refreshes of server proxy applications", + "default": 10000 + } + }, + "additionalProperties": false, + "type": "object" +} diff --git a/labextension/src/index.ts b/labextension/src/index.ts index d90cce9a..750a832c 100644 --- a/labextension/src/index.ts +++ b/labextension/src/index.ts @@ -4,8 +4,14 @@ import { ILayoutRestorer, } from "@jupyterlab/application"; import { ILauncher } from "@jupyterlab/launcher"; -import { PageConfig } from "@jupyterlab/coreutils"; +import { PageConfig, URLExt } from "@jupyterlab/coreutils"; +import { IRunningSessionManagers } from "@jupyterlab/running"; +import { ISettingRegistry } from "@jupyterlab/settingregistry"; +import { ITranslator, TranslationBundle } from "@jupyterlab/translation"; import { IFrame, MainAreaWidget, WidgetTracker } from "@jupyterlab/apputils"; +import { ServerProxyManager } from "./manager"; +import { IModel as IServerProxyModel } from "./serverproxy"; +import { RunningServerProxyApp, CommandIDs } from "./running"; function newServerProxyWidget( id: string, @@ -32,6 +38,33 @@ function newServerProxyWidget( return widget; } +/** + * This function adds the active server proxy applications to running sessions + * so that user can track currently running applications via server proxy. + * User can shut down the applications as well to restart them in future + * + */ +function addRunningSessionManager( + managers: IRunningSessionManagers, + app: JupyterFrontEnd, + manager: ServerProxyManager, + trans: TranslationBundle, +): void { + managers.add({ + name: "Server Proxy Apps", + running: () => + Array.from(manager.running()).map( + (model) => new RunningServerProxyApp(model, manager, app), + ), + shutdownAll: () => manager.shutdownAll(), + refreshRunning: () => manager.refreshRunning(), + runningChanged: manager.runningChanged, + shutdownAllConfirmationText: trans.__( + "Are you sure you want to close all server proxy applications?", + ), + }); +} + /** * The activate function is registered to be called on activation of the * jupyterlab extension. @@ -42,20 +75,30 @@ async function activate( app: JupyterFrontEnd, launcher: ILauncher, restorer: ILayoutRestorer, + settingRegistry: ISettingRegistry, + translator: ITranslator, + sessions: IRunningSessionManagers | null, ): Promise { + const trans = translator.load("jupyter-server-proxy"); + // Fetch configured server processes from {base_url}/server-proxy/servers-info const response = await fetch( - PageConfig.getBaseUrl() + "server-proxy/servers-info", + URLExt.join(PageConfig.getBaseUrl(), "server-proxy/api/servers-info"), ); if (!response.ok) { console.log( - "Could not fetch metadata about registered servers. Make sure jupyter-server-proxy is installed.", + trans.__( + "Could not fetch metadata about registered servers. Make sure jupyter-server-proxy is installed.", + ), ); console.log(response); return; } const data = await response.json(); + // Load application settings + const settings = await settingRegistry.load(extension.id); + const namespace = "server-proxy"; const tracker = new WidgetTracker>({ namespace, @@ -76,6 +119,13 @@ async function activate( } const { commands, shell } = app; + + // Add server proxy session manager to running sessions + if (sessions) { + let manager = new ServerProxyManager({ trans, settings }); + addRunningSessionManager(sessions, app, manager, trans); + } + commands.addCommand(command, { label: (args) => args["title"] as string, execute: (args) => { @@ -105,13 +155,24 @@ async function activate( }, }); + commands.addCommand(CommandIDs.open, { + execute: (args) => { + const model = args["sp"] as IServerProxyModel; + const url = URLExt.join(PageConfig.getBaseUrl(), model.url); + window.open(url, "_blank"); + return; + }, + }); + for (let server_process of data.server_processes) { if (!server_process.launcher_entry.enabled) { continue; } - const url = - PageConfig.getBaseUrl() + server_process.launcher_entry.path_info; + const url = URLExt.join( + PageConfig.getBaseUrl(), + server_process.launcher_entry.path_info, + ); const title = server_process.launcher_entry.title; const newBrowserTab = server_process.new_browser_tab; const id = namespace + ":" + server_process.name; @@ -142,7 +203,8 @@ async function activate( const extension: JupyterFrontEndPlugin = { id: "@jupyterhub/jupyter-server-proxy:add-launcher-entries", autoStart: true, - requires: [ILauncher, ILayoutRestorer], + requires: [ILauncher, ILayoutRestorer, ISettingRegistry, ITranslator], + optional: [IRunningSessionManagers], activate: activate, }; diff --git a/labextension/src/manager.ts b/labextension/src/manager.ts new file mode 100644 index 00000000..7644e86a --- /dev/null +++ b/labextension/src/manager.ts @@ -0,0 +1,226 @@ +import { Signal, ISignal } from "@lumino/signaling"; +import { Poll } from "@lumino/polling"; +import { ISettingRegistry } from "@jupyterlab/settingregistry"; +import { ServerConnection, BaseManager } from "@jupyterlab/services"; +import { TranslationBundle } from "@jupyterlab/translation"; +import { listRunning, shutdown } from "./restapi"; +import * as ServerProxyApp from "./serverproxy"; + +// Default refresh interval (in milliseconds) for polling +const DEFAULT_REFRESH_INTERVAL = 10000; // ms + +/** + * A server proxy manager. + */ +export class ServerProxyManager extends BaseManager implements ServerProxyApp.IManager { + /** + * Construct a new server proxy manager. + */ + constructor(options: ServerProxyManager.IOptions) { + super(options); + this._trans = options.trans; + this._settings = options.settings || null; + + const interval = + (this._settings?.get("refreshInterval").composite as number) || + DEFAULT_REFRESH_INTERVAL; + + // Start polling with exponential backoff. + this._proxyPoll = new Poll({ + auto: false, + factory: () => this.requestRunning(), + frequency: { + interval: interval, + backoff: true, + max: 300 * 1000, + }, + name: `@jupyterhub/jupyter-server-proxy:Manager#models`, + standby: options.standby ?? "when-hidden", + }); + + // Initialize internal data. + this._ready = (async () => { + await this!._proxyPoll.start(); + await this!._proxyPoll.tick; + this._isReady = true; + })(); + + // Fire callback when settings are changed + if (this._settings) { + this._settings?.changed.connect(this._onSettingsChange, this); + this._onSettingsChange(this._settings); + } + } + + /** + * Test whether the manager is ready. + */ + get isReady(): boolean { + return this._isReady; + } + + /** + * A promise that fulfills when the manager is ready. + */ + get ready(): Promise { + return this._ready; + } + + /** + * A signal emitted when the running server proxies change. + */ + get runningChanged(): ISignal { + return this._runningChanged; + } + + /** + * A signal emitted when there is a connection failure. + */ + get connectionFailure(): ISignal { + return this._connectionFailure; + } + + /** + * Dispose of the resources used by the manager. + */ + dispose(): void { + if (this.isDisposed) { + return; + } + this._proxyPoll.dispose(); + this._settings?.changed.disconnect(this._onSettingsChange, this); + super.dispose(); + } + + /** + * Create an iterator over the most recent running proxy apps. + * + * @returns A new iterator over the running proxy apps. + */ + running(): IterableIterator { + return this._models[Symbol.iterator](); + } + + /** + * Shut down a server proxy app by name. + */ + async shutdown(name: string): Promise { + await shutdown(name, this._trans, this.serverSettings); + await this.refreshRunning(); + } + + /** + * Shut down all server proxy apps. + * + * @returns A promise that resolves when all of the apps are shut down. + */ + async shutdownAll(): Promise { + // Update the list of models to make sure our list is current. + await this.refreshRunning(); + + // Shut down all models. + await Promise.all( + this._names.map((name) => + shutdown(name, this._trans, this.serverSettings) + ) + ); + + // Update the list of models to clear out our state. + await this.refreshRunning(); + } + + /** + * Force a refresh of the running server proxy apps. + * + * @returns A promise that with the list of running proxy apps. + */ + async refreshRunning(): Promise { + await this._proxyPoll.refresh(); + await this._proxyPoll.tick; + } + + /** + * Refresh the running proxy apps. + */ + protected async requestRunning(): Promise { + let models: ServerProxyApp.IModel[]; + try { + models = await listRunning(this.serverSettings); + } catch (err: any) { + // Handle network errors, as well as cases where we are on a + // JupyterHub and the server is not running. JupyterHub returns a + // 503 (<2.0) or 424 (>2.0) in that case. + if ( + err instanceof ServerConnection.NetworkError || + err.response?.status === 503 || + err.response?.status === 424 + ) { + this._connectionFailure.emit(err); + } + throw err; + } + + if (this.isDisposed) { + return; + } + + const names = models.map(({ name }) => name).sort(); + if (names === this._names) { + // Identical models list, so just return + return; + } + + this._names = names; + this._models = models; + this._runningChanged.emit(this._models); + } + + /** + * Callback invoked upon a change to plugin settings. + * + * @private + * @param settings - plugin settings + */ + private _onSettingsChange(settings: ISettingRegistry.ISettings) { + this._proxyPoll.frequency = { + ...this._proxyPoll.frequency, + interval: settings.composite.refreshInterval as number, + }; + } + + private _isReady = false; + + private _names: string[] = []; + private _models: ServerProxyApp.IModel[] = []; + private _proxyPoll: Poll; + private _trans: TranslationBundle; + private _settings: ISettingRegistry.ISettings | null; + private _ready: Promise; + private _runningChanged = new Signal(this); + private _connectionFailure = new Signal(this); +} + +/** + * The namespace for `ServerProxyManager` class statics. + */ +export namespace ServerProxyManager { + /** + * The options used to initialize a ServerProxyManager. + */ + export interface IOptions extends BaseManager.IOptions { + /** + * Translation Bundle. + */ + trans: TranslationBundle; + + /** + * The currently loaded settings. + */ + settings?: ISettingRegistry.ISettings; + + /** + * When the manager stops polling the API. Defaults to `when-hidden`. + */ + standby?: Poll.Standby | (() => boolean | Poll.Standby); + } +} diff --git a/labextension/src/restapi.ts b/labextension/src/restapi.ts new file mode 100644 index 00000000..b4ad57bd --- /dev/null +++ b/labextension/src/restapi.ts @@ -0,0 +1,73 @@ +import { URLExt } from "@jupyterlab/coreutils"; +import { showDialog, Dialog } from "@jupyterlab/apputils"; +import { ServerConnection } from "@jupyterlab/services"; +import { TranslationBundle } from "@jupyterlab/translation"; +import { IModel } from "./serverproxy"; + +/** + * The url for the server proxy service. + */ +const SERVER_PROXY_SERVICE_URL = "server-proxy/api/servers/"; + +/** + * List the running server proxy apps. + * + * @param settings - The server settings to use. + * + * @returns A promise that resolves with the list of running session models. + */ +export async function listRunning( + settings: ServerConnection.ISettings, +): Promise { + const url = URLExt.join(settings.baseUrl, SERVER_PROXY_SERVICE_URL); + const response = await ServerConnection.makeRequest(url, {}, settings); + if (response.status !== 200) { + const err = await ServerConnection.ResponseError.create(response); + throw err; + } + const data = await response.json(); + + if (!Array.isArray(data)) { + throw new Error("Invalid server proxy list"); + } + + return data; +} + +/** + * Shut down a server proxy app by name. + * + * @param name - The name of the target server proxy app. + * + * @param settings - The server settings to use. + * + * @returns A promise that resolves when the app is shut down. + */ +export async function shutdown( + name: string, + trans: TranslationBundle, + settings: ServerConnection.ISettings, +): Promise { + const url = URLExt.join(settings.baseUrl, SERVER_PROXY_SERVICE_URL, name); + const init = { method: "DELETE" }; + const response = await ServerConnection.makeRequest(url, init, settings); + if (response.status === 404) { + const msg = trans.__( + `Server proxy "${name}" is not running anymore. It will be removed from this list shortly`, + ); + console.warn(msg); + void showDialog({ + title: trans.__("Warning"), + body: msg, + buttons: [Dialog.okButton({ label: "Dismiss" })], + }); + } else if (response.status === 403) { + // This request cannot be made via JupyterLab UI and hence we just throw + // console log + const msg = trans.__(`Provide a running server proxy name to terminate`); + console.warn(msg); + } else if (response.status !== 204) { + const err = await ServerConnection.ResponseError.create(response); + throw err; + } +} diff --git a/labextension/src/running.ts b/labextension/src/running.ts new file mode 100644 index 00000000..f452879f --- /dev/null +++ b/labextension/src/running.ts @@ -0,0 +1,46 @@ +import { JupyterFrontEnd } from "@jupyterlab/application"; +import { IRunningSessions } from "@jupyterlab/running"; +import { LabIcon } from "@jupyterlab/ui-components"; +import { ServerProxyManager } from "./manager"; +import { IModel as IServerProxyModel } from "./serverproxy"; +import serverProxyAppSvgstr from "../style/icons/proxy.svg"; + +export const ServerProxyAppIcon = new LabIcon({ + name: "server-proxy:proxyAppIcon", + svgstr: serverProxyAppSvgstr, +}); + +export namespace CommandIDs { + export const open = "server-proxy:refresh"; +} + +export class RunningServerProxyApp implements IRunningSessions.IRunningItem { + constructor( + model: IServerProxyModel, + manager: ServerProxyManager, + app: JupyterFrontEnd, + ) { + this._model = model; + this._manager = manager; + this._app = app; + } + open(): void { + this._app.commands.execute(CommandIDs.open, { sp: this._model }); + } + icon(): LabIcon { + return ServerProxyAppIcon; + } + label(): string { + return `${this._model.name}`; + } + labelTitle(): string { + return `cmd: ${this._model.cmd}\nport: ${this._model.port}\nunix_socket: ${this._model.unix_socket}\nmanaged: ${this._model.managed}`; + } + shutdown(): Promise { + return this._manager.shutdown(this._model.name); + } + + private _model: IServerProxyModel; + private _manager: ServerProxyManager; + private _app: JupyterFrontEnd; +} diff --git a/labextension/src/serverproxy.ts b/labextension/src/serverproxy.ts new file mode 100644 index 00000000..3bc2f13b --- /dev/null +++ b/labextension/src/serverproxy.ts @@ -0,0 +1,85 @@ +import { JSONObject } from "@lumino/coreutils"; +import { ISignal } from "@lumino/signaling"; +import { IManager as IBaseManager } from "@jupyterlab/services"; + +/** + * The server model for a proxy. + */ +export interface IModel extends JSONObject { + /** + * The name of the proxy app. + */ + readonly name: string; + + /** + * The cmd used to launch proxy app. + */ + readonly cmd: string; + + /** + * The port at which proxy app is running. Port 0 means unix socket. + */ + readonly port: string; + + /** + * The url endpoint of the proxy app. + */ + readonly url: string; + + /** + * Proxy app managed by jupyter-server-proxy or not. + */ + readonly managed: boolean; + + /** + * Proxy app managed by jupyter-server-proxy or not. + */ + readonly unix_socket: string; +} + +/** + * The interface for a server proxy manager. + * + * The manager is responsible for maintaining the state of running + * server proxy apps. + */ +export interface IManager extends IBaseManager { + /** + * A signal emitted when the running server proxy apps change. + */ + runningChanged: ISignal; + + /** + * Create an iterator over the known server proxy apps. + * + * @returns A new iterator over the server proxy apps. + */ + running(): IterableIterator; + + /** + * Shut down a proxy app by name. + * + * @param name - The name of the proxy app. + * + * @returns A promise that resolves when the app is shut down. + */ + shutdown(name: string): Promise; + + /** + * Shut down all proxy apps. + * + * @returns A promise that resolves when all of the apps are shut down. + */ + shutdownAll(): Promise; + + /** + * Force a refresh of the running proxy apps. + * + * @returns A promise that with the list of running proxy apps. + * + * #### Notes + * This is not typically meant to be called by the user, since the + * manager maintains its own internal state. + */ + refreshRunning(): Promise; +} diff --git a/labextension/src/svg.d.ts b/labextension/src/svg.d.ts new file mode 100644 index 00000000..070d6622 --- /dev/null +++ b/labextension/src/svg.d.ts @@ -0,0 +1,4 @@ +declare module "*.svg" { + const image: string; + export default image; +} diff --git a/labextension/style/icons/proxy.svg b/labextension/style/icons/proxy.svg new file mode 100644 index 00000000..70bc0aaa --- /dev/null +++ b/labextension/style/icons/proxy.svg @@ -0,0 +1,22 @@ + +Created with Fabric.js 1.7.22 + + + + + + diff --git a/labextension/yarn.lock b/labextension/yarn.lock index 0dc4c484..d08cae0e 100644 --- a/labextension/yarn.lock +++ b/labextension/yarn.lock @@ -10,9 +10,9 @@ regenerator-runtime "^0.13.11" "@blueprintjs/colors@^4.0.0-alpha.3": - version "4.1.22" - resolved "https://registry.npmjs.org/@blueprintjs/colors/-/colors-4.1.22.tgz#033cbf03705100d5114d54161c225eec5cfeacfb" - integrity sha512-qcC7nWW9TTSS7aDxE5gbo9vrxo+IOpC6/Kzpi0rdOBYFDd02PppCdnCCjGYw1/IopSsZ9EWqDLmD7zuy0H+WEA== + version "4.1.21" + resolved "https://registry.npmjs.org/@blueprintjs/colors/-/colors-4.1.21.tgz#622c56ac7f9af466680eafcdbaf26e5c9152ad3b" + integrity sha512-5csitaTn1xyHktMRyXAcvWzsbrgtP9pK7ZmYX9f0TGjB1UG5zNaTGLexX0aFqop44SpfsSP5mbA8xGBniy8nZA== "@blueprintjs/core@^3.36.0", "@blueprintjs/core@^3.54.0": version "3.54.0" @@ -189,7 +189,7 @@ sanitize-html "~2.7.3" url "^0.11.0" -"@jupyterlab/builder@^3.2.4": +"@jupyterlab/builder@^3.6.3": version "3.6.3" resolved "https://registry.npmjs.org/@jupyterlab/builder/-/builder-3.6.3.tgz#a4b22efe34e9598b84122ff10509d3d890017b6a" integrity sha512-oY1a/r75RMoPzhSmuVu+DfjL0cKk1ceHTniZsM2wPuhjjyoF875u6CDzArJatpOOuTgLm7CY5OcU3LCIK1OAgg== @@ -389,6 +389,19 @@ lodash.escape "^4.0.1" marked "^4.0.17" +"@jupyterlab/running@^2.0 || ^3.0": + version "3.6.3" + resolved "https://registry.npmjs.org/@jupyterlab/running/-/running-3.6.3.tgz#a4a5e2510d1b38321a58aefd20b68418c8373178" + integrity sha512-KRHLCM7Qng/QSswuw5d2kTRqxDmFQX/SAsJrAZk18m7cHituZUtoMOmU74mgguGnryrynbuQZFgAVOg74NRQwQ== + dependencies: + "@jupyterlab/apputils" "^3.6.3" + "@jupyterlab/translation" "^3.6.3" + "@jupyterlab/ui-components" "^3.6.3" + "@lumino/coreutils" "^1.11.0" + "@lumino/disposable" "^1.10.0" + "@lumino/signaling" "^1.10.0" + react "^17.0.1" + "@jupyterlab/services@^6.6.3": version "6.6.3" resolved "https://registry.npmjs.org/@jupyterlab/services/-/services-6.6.3.tgz#303938e5dc5aebce7a86324a64ed89c25c61c9e7" @@ -678,7 +691,12 @@ "@types/estree" "*" "@types/json-schema" "*" -"@types/estree@*", "@types/estree@^1.0.0": +"@types/estree@*": + version "1.0.0" + resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.0.tgz#5fb2e536c1ae9bf35366eed879e827fa59ca41c2" + integrity sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ== + +"@types/estree@^1.0.0": version "1.0.1" resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz#aa22750962f3bf0e79d753d3cc067f010c95f194" integrity sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA== @@ -689,9 +707,9 @@ integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ== "@types/node@*": - version "18.15.12" - resolved "https://registry.npmjs.org/@types/node/-/node-18.15.12.tgz#833756634e78c829e1254db006468dadbb0c696b" - integrity sha512-Wha1UwsB3CYdqUm2PPzh/1gujGCNtWVUYF0mB00fJFoR4gTyWTDPjSm+zBF787Ahw8vSGgBja90MkgFwvB86Dg== + version "18.15.11" + resolved "https://registry.npmjs.org/@types/node/-/node-18.15.11.tgz#b3b790f09cb1696cffcec605de025b088fa4225f" + integrity sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q== "@types/prop-types@*": version "15.7.5" @@ -699,9 +717,9 @@ integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w== "@types/react@^17.0.0": - version "17.0.58" - resolved "https://registry.npmjs.org/@types/react/-/react-17.0.58.tgz#c8bbc82114e5c29001548ebe8ed6c4ba4d3c9fb0" - integrity sha512-c1GzVY97P0fGxwGxhYq989j4XwlcHQoto6wQISOC2v6wm3h0PORRWJFHlkRjfGsiG3y1609WdQ+J+tKxvrEd6A== + version "17.0.57" + resolved "https://registry.npmjs.org/@types/react/-/react-17.0.57.tgz#341152f222222075caf020ae6e0e68b9b835404c" + integrity sha512-e4msYpu5QDxzNrXDHunU/VPyv2M1XemGG/p7kfCjUiPtlLDCWLGQfgAMng6YyisWYxZ09mYdQlmMnyS0NfZdEg== dependencies: "@types/prop-types" "*" "@types/scheduler" "*" @@ -979,6 +997,11 @@ base64-js@^1.3.1: resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== +big.js@^5.2.2: + version "5.2.2" + resolved "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" + integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -1043,9 +1066,9 @@ call-bind@^1.0.0, call-bind@^1.0.2: get-intrinsic "^1.0.2" caniuse-lite@^1.0.30001449: - version "1.0.30001480" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001480.tgz#9bbd35ee44c2480a1e3a3b9f4496f5066817164a" - integrity sha512-q7cpoPPvZYgtyC4VaBSN0Bt+PJ4c4EYRf0DrduInOz2SkFpHD5p3LnvEpqBp7UnJn+8x1Ogl1s38saUxe+ihQQ== + version "1.0.30001478" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001478.tgz#0ef8a1cf8b16be47a0f9fc4ecfc952232724b32a" + integrity sha512-gMhDyXGItTHipJj2ApIvR+iVB5hd0KP3svMWWXDvZOmjzJJassGLMfxRkQCSYgGd2gtdL/ReeiyvMSFD1Ss6Mw== chalk@^2.3.0, chalk@^2.4.1: version "2.4.2" @@ -1103,9 +1126,9 @@ color-name@1.1.3: integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== colorette@^2.0.14: - version "2.0.20" - resolved "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" - integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== + version "2.0.19" + resolved "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz#cdf044f47ad41a0f4b56b3a0d5b4e6e1a2d5a798" + integrity sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ== commander@^2.20.0: version "2.20.3" @@ -1157,9 +1180,9 @@ concat-map@0.0.1: integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== core-js-pure@^3.6.5: - version "3.30.1" - resolved "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.30.1.tgz#7d93dc89e7d47b8ef05d7e79f507b0e99ea77eec" - integrity sha512-nXBEVpmUnNRhz83cHd9JRQC52cTMcuXAmR56+9dSMpRdpeA4I1PX6yjmhd71Eyc/wXNsdBdUDIj1QTIeZpU5Tg== + version "3.30.0" + resolved "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.30.0.tgz#41b6c42e5f363bd53d79999bd35093b17e42e1bf" + integrity sha512-+2KbMFGeBU0ln/csoPqTe0i/yfHbrd2EUhNMObsGtXMKS/RTtlkYyi+/3twLcevbgNR0yM/r0Psa3TEoQRpFMQ== cross-spawn@^6.0.5: version "6.0.5" @@ -1251,7 +1274,7 @@ deferred-leveldown@~5.3.0: abstract-leveldown "~6.2.1" inherits "^2.0.3" -define-properties@^1.1.3, define-properties@^1.1.4, define-properties@^1.2.0: +define-properties@^1.1.3, define-properties@^1.1.4: version "1.2.0" resolved "https://registry.npmjs.org/define-properties/-/define-properties-1.2.0.tgz#52988570670c9eacedd8064f4a990f2405849bd5" integrity sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA== @@ -1312,9 +1335,14 @@ duplicate-package-checker-webpack-plugin@^3.0.0: semver "^5.4.1" electron-to-chromium@^1.4.284: - version "1.4.368" - resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.368.tgz#75901f97d3e23da2e66feb1e61fbb8e70ac96430" - integrity sha512-e2aeCAixCj9M7nJxdB/wDjO6mbYX+lJJxSJCXDzlr5YPGYVofuJwGN9nKg2o6wWInjX6XmxRinn3AeJMK81ltw== + version "1.4.359" + resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.359.tgz#5c4d13cb08032469fcd6bd36457915caa211356b" + integrity sha512-OoVcngKCIuNXtZnsYoqlCvr0Cf3NIPzDIgwUfI9bdTFjXCrr79lI0kwQstLPZ7WhCezLlGksZk/BFAzoXC7GDw== + +emojis-list@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" + integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== encoding-down@^6.3.0: version "6.3.0" @@ -1560,7 +1588,7 @@ function.prototype.name@^1.1.5: es-abstract "^1.19.0" functions-have-names "^1.2.2" -functions-have-names@^1.2.2, functions-have-names@^1.2.3: +functions-have-names@^1.2.2: version "1.2.3" resolved "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== @@ -1964,7 +1992,14 @@ json-schema-traverse@^0.4.1: resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== -json5@^2.1.1: +json5@^1.0.1: + version "1.0.2" + resolved "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593" + integrity sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA== + dependencies: + minimist "^1.2.0" + +json5@^2.1.1, json5@^2.1.2: version "2.2.3" resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== @@ -2100,10 +2135,23 @@ loader-runner@^4.2.0: resolved "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz#c1b4a163b99f614830353b16755e7149ac2314e1" integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg== -loader-utils@>=2.0.3, loader-utils@^1.0.0, loader-utils@^2.0.0, loader-utils@~2.0.0: - version "3.2.1" - resolved "https://registry.npmjs.org/loader-utils/-/loader-utils-3.2.1.tgz#4fb104b599daafd82ef3e1a41fb9265f87e1f576" - integrity sha512-ZvFw1KWS3GVyYBYb7qkmRM/WwL2TQQBxgCK62rlvm4WpVQ23Nb4tYjApUlfjrEGvOs7KHEsmyUn75OHZrJMWPw== +loader-utils@^1.0.0: + version "1.4.2" + resolved "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz#29a957f3a63973883eb684f10ffd3d151fec01a3" + integrity sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg== + dependencies: + big.js "^5.2.2" + emojis-list "^3.0.0" + json5 "^1.0.1" + +loader-utils@^2.0.0, loader-utils@~2.0.0: + version "2.0.4" + resolved "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz#8b5cb38b5c34a9a018ee1fc0e6a066d1dfcc528c" + integrity sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw== + dependencies: + big.js "^5.2.2" + emojis-list "^3.0.0" + json5 "^2.1.2" locate-path@^5.0.0: version "5.0.0" @@ -2196,7 +2244,7 @@ minimatch@^3.0.4, minimatch@^3.1.1: dependencies: brace-expansion "^1.1.7" -minimist@~1.2.0: +minimist@^1.2.0, minimist@~1.2.0: version "1.2.8" resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== @@ -2511,11 +2559,11 @@ postcss-value-parser@^4.1.0: integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== postcss@^8.2.15, postcss@^8.3.11: - version "8.4.23" - resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.23.tgz#df0aee9ac7c5e53e1075c24a3613496f9e6552ab" - integrity sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA== + version "8.4.21" + resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz#c639b719a57efc3187b13a1d765675485f4134f4" + integrity sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg== dependencies: - nanoid "^3.3.6" + nanoid "^3.3.4" picocolors "^1.0.0" source-map-js "^1.0.2" @@ -2768,9 +2816,9 @@ semver@^6.0.0: integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== semver@^7.3.5, semver@^7.3.8: - version "7.5.0" - resolved "https://registry.npmjs.org/semver/-/semver-7.5.0.tgz#ed8c5dc8efb6c629c88b23d41dc9bf40c1d96cd0" - integrity sha512-+XC0AD/R7Q2mPSRuy2Id0+CGTZ98+8f+KvwirxOKIEyid+XSx6HbC63p+O4IndTHuX5Z+JxQ0TghCkO5Cg/2HA== + version "7.4.0" + resolved "https://registry.npmjs.org/semver/-/semver-7.4.0.tgz#8481c92feffc531ab1e012a8ffc15bdd3a0f4318" + integrity sha512-RgOxM8Mw+7Zus0+zcLEUn8+JfoLpj/huFTItQy2hsM4khuC1HYRDp0cU482Ewn/Fcy6bCjufD8vAj7voC66KQw== dependencies: lru-cache "^6.0.0" @@ -3034,9 +3082,9 @@ terser-webpack-plugin@^5.3.7: terser "^5.16.5" terser@^5.16.5, terser@^5.3.4: - version "5.17.1" - resolved "https://registry.npmjs.org/terser/-/terser-5.17.1.tgz#948f10830454761e2eeedc6debe45c532c83fd69" - integrity sha512-hVl35zClmpisy6oaoKALOpS0rDYLxRFLHhRuDlEGTKey9qHjS1w9GMORjuwIMt70Wan4lwsLYyWDVnWgF+KUEw== + version "5.16.9" + resolved "https://registry.npmjs.org/terser/-/terser-5.16.9.tgz#7a28cb178e330c484369886f2afd623d9847495f" + integrity sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg== dependencies: "@jridgewell/source-map" "^0.3.2" acorn "^8.5.0" @@ -3129,9 +3177,9 @@ universalify@^2.0.0: integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== update-browserslist-db@^1.0.10: - version "1.0.11" - resolved "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz#9a2a641ad2907ae7b3616506f4b977851db5b940" - integrity sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA== + version "1.0.10" + resolved "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz#0f54b876545726f17d00cd9a2561e6dade943ff3" + integrity sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ== dependencies: escalade "^3.1.1" picocolors "^1.0.0" @@ -3276,21 +3324,21 @@ webpack-sources@^3.2.3: integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== webpack@^5.41.1: - version "5.80.0" - resolved "https://registry.npmjs.org/webpack/-/webpack-5.80.0.tgz#3e660b4ab572be38c5e954bdaae7e2bf76010fdc" - integrity sha512-OIMiq37XK1rWO8mH9ssfFKZsXg4n6klTEDL7S8/HqbAOBBaiy8ABvXvz0dDCXeEF9gqwxSvVk611zFPjS8hJxA== + version "5.78.0" + resolved "https://registry.npmjs.org/webpack/-/webpack-5.78.0.tgz#836452a12416af2a7beae906b31644cb2562f9e6" + integrity sha512-gT5DP72KInmE/3azEaQrISjTvLYlSM0j1Ezhht/KLVkrqtv10JoP/RXhwmX/frrutOPuSq3o5Vq0ehR/4Vmd1g== dependencies: "@types/eslint-scope" "^3.7.3" - "@types/estree" "^1.0.0" - "@webassemblyjs/ast" "^1.11.5" - "@webassemblyjs/wasm-edit" "^1.11.5" - "@webassemblyjs/wasm-parser" "^1.11.5" + "@types/estree" "^0.0.51" + "@webassemblyjs/ast" "1.11.1" + "@webassemblyjs/wasm-edit" "1.11.1" + "@webassemblyjs/wasm-parser" "1.11.1" acorn "^8.7.1" acorn-import-assertions "^1.7.6" browserslist "^4.14.5" chrome-trace-event "^1.0.2" - enhanced-resolve "^5.13.0" - es-module-lexer "^1.2.1" + enhanced-resolve "^5.10.0" + es-module-lexer "^0.9.0" eslint-scope "5.1.1" events "^3.2.0" glob-to-regexp "^0.4.1" @@ -3299,9 +3347,9 @@ webpack@^5.41.1: loader-runner "^4.2.0" mime-types "^2.1.27" neo-async "^2.6.2" - schema-utils "^3.1.2" + schema-utils "^3.1.0" tapable "^2.1.1" - terser-webpack-plugin "^5.3.7" + terser-webpack-plugin "^5.1.3" watchpack "^2.4.0" webpack-sources "^3.2.3" @@ -3449,9 +3497,9 @@ yarn-deduplicate@^6.0.0: tslib "^2.4.1" yjs@^13.5.40: - version "13.5.53" - resolved "https://registry.npmjs.org/yjs/-/yjs-13.5.53.tgz#6531378981b89cfadd107145f7fb9f65f708a01f" - integrity sha512-B4UUycEK8BcYf195HL4LN4Az4Sg2+QzTHnabFHjQwLvGn96v/G+4CS52xNZk/0QWNXhLRCb+2GK3JmcX5fiCEQ== + version "13.5.52" + resolved "https://registry.npmjs.org/yjs/-/yjs-13.5.52.tgz#aec0535e16d45ed4defd6489fffae2b17e30fdb3" + integrity sha512-wTajR70VeI6uztpUk4kMcXYHSRzuUlNyJPdBG9NII0EcFf27DwGduZEm3XbP7VSzlGx5n6uenBhOPX+YuPH/tA== dependencies: lib0 "^0.2.72" diff --git a/tests/acceptance/Lab.robot b/tests/acceptance/Lab.robot index fdc01464..9c1acae0 100644 --- a/tests/acceptance/Lab.robot +++ b/tests/acceptance/Lab.robot @@ -17,7 +17,13 @@ Launch Browser Tab Location Should Contain foo Page Should Contain Hello World Close Window - [Teardown] Switch Window title:JupyterLab + Switch Window title:JupyterLab + Click RunningSessions + Click RefreshSessions + Capture Page Screenshot 01-running.png + Click Shutdown + Capture Page Screenshot 02-shutdown.png + [Teardown] Launch Lab Tab Click Launcher bar @@ -34,3 +40,13 @@ Start Lab Tests Click Launcher [Arguments] ${title} Click Element css:.jp-LauncherCard-label[title^\="${title}"] + +Click RunningSessions + Click Element css:#tab-key-1-0 + +Click RefreshSessions + Click Element css:.jp-Button.jp-ToolbarButtonComponent[title^\="Refresh List"] + +Click ShutDown + Mouse Over css:.jp-RunningSessions-itemShutdown[title^\="Shut Down"] + Click Element css:.jp-RunningSessions-itemShutdown[title^\="Shut Down"] diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 00000000..18dbbde5 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,151 @@ +"""Tests for API endpoints""" + +import json +import os +import time +from http.client import HTTPConnection +from typing import Tuple + +import pytest +from traitlets.config.loader import PyFileConfigLoader + +# use ipv4 for CI, etc. +LOCALHOST = "127.0.0.1" + + +def request_get(port, path, token, host=LOCALHOST): + h = HTTPConnection(host, port, 10) + if "?" in path: + url = f"{path}&token={token}" + else: + url = f"{path}?token={token}" + h.request("GET", url) + return h.getresponse() + + +def request_delete(port, path, token, host=LOCALHOST): + h = HTTPConnection(host, port, 10) + if "?" in path: + url = f"{path}&token={token}" + else: + url = f"{path}?token={token}" + h.request("DELETE", url) + return h.getresponse() + + +def load_config(): + """Load config file""" + config_file_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "resources", + "jupyter_server_config.py", + ) + cl = PyFileConfigLoader(config_file_path) + return cl.load_config() + + +def start_proxies(PORT, TOKEN): + """Start proxy servers for testing API handlers""" + selected_servers = ["python-http", "python-unix-socket-true", "python-websocket"] + for url in selected_servers: + _ = request_get(PORT, f"/{url}/", TOKEN) + return selected_servers + + +def test_server_proxy_info(a_server_port_and_token: Tuple[int, str]) -> None: + """Test API endpoint of /server-proxy/api/servers-info.""" + PORT, TOKEN = a_server_port_and_token + config = load_config() + test_url = "/server-proxy/api/servers-info" + expected_servers = list(config["ServerProxy"]["servers"].keys()) + r = request_get(PORT, test_url, TOKEN) + data = json.loads(r.read().decode()) + found_servers = [sp["name"] for sp in data["server_processes"]] + assert r.code == 200 + assert found_servers == expected_servers + + +def test_get_all_server_proxy(a_server_port_and_token: Tuple[int, str]) -> None: + """Test API endpoint of /server-proxy/api/servers.""" + PORT, TOKEN = a_server_port_and_token + expected_servers = start_proxies(PORT, TOKEN) + test_url = "/server-proxy/api/servers/" + r = request_get(PORT, test_url, TOKEN) + data = json.loads(r.read().decode()) + found_servers = [sp["name"] for sp in data] + assert r.code == 200 + assert found_servers == expected_servers + + +@pytest.mark.parametrize( + "server_process_path", + [ + "python-http", + "python-unix-socket-true", + "python-websocket", + ], +) +def test_get_given_server_proxy( + server_process_path: str, a_server_port_and_token: Tuple[int, str] +) -> None: + """Test API GET endpoint of /server-proxy/api/servers/{name}.""" + PORT, TOKEN = a_server_port_and_token + # config = load_config() + # expected_data = config['ServerProxy']['servers'][server_process_path] + _ = request_get(PORT, f"/{server_process_path}/", TOKEN) + test_url = f"/server-proxy/api/servers/{server_process_path}" + r = request_get(PORT, test_url, TOKEN) + data = json.loads(r.read().decode()) + assert r.code == 200 + assert data["name"] == server_process_path + if server_process_path in ["python-http", "python-websocket"]: + assert isinstance(int(data["port"]), int) + assert data["managed"] == True + assert data["unix_socket"] == "" + elif server_process_path == "python-unix-socket-true": + assert int(data["port"]) == 0 + assert "jupyter-server-proxy" in data["unix_socket"] + assert data["managed"] == True + + +def test_get_nonexisting_server_proxy(a_server_port_and_token: Tuple[int, str]) -> None: + """Test API non existing GET endpoint of /server-proxy/api/servers/{name}.""" + PORT, TOKEN = a_server_port_and_token + test_url = "/server-proxy/api/servers/doesnotexist" + r = request_get(PORT, test_url, TOKEN) + assert r.code == 404 + + +@pytest.mark.parametrize( + "server_process_path", + [ + "python-http", + "python-unix-socket-true", + "python-websocket", + ], +) +def test_delete_given_server_proxy( + server_process_path: str, a_server_port_and_token: Tuple[int, str] +) -> None: + """Test API DELETE endpoint of /server-proxy/api/servers/{name}.""" + PORT, TOKEN = a_server_port_and_token + _ = request_get(PORT, f"/{server_process_path}/", TOKEN) + # Just give enough time for it to be added in manager if it does not exist already + time.sleep(1) + test_url = f"/server-proxy/api/servers/{server_process_path}" + r = request_delete(PORT, test_url, TOKEN) + assert r.code == 204 + + +def test_delete_nonexisting_server_proxy( + a_server_port_and_token: Tuple[int, str] +) -> None: + """Test API DELETE non existing endpoint of /server-proxy/api/servers/{name}.""" + PORT, TOKEN = a_server_port_and_token + test_url = "/server-proxy/api/servers/doesnotexist" + r = request_delete(PORT, test_url, TOKEN) + assert r.code == 404 + # When no server name is supplied + test_url = "/server-proxy/api/servers/" + r = request_delete(PORT, test_url, TOKEN) + assert r.code == 403