mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-15 05:53:00 +00:00
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:
@@ -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.
|
||||
|
@@ -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),
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -1,4 +1,5 @@
|
||||
alembic
|
||||
async_generator>=1.8
|
||||
traitlets>=4.3.2
|
||||
tornado>=4.1
|
||||
jinja2
|
||||
|
@@ -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);
|
||||
// }
|
||||
|
@@ -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 %}
|
||||
|
Reference in New Issue
Block a user