implement progress on spawn_pending page

- add Spawner.progress method. Must be an async generator of JSON-able progress events
- add /api/users/:user/server-progress eventstream endpoint
- use eventstream to fill progress bar on the spawn pending page
This commit is contained in:
Min RK
2018-04-04 13:47:57 +02:00
parent d962e8bcbc
commit 4f78cbbd1b
7 changed files with 266 additions and 14 deletions

View File

@@ -18,9 +18,12 @@ class APIHandler(BaseHandler):
def content_security_policy(self): def content_security_policy(self):
return '; '.join([super().content_security_policy, "default-src 'none'"]) return '; '.join([super().content_security_policy, "default-src 'none'"])
def get_content_type(self):
return 'application/json'
def set_default_headers(self): def set_default_headers(self):
super().set_default_headers() super().set_default_headers()
self.set_header('Content-Type', 'application/json') self.set_header('Content-Type', self.get_content_type())
def check_referer(self): def check_referer(self):
"""Check Origin for cross-site API requests. """Check Origin for cross-site API requests.

View File

@@ -5,10 +5,11 @@
import json import json
from tornado import gen, web from tornado import web
from tornado.iostream import StreamClosedError
from .. import orm from .. import orm
from ..utils import admin_only, maybe_future from ..utils import admin_only, maybe_future, url_path_join
from .base import APIHandler from .base import APIHandler
@@ -17,6 +18,7 @@ class SelfAPIHandler(APIHandler):
Based on the authentication info. Acts as a 'whoami' for auth tokens. Based on the authentication info. Acts as a 'whoami' for auth tokens.
""" """
async def get(self): async def get(self):
user = self.get_current_user() user = self.get_current_user()
if user is None: if user is None:
@@ -102,6 +104,7 @@ def admin_or_self(method):
return method(self, name, *args, **kwargs) return method(self, name, *args, **kwargs)
return m return m
class UserAPIHandler(APIHandler): class UserAPIHandler(APIHandler):
@admin_or_self @admin_or_self
@@ -269,10 +272,108 @@ class UserAdminAccessAPIHandler(APIHandler):
raise web.HTTPError(404) 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 <a href="{0}">{0}</a>'.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 = [ default_handlers = [
(r"/api/user", SelfAPIHandler), (r"/api/user", SelfAPIHandler),
(r"/api/users", UserListAPIHandler), (r"/api/users", UserListAPIHandler),
(r"/api/users/([^/]+)", UserAPIHandler), (r"/api/users/([^/]+)", UserAPIHandler),
(r"/api/users/([^/]+)/server-progress", SpawnProgressAPIHandler),
(r"/api/users/([^/]+)/server-progress/([^/]*)", SpawnProgressAPIHandler),
(r"/api/users/([^/]+)/server", UserServerAPIHandler), (r"/api/users/([^/]+)/server", UserServerAPIHandler),
(r"/api/users/([^/]+)/servers/([^/]*)", UserServerAPIHandler), (r"/api/users/([^/]+)/servers/([^/]*)", UserServerAPIHandler),
(r"/api/users/([^/]+)/admin-access", UserAdminAccessAPIHandler), (r"/api/users/([^/]+)/admin-access", UserAdminAccessAPIHandler),

View File

@@ -887,11 +887,21 @@ class UserSpawnHandler(BaseHandler):
pass pass
# we may have waited above, check pending again: # 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: if spawner.pending:
self.log.info("%s is pending %s", spawner._log_name, spawner.pending) self.log.info("%s is pending %s", spawner._log_name, spawner.pending)
# spawn has started, but not finished # spawn has started, but not finished
self.statsd.incr('redirects.user_spawn_pending', 1) 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) self.finish(html)
return return
@@ -919,7 +929,11 @@ class UserSpawnHandler(BaseHandler):
self.log.info("%s is pending %s", spawner._log_name, spawner.pending) self.log.info("%s is pending %s", spawner._log_name, spawner.pending)
# spawn has started, but not finished # spawn has started, but not finished
self.statsd.incr('redirects.user_spawn_pending', 1) 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) self.finish(html)
return return

