diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py index f1ec8cc4..f21779bc 100644 --- a/jupyterhub/handlers/base.py +++ b/jupyterhub/handlers/base.py @@ -20,7 +20,7 @@ from sqlalchemy.exc import SQLAlchemyError from tornado.log import app_log from tornado.httputil import url_concat, HTTPHeaders from tornado.ioloop import IOLoop -from tornado.web import RequestHandler +from tornado.web import RequestHandler, MissingArgumentError from tornado import gen, web from .. import __version__ @@ -854,10 +854,10 @@ class BaseHandler(RequestHandler): await self.proxy.delete_user(user, server_name) await user.stop(server_name) - async def stop_single_user(self, user, name=''): - if name not in user.spawners: - raise KeyError("User %s has no such spawner %r", user.name, name) - spawner = user.spawners[name] + async def stop_single_user(self, user, server_name=''): + if server_name not in user.spawners: + raise KeyError("User %s has no such spawner %r", user.name, server_name) + spawner = user.spawners[server_name] if spawner.pending: raise RuntimeError("%s pending %s" % (spawner._log_name, spawner.pending)) # set user._stop_pending before doing anything async @@ -873,8 +873,8 @@ class BaseHandler(RequestHandler): """ tic = IOLoop.current().time() try: - await self.proxy.delete_user(user, name) - await user.stop(name) + await self.proxy.delete_user(user, server_name) + await user.stop(server_name) finally: spawner._stop_pending = False toc = IOLoop.current().time() @@ -885,7 +885,7 @@ class BaseHandler(RequestHandler): await gen.with_timeout(timedelta(seconds=self.slow_stop_timeout), stop()) except gen.TimeoutError: # hit timeout, but stop is still pending - self.log.warning("User %s:%s server is slow to stop", user.name, name) + self.log.warning("User %s:%s server is slow to stop", user.name, server_name) #--------------------------------------------------------------- # template rendering @@ -1019,7 +1019,7 @@ class PrefixRedirectHandler(BaseHandler): class UserSpawnHandler(BaseHandler): - """Redirect requests to /user/name/* handled by the Hub. + """Redirect requests to /user/user_name/* handled by the Hub. If logged in, spawn a single-user server and redirect request. If a user, alice, requests /user/bob/notebooks/mynotebook.ipynb, @@ -1035,21 +1035,21 @@ class UserSpawnHandler(BaseHandler): self.write(json.dumps({"message": "%s is not running" % user.name})) self.finish() - async def get(self, name, user_path): + async def get(self, user_name, user_path): if not user_path: user_path = '/' current_user = self.current_user if ( current_user - and current_user.name != name + and current_user.name != user_name and current_user.admin and self.settings.get('admin_access', False) ): # allow admins to spawn on behalf of users - user = self.find_user(name) + user = self.find_user(user_name) if user is None: # no such user - raise web.HTTPError(404, "No such user %s" % name) + raise web.HTTPError(404, "No such user %s" % user_name) self.log.info("Admin %s requesting spawn on behalf of %s", current_user.name, user.name) admin_spawn = True @@ -1059,7 +1059,7 @@ class UserSpawnHandler(BaseHandler): admin_spawn = False # For non-admins, we should spawn if the user matches # otherwise redirect users to their own server - should_spawn = (current_user and current_user.name == name) + should_spawn = (current_user and current_user.name == user_name) if "api" in user_path.split("/") and not user.active: # API request for not-running server (e.g. notebook UI left open) @@ -1071,7 +1071,7 @@ class UserSpawnHandler(BaseHandler): # if spawning fails for any reason, point users to /hub/home to retry self.extra_error_html = self.spawn_home_error - # If people visit /user/:name directly on the Hub, + # If people visit /user/:user_name directly on the Hub, # the redirects will just loop, because the proxy is bypassed. # Try to check for that and warn, # though the user-facing behavior is unchanged @@ -1087,7 +1087,11 @@ class UserSpawnHandler(BaseHandler): """, self.request.full_url(), self.proxy.public_url) # logged in as valid user, check for pending spawn - spawner = user.spawner + if self.allow_named_servers: + server_name = self.get_argument('server', '') + else: + server_name = '' + spawner = user.spawners[server_name] # First, check for previous failure. if ( @@ -1152,7 +1156,7 @@ class UserSpawnHandler(BaseHandler): {'next': self.request.uri})) return else: - await self.spawn_single_user(user) + await self.spawn_single_user(user, server_name) # spawn didn't finish, show pending page if spawner.pending: @@ -1208,7 +1212,7 @@ class UserSpawnHandler(BaseHandler): url_parts = urlparse(target) query_parts = parse_qs(url_parts.query) query_parts['redirects'] = redirects + 1 - url_parts = url_parts._replace(query=urlencode(query_parts)) + url_parts = url_parts._replace(query=urlencode(query_parts, doseq=True)) target = urlunparse(url_parts) else: target = url_concat(target, {'redirects': 1}) @@ -1276,7 +1280,7 @@ class AddSlashHandler(BaseHandler): default_handlers = [ (r'', AddSlashHandler), # add trailing / to `/hub` - (r'/user/([^/]+)(/.*)?', UserSpawnHandler), + (r'/user/(?P[^/]+)(?P/.*)?', UserSpawnHandler), (r'/user-redirect/(.*)?', UserRedirectHandler), (r'/security/csp-report', CSPReportHandler), ] diff --git a/jupyterhub/handlers/pages.py b/jupyterhub/handlers/pages.py index 536500f8..fcf7fcfc 100644 --- a/jupyterhub/handlers/pages.py +++ b/jupyterhub/handlers/pages.py @@ -50,13 +50,18 @@ class HomeHandler(BaseHandler): # trigger poll_and_notify event in case of a server that died await user.spawner.poll_and_notify() - # send the user to /spawn if they aren't running or pending a spawn, + # send the user to /spawn if they have no active servers, # to establish that this is an explicit spawn request rather - # than an implicit one, which can be caused by any link to `/user/:name` - url = user.url if user.spawner.active else url_path_join(self.hub.base_url, 'spawn') + # than an implicit one, which can be caused by any link to `/user/:name(/:server_name)` + url = url_path_join(self.hub.base_url, 'user', user.name) if user.active else url_path_join(self.hub.base_url, 'spawn') html = self.render_template('home.html', user=user, url=url, + allow_named_servers=self.allow_named_servers, + url_path_join=url_path_join, + # can't use user.spawners because the stop method of User pops named servers from user.spawners when they're stopped + spawners = user.orm_user._orm_spawners, + default_server = user.spawner, ) self.finish(html) @@ -214,11 +219,12 @@ class AdminHandler(BaseHandler): running = [ u for u in users if u.running ] html = self.render_template('admin.html', - user=self.current_user, + current_user=self.current_user, admin_access=self.settings.get('admin_access', False), users=users, running=running, sort={s:o for s,o in zip(sorts, orders)}, + allow_named_servers=self.allow_named_servers, ) self.finish(html) diff --git a/jupyterhub/user.py b/jupyterhub/user.py index f5a84f8c..234bab5b 100644 --- a/jupyterhub/user.py +++ b/jupyterhub/user.py @@ -182,19 +182,19 @@ class User: await self.save_auth_state(auth_state) return auth_state - def _new_spawner(self, name, spawner_class=None, **kwargs): + def _new_spawner(self, server_name, spawner_class=None, **kwargs): """Create a new spawner""" if spawner_class is None: spawner_class = self.spawner_class - self.log.debug("Creating %s for %s:%s", spawner_class, self.name, name) + self.log.debug("Creating %s for %s:%s", spawner_class, self.name, server_name) - orm_spawner = self.orm_spawners.get(name) + orm_spawner = self.orm_spawners.get(server_name) if orm_spawner is None: - orm_spawner = orm.Spawner(user=self.orm_user, name=name) + orm_spawner = orm.Spawner(user=self.orm_user, name=server_name) self.db.add(orm_spawner) self.db.commit() - assert name in self.orm_spawners - if name == '' and self.state: + assert server_name in self.orm_spawners + if server_name == '' and self.state: # migrate user.state to spawner.state orm_spawner.state = self.state self.state = None @@ -202,15 +202,15 @@ class User: # use fully quoted name for client_id because it will be used in cookie-name # self.escaped_name may contain @ which is legal in URLs but not cookie keys client_id = 'jupyterhub-user-%s' % quote(self.name) - if name: - client_id = '%s-%s' % (client_id, quote(name)) + if server_name: + client_id = '%s-%s' % (client_id, quote(server_name)) spawn_kwargs = dict( user=self, orm_spawner=orm_spawner, hub=self.settings.get('hub'), authenticator=self.authenticator, config=self.settings.get('config'), - proxy_spec=url_path_join(self.proxy_spec, name, '/'), + proxy_spec=url_path_join(self.proxy_spec, server_name, '/'), db=self.db, oauth_client_id=client_id, cookie_options = self.settings.get('cookie_options', {}), diff --git a/share/jupyterhub/static/js/admin.js b/share/jupyterhub/static/js/admin.js index 20c09a9d..86ae728d 100644 --- a/share/jupyterhub/static/js/admin.js +++ b/share/jupyterhub/static/js/admin.js @@ -62,24 +62,50 @@ require(["jquery", "bootstrap", "moment", "jhapi", "utils"], function ($, bs, mo el.text(m.isValid() ? m.fromNow() : "Never"); }); - $(".stop-server").click(function () { + $(".stop-server.default-server").click(function () { var el = $(this); var row = get_row(el); var user = row.data('user'); el.text("stopping..."); api.stop_server(user, { success: function () { - el.text('stop server').addClass('hidden'); - row.find('.access-server').addClass('hidden'); - row.find('.start-server').removeClass('hidden'); - } + if (el.data("named_servers") === true) { + el.text("stop default server").addClass("hidden"); + } else { + el.text("stop server").addClass("hidden"); + } + row.find(".access-server").addClass("hidden"); + row.find(".start-server").removeClass("hidden"); + }, }); }); - $(".access-server").map(function (i, el) { + $(".stop-server[id^='stop-']").click(function () { + var el = $(this); + var row = get_row(el); + var user = row.data("user"); + var server_name = (this.id).replace(/^(stop-)/, ""); + el.text("stopping..."); + api.stop_named_server(user, server_name, { + success: function() { + el.text("stop "+server_name).addClass("hidden"); + row.find(".access-server").addClass("hidden"); + row.find(".start-server").removeClass("hidden"); + }, + }); + }); + + $(".access-server.default-server").map(function (i, el) { el = $(el); - var user = get_row(el).data('user'); - el.attr('href', utils.url_path_join(prefix, 'user', user) + '/'); + var user = get_row(el).data("user"); + el.attr("href", utils.url_path_join(prefix, "user", user) + "/"); + }); + + $(".access-server[id^='access-']").map(function (i, el) { + el = $(el); + var user = get_row(el).data("user"); + var server_name = (this.id).replace(/^(access-)/, ""); + el.attr("href", utils.url_path_join(prefix, "user", user, server_name) + "/" ); }); if (admin_access && options_form) { @@ -94,16 +120,35 @@ require(["jquery", "bootstrap", "moment", "jhapi", "utils"], function ($, bs, mo // since it would mean opening a bunch of tabs $('#start-all-servers').addClass('hidden'); } else { - $(".start-server").click(function () { + $(".start-server.default-server").click(function () { var el = $(this); var row = get_row(el); var user = row.data('user'); el.text("starting..."); api.start_server(user, { success: function () { - el.text('start server').addClass('hidden'); - row.find('.stop-server').removeClass('hidden'); - row.find('.access-server').removeClass('hidden'); + if (el.data("named_servers") === true ){ + el.text("start default server").addClass("hidden"); + } else { + el.text("start server").addClass("hidden"); + } + row.find(".stop-server").removeClass("hidden"); + row.find(".access-server").removeClass("hidden"); + } + }); + }); + + $(".start-server[id^='start-']").click(function () { + var el = $(this); + var row = get_row(el); + var user = row.data("user"); + var server_name = (this.id).replace(/^(start-)/, ""); + el.text("starting..."); + api.start_named_server(user, server_name, { + success: function () { + el.text("start "+server_name).addClass("hidden"); + row.find(".stop-server").removeClass("hidden"); + row.find(".access-server").removeClass("hidden"); } }); }); diff --git a/share/jupyterhub/static/js/home.js b/share/jupyterhub/static/js/home.js index 6eed875f..bfeca59e 100644 --- a/share/jupyterhub/static/js/home.js +++ b/share/jupyterhub/static/js/home.js @@ -17,13 +17,156 @@ require(["jquery", "jhapi"], function($, JHAPI) { }); api.stop_server(user, { success: function() { + if ($("#start").data("named_servers") === true) { + $("#start") + .text("Start Default Server") + .attr("title", "Start the default server") + .attr("disabled", false) + .off("click"); + } else { $("#start") .text("Start My Server") .attr("title", "Start your server") .attr("disabled", false) .off("click"); $("#stop").hide(); - } + } + }, }); }); + + //// Named servers buttons + + function stop() { + let server_name = (this.id).replace(/^(stop-)/, ""); + // before request + $("#stop-"+server_name) + .attr("disabled", true) + .off("click") + .click(function() { + return false;}); + + $("#goto-"+server_name) + .attr("disabled", true) + .off("click") + .click(function() { + return false;}); + + // request + api.stop_named_server(user, server_name, { + success: function() { + // after request --> establish final local state + $("#stop-"+server_name) + .data("state", false) + .hide() + + $("#goto-"+server_name) + .data("state", false) + .hide() + + $("#start-"+server_name) + .data("state", false) + .show() + .attr("disabled", false) + .off("click") + .click(start); + }, + }); + }; + + function goto() { + let server_name = (this.id).replace(/^(goto-)/, ""); + // before request + $("#stop-"+server_name) + .attr("disabled", true) + .off("click") + .click(function() { + return false;}); + + $("#goto-"+server_name) + .attr("disabled", true) + .off("click") + .click(function() { + return false;}); + + window.location = base_url+"/user/"+user+"/"+server_name+"/tree"; } + + function start() { + let server_name = (this.id).replace(/^(start-)/, ""); + // before request + $("#start-"+server_name) + .attr("disabled", true) + .off("click") + .click(function() { + return false;}); + + // request + api.start_named_server(user, server_name, { + success: function() { + // after request --> establish final local state + $("#stop-"+server_name) + .data("state", true) + .show() + .attr("disabled", false) + .off("click") + .click(stop); + + $("#goto-"+server_name) + .data("state", true) + .show() + .attr("disabled", false) + .off("click") + .click(goto); + + $("#start-"+server_name) + .data("state", true) + .hide() + }});}; + + // Initial state: TRUE (server running) -- local state transitions occur by clicking stop + // - stop visible + // - goto visible + // - start invisible + + $("[data-state='true'][id^='stop-']") + .show() + .attr("disabled", false) + .click(stop); + + $("[data-state='true'][id^='goto-']") + .show() + .attr("disabled", false) + .click(goto); + + $("[data-state='true'][id^='start-']") + .hide() + .attr("disabled", true) + .off("click") + .click(function() { + return false;}); + + // Initial state: FALSE (server not running) -- local state transitions occur by clicking start + // - stop invisible + // - goto invisible + // - start visible + + $("[data-state='false'][id^='stop-']") + .hide() + .attr("disabled", true) + .off("click") + .click(function() { + return false;}); + + $("[data-state='false'][id^='goto-']") + .hide() + .attr("disabled", true) + .off("click") + .click(function() { + return false;}); + + $("[data-state='false'][id^='start-']") + .show() + .attr("disabled", false) + .click(start); + }); diff --git a/share/jupyterhub/static/js/jhapi.js b/share/jupyterhub/static/js/jhapi.js index 6ea2fe89..46d2d369 100644 --- a/share/jupyterhub/static/js/jhapi.js +++ b/share/jupyterhub/static/js/jhapi.js @@ -52,6 +52,15 @@ define(['jquery', 'utils'], function ($, utils) { ); }; + JHAPI.prototype.start_named_server = function (user, server_name, options) { + options = options || {}; + options = update(options, {type: 'POST', dataType: null}); + this.api_request( + utils.url_path_join('users', user, 'servers', server_name), + options + ); + }; + JHAPI.prototype.stop_server = function (user, options) { options = options || {}; options = update(options, {type: 'DELETE', dataType: null}); @@ -61,6 +70,15 @@ define(['jquery', 'utils'], function ($, utils) { ); }; + JHAPI.prototype.stop_named_server = function (user, server_name, options) { + options = options || {}; + options = update(options, {type: 'DELETE', dataType: null}); + this.api_request( + utils.url_path_join('users', user, 'servers', server_name), + options + ); + }; + JHAPI.prototype.list_users = function (options) { this.api_request('users', options); }; diff --git a/share/jupyterhub/static/less/admin.less b/share/jupyterhub/static/less/admin.less index 7283d91f..70a262d9 100644 --- a/share/jupyterhub/static/less/admin.less +++ b/share/jupyterhub/static/less/admin.less @@ -1,3 +1,3 @@ i.sort-icon { margin-left: 4px; -} \ No newline at end of file +} diff --git a/share/jupyterhub/templates/admin.html b/share/jupyterhub/templates/admin.html index 7227e663..4b555dd8 100644 --- a/share/jupyterhub/templates/admin.html +++ b/share/jupyterhub/templates/admin.html @@ -40,37 +40,87 @@ Shutdown Hub - {% for u in users %} - + {% for user in users %} + {% block user_row scoped %} - {{u.name}} - {% if u.admin %}admin{% endif %} + {{user.name}} + {% if user.admin %}admin{% endif %} - {%- if u.last_activity -%} - {{ u.last_activity.isoformat() + 'Z' }} + {%- if user.last_activity -%} + {{ user.last_activity.isoformat() + 'Z' }} {%- else -%} Never {%- endif -%} - stop server - start server + + {% if allow_named_servers %} + stop default server + {% else %} + stop server + {% endif %} + + + {% if allow_named_servers %} + start default server + {% else %} + start server + {% endif %} {% if admin_access %} - access server + + {% if allow_named_servers %} + access default server + {% else %} + access server + {% endif %} + {% endif %} - edit + edit{% if allow_named_servers %} {{ user.name }}{% endif %} - {% if u.name != user.name %} - delete + {% if user.name != current_user.name %} + delete{% if allow_named_servers %} {{ user.name }}{% endif %} {% endif %} + + {% if allow_named_servers %} + {% for spawner in user.orm_user._orm_spawners %} + {% if spawner.name %} + + {# {% block user_row scoped %} #} + + + + {%- if spawner.last_activity -%} + {{ spawner.last_activity.isoformat() + 'Z' }} + {%- else -%} + Never + {%- endif -%} + + + stop {{ spawner.name }} + start {{ spawner.name }} + + + {% if admin_access %} + access server + {% endif %} + + + + + + + + + {% endif %} + {% endfor %} + {% endif %} {% endblock user_row %} - {% endfor %} diff --git a/share/jupyterhub/templates/home.html b/share/jupyterhub/templates/home.html index 3db7334a..f0463918 100644 --- a/share/jupyterhub/templates/home.html +++ b/share/jupyterhub/templates/home.html @@ -6,20 +6,76 @@ {% block main %} +{% endif %} {% endblock %}