diff --git a/jupyter_server_proxy/__init__.py b/jupyter_server_proxy/__init__.py index 2a6bd6aa..6b1f99af 100644 --- a/jupyter_server_proxy/__init__.py +++ b/jupyter_server_proxy/__init__.py @@ -34,8 +34,10 @@ def _load_jupyter_server_extension(nbapp): launcher_entries = [] 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 + for le in sp.launcher_entries: + if le.enabled and le.icon_path: + icons[(sp.name, le.name)] = le.icon_path + nbapp.web_app.add_handlers('.*', [ (ujoin(base_url, 'server-proxy/servers-info'), ServersInfoHandler, {'server_processes': server_processes}), diff --git a/jupyter_server_proxy/api.py b/jupyter_server_proxy/api.py index 7718d690..9920af56 100644 --- a/jupyter_server_proxy/api.py +++ b/jupyter_server_proxy/api.py @@ -14,18 +14,25 @@ async def get(self): # Pick out and send only metadata # Don't send anything that might be a callable, or leak sensitive info for sp in self.server_processes: - # Manually recurse to convert namedtuples into JSONable structures item = { 'name': sp.name, - 'launcher_entry': { - 'enabled': sp.launcher_entry.enabled, - 'title': sp.launcher_entry.title - }, - 'new_browser_tab' : sp.new_browser_tab + 'launcher_entries': [] } - if sp.launcher_entry.icon_path: - icon_url = ujoin(self.base_url, 'server-proxy', 'icon', sp.name) - item['launcher_entry']['icon_url'] = icon_url + + for le in sp.launcher_entries: + litem = { + 'enabled': le.enabled, + 'title': le.title, + 'new_browser_tab': le.new_browser_tab, + 'path': le.path + } + + # Manually recurse to convert namedtuples into JSONable structures + if le.icon_path: + icon_url = ujoin(self.base_url, 'server-proxy', 'icon', sp.name, le.name) + litem['icon_url'] = icon_url + + item['launcher_entries'].append(litem) data.append(item) @@ -44,6 +51,7 @@ def initialize(self, icons): self.icons = icons async def get(self, name): + name = tuple(name.split("/", 1)) if name not in self.icons: raise web.HTTPError(404) path = self.icons[name] @@ -64,6 +72,6 @@ async def get(self, name): else: content_type = "application/octet-stream" - with open(self.icons[name]) as f: + with open(self.icons[name], "rb") as f: self.write(f.read()) self.set_header('Content-Type', content_type) diff --git a/jupyter_server_proxy/config.py b/jupyter_server_proxy/config.py index 465a4735..6c7ded3d 100644 --- a/jupyter_server_proxy/config.py +++ b/jupyter_server_proxy/config.py @@ -106,12 +106,13 @@ def make_handlers(base_url, server_processes): )) return handlers -LauncherEntry = namedtuple('LauncherEntry', ['enabled', 'icon_path', 'title']) +LauncherEntry = namedtuple('LauncherEntry', ['enabled', 'icon_path', 'title', 'name', 'new_browser_tab', 'path']) ServerProcess = namedtuple('ServerProcess', [ - 'name', 'command', 'environment', 'timeout', 'absolute_url', 'port', 'mappath', 'launcher_entry', 'new_browser_tab', 'request_headers_override']) + 'name', 'command', 'environment', 'timeout', 'absolute_url', 'port', 'mappath', 'launcher_entries', 'request_headers_override']) + + def make_server_process(name, server_process_config): - le = server_process_config.get('launcher_entry', {}) return ServerProcess( name=name, command=server_process_config['command'], @@ -120,15 +121,27 @@ def make_server_process(name, server_process_config): absolute_url=server_process_config.get('absolute_url', False), port=server_process_config.get('port', 0), mappath=server_process_config.get('mappath', {}), - launcher_entry=LauncherEntry( - enabled=le.get('enabled', True), - icon_path=le.get('icon_path'), - title=le.get('title', name) - ), - new_browser_tab=server_process_config.get('new_browser_tab', True), + launcher_entries=[*make_launcher_entries(name, server_process_config)], request_headers_override=server_process_config.get('request_headers_override', {}) ) + +def make_launcher_entries(name, server_process_config): + le = server_process_config.get('launcher_entry', {}) + les = [le] if isinstance(le, dict) else le + new_browser_tab = server_process_config.get('new_browser_tab', True) + + for le in les: + yield LauncherEntry( + name=le.get('name', 'default'), + enabled=le.get('enabled', True), + icon_path=le.get('icon_path'), + title=le.get('title', le.get('name', name)), + path=le.get('path', '/'), + new_browser_tab=le.get('new_browser_tab', new_browser_tab) + ) + + class ServerProxy(Configurable): servers = Dict( {}, @@ -167,8 +180,17 @@ class ServerProxy(Configurable): Either a dictionary of request paths to proxied paths, or a callable that takes parameter ``path`` and returns the proxied path. + new_browser_tab + Set to True (default) to make the proxied server interface opened as a new browser tab. Set to False + to have it open a new JupyterLab tab. This has no effect in classic notebook. + + request_headers_override + A dictionary of additional HTTP headers for the proxy request. As with + the command traitlet, {{port}} and {{base_url}} will be substituted. + launcher_entry A dictionary of various options for entries in classic notebook / jupyterlab launchers. + May also be a list of the same, where each must have a ``name`` key. Keys recognized are: @@ -178,18 +200,20 @@ class ServerProxy(Configurable): icon_path Full path to an svg icon that could be used with a launcher. Currently only used by the - JupyterLab launcher + JupyterLab launcher. title Title to be used for the launcher entry. Defaults to the name of the server if missing. - new_browser_tab - Set to True (default) to make the proxied server interface opened as a new browser tab. Set to False - to have it open a new JupyterLab tab. This has no effect in classic notebook. + new_browser_tab + Override the default tab behavior from the root config. + + name: + A name (default: default) used for constructing URLs and DOM ids for the launcher + + path: + A path (default: /) to open. May include ``?`` params and ``#`` fragments. - request_headers_override - A dictionary of additional HTTP headers for the proxy request. As with - the command traitlet, {{port}} and {{base_url}} will be substituted. """, config=True ) diff --git a/jupyter_server_proxy/static/tree.js b/jupyter_server_proxy/static/tree.js index 57e7304d..d4871391 100644 --- a/jupyter_server_proxy/static/tree.js +++ b/jupyter_server_proxy/static/tree.js @@ -18,33 +18,34 @@ define(['jquery', 'base/js/namespace', 'base/js/utils'], function($, Jupyter, ut .attr('role', 'presentation') .addClass('divider'); - + /* add the divider */ $menu.append($divider); $.each(data.server_processes, function(_, server_process) { - if (!server_process.launcher_entry.enabled) { - return; - } - - /* create our list item */ - var $entry_container = $('
  • ') - .attr('role', 'presentation') - .addClass('new-' + server_process.name); - - /* create our list item's link */ - var $entry_link = $('') - .attr('role', 'menuitem') - .attr('tabindex', '-1') - .attr('href', base_url + server_process.name + '/') - .attr('target', '_blank') - .text(server_process.launcher_entry.title); - - /* add the link to the item and - * the item to the menu */ - $entry_container.append($entry_link); - $menu.append($entry_container); - + $.each(server_process.launcher_entries, function(_, launcher_entry) { + if (!launcher_entry.enabled) { + return; + } + + /* create our list item */ + var $entry_container = $('
  • ') + .attr('role', 'presentation') + .addClass('new-' + server_process.name + '-' + launcher_entry.name); + + /* create our list item's link */ + var $entry_link = $('') + .attr('role', 'menuitem') + .attr('tabindex', '-1') + .attr('href', base_url + server_process.name + launcher_entry.path) + .attr('target', '_blank') + .text(launcher_entry.title); + + /* add the link to the item and + * the item to the menu */ + $entry_container.append($entry_link); + $menu.append($entry_container); + }); }); }); } diff --git a/jupyterlab-server-proxy/package.json b/jupyterlab-server-proxy/package.json index 1453fd3e..08549264 100644 --- a/jupyterlab-server-proxy/package.json +++ b/jupyterlab-server-proxy/package.json @@ -41,18 +41,20 @@ "install:extension": "jupyter labextension develop --overwrite .", "prepare": "jlpm run clean && jlpm run build:prod", "watch": "run-p watch:src watch:labextension", - "watch:src": "tsc -w", + "watch:src": "tsc -w --preserveWatchOutput", "watch:labextension": "jupyter labextension watch ." }, "dependencies": { "@jupyterlab/application": "^2.0.0 || ^3.0.0", "@jupyterlab/apputils": "^2.0.0 || ^3.0.0", - "@jupyterlab/launcher": "^2.0.0 || ^3.0.0" + "@jupyterlab/launcher": "^2.0.0 || ^3.0.0", + "@jupyterlab/mainmenu": "^2.0.0 || ^3.0.0" }, "devDependencies": { "@jupyterlab/builder": "^3.0.2", - "rimraf": "^2.6.1", - "typescript": "~3.7.0" + "rimraf": "^3.0.2", + "typescript": "~4.2.4", + "npm-run-all": "~4.1.5" }, "jupyterlab": { "extension": true, diff --git a/jupyterlab-server-proxy/src/index.ts b/jupyterlab-server-proxy/src/index.ts index f63890d9..22f9363f 100644 --- a/jupyterlab-server-proxy/src/index.ts +++ b/jupyterlab-server-proxy/src/index.ts @@ -1,7 +1,10 @@ +import { Menu } from '@lumino/widgets'; + import { JupyterFrontEnd, JupyterFrontEndPlugin, ILayoutRestorer } from '@jupyterlab/application'; import { ILauncher } from '@jupyterlab/launcher'; import { PageConfig } from '@jupyterlab/coreutils'; -import { IFrame, MainAreaWidget, WidgetTracker } from '@jupyterlab/apputils'; +import { ICommandPalette, IFrame, MainAreaWidget, WidgetTracker } from '@jupyterlab/apputils'; +import { IMainMenu } from '@jupyterlab/mainmenu'; import '../style/index.css'; @@ -19,7 +22,13 @@ function newServerProxyWidget(id: string, url: string, text: string): MainAreaWi return widget; } -async function activate(app: JupyterFrontEnd, launcher: ILauncher, restorer: ILayoutRestorer) : Promise { +async function activate( + app: JupyterFrontEnd, + launcher?: ILauncher, + restorer?: ILayoutRestorer, + palette?: ICommandPalette, + mainMenu?: IMainMenu +) : Promise { const response = await fetch(PageConfig.getBaseUrl() + 'server-proxy/servers-info'); if (!response.ok) { console.log('Could not fetch metadata about registered servers. Make sure jupyter-server-proxy is installed.'); @@ -75,30 +84,54 @@ async function activate(app: JupyterFrontEnd, launcher: ILauncher, restorer: ILa } }); + const menuItems: Menu.IItemOptions[] = []; + for (let server_process of data.server_processes) { - if (!server_process.launcher_entry.enabled) { - continue; - } + for (let launcher_entry of server_process.launcher_entries) { + if (!launcher_entry.enabled) { + continue; + } - const url = PageConfig.getBaseUrl() + server_process.name + '/'; - const title = server_process.launcher_entry.title; - const newBrowserTab = server_process.new_browser_tab; - const id = namespace + ':' + server_process.name; - const launcher_item : ILauncher.IItemOptions = { - command: command, - args: { - url: url, - title: title + (newBrowserTab ? ' [↗]': ''), - newBrowserTab: newBrowserTab, - id: id - }, - category: 'Notebook' - }; - - if (server_process.launcher_entry.icon_url) { - launcher_item.kernelIconUrl = server_process.launcher_entry.icon_url; + const url = PageConfig.getBaseUrl() + server_process.name + launcher_entry.path; + const newBrowserTab = launcher_entry.new_browser_tab; + const title = launcher_entry.title + (newBrowserTab ? ' [↗]': ''); + const id = namespace + ':' + server_process.name + ':' + launcher_entry.name; + const launcher_item : ILauncher.IItemOptions = { + command: command, + args: { url, title, newBrowserTab, id }, + category: 'Notebook' + }; + + if (launcher_entry.icon_url) { + launcher_item.kernelIconUrl = launcher_entry.icon_url; + } + + if (launcher) { + launcher.add(launcher_item); + } + + if (palette) { + palette.addItem({ + command, + args: { + ...launcher_item.args, + title: `Launch ${title}` + }, + category: 'Server Proxies' + }); + } + + if (mainMenu) { + menuItems.push({ + command, + args: launcher_item.args + }); + } } - launcher.add(launcher_item); + } + + if (mainMenu && menuItems) { + mainMenu.fileMenu.newMenu.addGroup(menuItems); } } @@ -108,8 +141,8 @@ async function activate(app: JupyterFrontEnd, launcher: ILauncher, restorer: ILa const extension: JupyterFrontEndPlugin = { id: 'jupyterlab-server-proxy', autoStart: true, - requires: [ILauncher, ILayoutRestorer], - activate: activate + optional: [ILauncher, ILayoutRestorer, ICommandPalette, IMainMenu], + activate }; export default extension;