View File

@@ -16,6 +16,9 @@ import warnings
from subprocess import Popen from subprocess import Popen
from tempfile import mkdtemp 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 sqlalchemy import inspect
from tornado import gen, concurrent 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 is created for each user. If there are 20 JupyterHub users, there will be 20
instances of the subclass. instances of the subclass.
""" """
# private attributes for tracking status # private attributes for tracking status
_spawn_pending = False _spawn_pending = False
_start_pending = False _start_pending = False
@@ -264,11 +267,10 @@ class Spawner(LoggingConfigurable):
"""Get the options form """Get the options form
Returns: 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. prior to starting a Spawner.
.. versionadded:: 0.9.0 .. versionadded:: 0.9
Introduced.
""" """
if callable(self.options_form): if callable(self.options_form):
options_form = await maybe_future(self.options_form(self)) options_form = await maybe_future(self.options_form(self))
@@ -690,6 +692,65 @@ class Spawner(LoggingConfigurable):
if self.pre_spawn_hook: if self.pre_spawn_hook:
return self.pre_spawn_hook(self) 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): async def start(self):
"""Start the single-user server """Start the single-user server

View File

@@ -1,4 +1,5 @@
alembic alembic
async_generator>=1.8
traitlets>=4.3.2 traitlets>=4.3.2
tornado>=4.1 tornado>=4.1
jinja2 jinja2

View File

@@ -12,3 +12,17 @@
.hidden { .hidden {
display: none; 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);
// }

View File

@@ -10,9 +10,20 @@
<p>You will be redirected automatically when it's ready for you.</p> <p>You will be redirected automatically when it's ready for you.</p>
{% endblock %} {% endblock %}
<p><i class="fa fa-spinner fa-pulse fa-fw fa-3x" aria-hidden="true"></i></p> <p><i class="fa fa-spinner fa-pulse fa-fw fa-3x" aria-hidden="true"></i></p>
<a role="button" id="refresh" class="btn btn-lg btn-primary" href="#">refresh</a> <div class="progress">
</div> <div id="progress-bar" class="progress-bar" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="width: 0%;">
<span class="sr-only"><span id="sr-progress">0%</span> Complete</span>
</div>
</div>
<p id="progress-message"></p>
</div> </div>
<div class="row">
<div class="col-md-8 col-md-offset-2">
<details id="progress-details">
<summary>Event log</summary>
<div id="progress-log"></div>
</details>
</div>
</div> </div>
{% endblock %} {% endblock %}
@@ -24,9 +35,56 @@ require(["jquery"], function ($) {
$("#refresh").click(function () { $("#refresh").click(function () {
window.location.reload(); window.location.reload();
}) })
setTimeout(function () {
window.location.reload(); // hook up event-stream for progress
}, 5000); var evtSource = new EventSource("{{ progress_url }}");
var progressMessage = $("#progress-message");
var progressBar = $("#progress-bar");
var srProgress = $("#sr-progress");
var progressLog = $("#progress-log");
evtSource.onmessage = function(e) {
var evt = JSON.parse(e.data);
console.log(evt);
if (evt.progress !== undefined) {
// update progress
var progText = evt.progress.toString();
progressBar.attr('aria-valuenow', progText);
srProgress.text(progText + '%');
progressBar.css('width', progText + '%');
}
// update message
var html_message;
if (evt.html_message !== undefined) {
progressMessage.html(evt.html_message);
html_message = evt.html_message;
} else if (evt.message !== undefined) {
progressMessage.text(evt.message);
html_message = progressMessage.html();
}
if (html_message) {
progressLog.append(
$("<div>")
.addClass('progress-log-event')
.html(html_message)
);
}
if (evt.ready) {
// reload the current page
// which should result in a redirect to the running server
window.location.reload();
}
if (evt.failed) {
evtSource.close();
// turn progress bar red
progressBar.addClass('progress-bar-danger');
// open event log for debugging
$('progress-details').prop('open', true);
}
}
}); });
</script> </script>
{% endblock %} {% endblock %}