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):
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.

View File

@@ -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 <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 = [
(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),

View File

@@ -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

View File

@@ -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
@@ -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

View File

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

View File

@@ -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);
// }

View File

@@ -10,9 +10,20 @@
<p>You will be redirected automatically when it's ready for you.</p>
{% endblock %}
<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>
<div class="progress">
<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 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>
{% endblock %}
@@ -24,9 +35,56 @@ require(["jquery"], function ($) {
$("#refresh").click(function () {
window.location.reload();
})
setTimeout(function () {
window.location.reload();
}, 5000);
// hook up event-stream for progress
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>
{% endblock %}