diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py index 2a91d04e..a34e2088 100644 --- a/jupyterhub/apihandlers/base.py +++ b/jupyterhub/apihandlers/base.py @@ -18,9 +18,12 @@ class APIHandler(BaseHandler): def content_security_policy(self): return '; '.join([super().content_security_policy, "default-src 'none'"]) + def get_content_type(self): + return 'application/json' + def set_default_headers(self): super().set_default_headers() - self.set_header('Content-Type', 'application/json') + self.set_header('Content-Type', self.get_content_type()) def check_referer(self): """Check Origin for cross-site API requests. diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py index 9247835e..a1f5e9f3 100644 --- a/jupyterhub/apihandlers/users.py +++ b/jupyterhub/apihandlers/users.py @@ -5,10 +5,11 @@ import json -from tornado import gen, web +from tornado import web +from tornado.iostream import StreamClosedError from .. import orm -from ..utils import admin_only, maybe_future +from ..utils import admin_only, maybe_future, url_path_join from .base import APIHandler @@ -17,6 +18,7 @@ class SelfAPIHandler(APIHandler): Based on the authentication info. Acts as a 'whoami' for auth tokens. """ + async def get(self): user = self.get_current_user() if user is None: @@ -102,6 +104,7 @@ def admin_or_self(method): return method(self, name, *args, **kwargs) return m + class UserAPIHandler(APIHandler): @admin_or_self @@ -269,10 +272,108 @@ class UserAdminAccessAPIHandler(APIHandler): raise web.HTTPError(404) +class SpawnProgressAPIHandler(APIHandler): + """EventStream handler for pending spawns""" + def get_content_type(self): + return 'text/event-stream' + + async def send_event(self, event): + try: + self.write('data: {}\n\n'.format(json.dumps(event))) + await self.flush() + except StreamClosedError: + self.log.warning("Stream closed while handling %s", self.request.uri) + # raise Finish to halt the handler + raise web.Finish() + + @admin_or_self + async def get(self, username, server_name=''): + self.set_header('Cache-Control', 'no-cache') + if server_name is None: + server_name = '' + user = self.find_user(username) + if user is None: + # no such user + raise web.HTTPError(404) + if server_name not in user.spawners: + # user has no such server + raise web.HTTPError(404) + spawner = user.spawners[server_name] + # cases: + # - spawner already started and ready + # - spawner not running at all + # - spawner failed + # - spawner pending start (what we expect) + url = url_path_join(user.url, server_name, '/') + ready_event = { + 'progress': 100, + 'ready': True, + 'message': "Server ready at {}".format(url), + 'html_message': 'Server ready at {0}'.format(url), + 'url': url, + } + failed_event = { + 'progress': 100, + 'failed': True, + 'message': "Spawn failed", + } + + if spawner.ready: + # spawner already ready. Trigger progress-completion immediately + self.log.info("Server %s is already started", spawner._log_name) + await self.send_event(ready_event) + return + + if not spawner._spawn_pending: + # not pending, no progress to fetch + # check if spawner has just failed + f = spawner._spawn_future + if f and f.done() and f.exception(): + failed_event['message'] = "Spawn failed: %s" % f.exception() + await self.send_event(failed_event) + return + else: + raise web.HTTPError(400, "%s is not starting...", spawner._log_name) + + # retrieve progress events from the Spawner + async for event in spawner._generate_progress(): + # don't allow events to sneakily set the 'ready flag' + if 'ready' in event: + event.pop('ready', None) + await self.send_event(event) + + # events finished, check if we are still pending + if spawner._spawn_pending: + try: + await spawner._spawn_future + except Exception: + # suppress exceptions in spawn future, + # which will be logged elsewhere + pass + + # progress finished, check if we are done + if spawner.ready: + # spawner is ready, signal completion and redirect + self.log.info("Server %s is ready", spawner._log_name) + await self.send_event(ready_event) + return + else: + # what happened? Maybe spawn failed? + f = spawner._spawn_future + if f and f.done() and f.exception(): + failed_event['message'] = "Spawn failed: %s" % f.exception() + else: + self.log.warning("Server %s didn't start for unknown reason", spawner._log_name) + await self.send_event(failed_event) + return + + default_handlers = [ (r"/api/user", SelfAPIHandler), (r"/api/users", UserListAPIHandler), (r"/api/users/([^/]+)", UserAPIHandler), + (r"/api/users/([^/]+)/server-progress", SpawnProgressAPIHandler), + (r"/api/users/([^/]+)/server-progress/([^/]*)", SpawnProgressAPIHandler), (r"/api/users/([^/]+)/server", UserServerAPIHandler), (r"/api/users/([^/]+)/servers/([^/]*)", UserServerAPIHandler), (r"/api/users/([^/]+)/admin-access", UserAdminAccessAPIHandler), diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py index fdd01e86..7e61fc65 100644 --- a/jupyterhub/handlers/base.py +++ b/jupyterhub/handlers/base.py @@ -887,11 +887,21 @@ class UserSpawnHandler(BaseHandler): pass # we may have waited above, check pending again: + progress_url = url_path_join( + self.hub.base_url, 'api/users', + user.escaped_name, 'server-progress', spawner.name, + ) + if spawner.pending: self.log.info("%s is pending %s", spawner._log_name, spawner.pending) # spawn has started, but not finished self.statsd.incr('redirects.user_spawn_pending', 1) - html = self.render_template("spawn_pending.html", user=user) + url_parts = [] + html = self.render_template( + "spawn_pending.html", + user=user, + progress_url=progress_url, + ) self.finish(html) return @@ -919,7 +929,11 @@ class UserSpawnHandler(BaseHandler): self.log.info("%s is pending %s", spawner._log_name, spawner.pending) # spawn has started, but not finished self.statsd.incr('redirects.user_spawn_pending', 1) - html = self.render_template("spawn_pending.html", user=user) + html = self.render_template( + "spawn_pending.html", + user=user, + progress_url=progress_url, + ) self.finish(html) return diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index 35b625ba..01df8183 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -16,6 +16,9 @@ import warnings from subprocess import Popen from tempfile import mkdtemp +# FIXME: remove when we drop Python 3.5 support +from async_generator import isasyncgenfunction, async_generator, yield_ + from sqlalchemy import inspect from tornado import gen, concurrent @@ -47,7 +50,7 @@ class Spawner(LoggingConfigurable): is created for each user. If there are 20 JupyterHub users, there will be 20 instances of the subclass. """ - + # private attributes for tracking status _spawn_pending = False _start_pending = False @@ -264,11 +267,10 @@ class Spawner(LoggingConfigurable): """Get the options form Returns: - (Future(str)): the content of the options form presented to the user + Future (str): the content of the options form presented to the user prior to starting a Spawner. - .. versionadded:: 0.9.0 - Introduced. + .. versionadded:: 0.9 """ if callable(self.options_form): options_form = await maybe_future(self.options_form(self)) @@ -690,6 +692,65 @@ class Spawner(LoggingConfigurable): if self.pre_spawn_hook: return self.pre_spawn_hook(self) + @async_generator + async def _generate_progress(self): + """Private wrapper of progress generator + + This method is always an async generator and will always yield at least one event. + + Calls self._default_progress if self.progress is not an async generator + """ + if not self._spawn_pending: + raise RuntimeError("Spawn not pending, can't generate progress") + await yield_({ + "progress": 0, + "message": "Server requested", + }) + if isasyncgenfunction(self.progress): + progress = self.progress + else: + progress = self._default_progress + + # TODO: stop when spawn is ready, even if progress isn't + async for event in progress(): + await yield_(event) + + @async_generator + async def _default_progress(self): + """The default progress events generator + + Yields just one generic event for 50% progress + """ + await yield_({ + "progress": 50, + "message": "Spawning server...", + }) + + async def progress(self): + """Async generator for progress events + + Must be an async generator + + For Python 3.5-compatibility, use the async_generator package + + Should yield messages of the form: + + :: + + { + "progress": 80, # integer, out of 100 + "message": text, # text message (will be escaped for HTML) + "html_message": html_text, # optional html-formatted message (may have links) + } + + In HTML contexts, html_message will be displayed instead of message if present. + Progress will be updated if defined. + To update messages without progress omit the progress field. + + .. versionadded:: 0.9 + """ + pass + async def start(self): """Start the single-user server diff --git a/requirements.txt b/requirements.txt index a3a47065..9c75ebd1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ alembic +async_generator>=1.8 traitlets>=4.3.2 tornado>=4.1 jinja2 diff --git a/share/jupyterhub/static/less/page.less b/share/jupyterhub/static/less/page.less index 0ffdeab9..6193b357 100644 --- a/share/jupyterhub/static/less/page.less +++ b/share/jupyterhub/static/less/page.less @@ -12,3 +12,17 @@ .hidden { display: none; } + +#progress-log { + margin-top: 8px; +} + +.progress-log-event { + border-top: 1px solid #e7e7e7; + padding: 8px; +} + +// hover-highlight on log events? +// .progress-log-event:hover { +// background: rgba(66, 165, 245, 0.2); +// } diff --git a/share/jupyterhub/templates/spawn_pending.html b/share/jupyterhub/templates/spawn_pending.html index 71c88d08..a255f63a 100644 --- a/share/jupyterhub/templates/spawn_pending.html +++ b/share/jupyterhub/templates/spawn_pending.html @@ -10,9 +10,20 @@
You will be redirected automatically when it's ready for you.
{% endblock %}- refresh - +