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):
|
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.
|
||||||
|
@@ -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),
|
||||||
|
@@ -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
|
||||||
|
|
||||||
|
@@ -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
|
||||||
|
|
||||||
|
@@ -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
|
||||||
|
@@ -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);
|
||||||
|
// }
|
||||||
|
@@ -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 %}
|
||||||
|
Reference in New Issue
Block a user