mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-09 19:13:03 +00:00
Compare commits
23 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
8249ef69f0 | ||
![]() |
c63605425f | ||
![]() |
5b57900c0b | ||
![]() |
d0afdabd4c | ||
![]() |
618746fa00 | ||
![]() |
e7bc6c2ba9 | ||
![]() |
e9f86cd602 | ||
![]() |
6e8517f795 | ||
![]() |
5fa540bea1 | ||
![]() |
99f597887c | ||
![]() |
352526c36a | ||
![]() |
cbbed04eed | ||
![]() |
b2756fb18c | ||
![]() |
37b88029e4 | ||
![]() |
4b7413184e | ||
![]() |
41ef0da180 | ||
![]() |
a4a8b3fa2c | ||
![]() |
02e5984f34 | ||
![]() |
b91c5a489c | ||
![]() |
c47c3b2f9e | ||
![]() |
eaa1353dcd | ||
![]() |
b9a3b0a66a | ||
![]() |
929b805fae |
@@ -13,7 +13,7 @@
|
|||||||
[](https://pypi.python.org/pypi/jupyterhub)
|
[](https://pypi.python.org/pypi/jupyterhub)
|
||||||
[](https://www.npmjs.com/package/jupyterhub)
|
[](https://www.npmjs.com/package/jupyterhub)
|
||||||
[](https://jupyterhub.readthedocs.org/en/latest/)
|
[](https://jupyterhub.readthedocs.org/en/latest/)
|
||||||
[](https://travis-ci.org/jupyterhub/jupyterhub)
|
[](https://travis-ci.com/jupyterhub/jupyterhub)
|
||||||
[](https://hub.docker.com/r/jupyterhub/jupyterhub/tags)
|
[](https://hub.docker.com/r/jupyterhub/jupyterhub/tags)
|
||||||
[](https://circleci.com/gh/jupyterhub/jupyterhub)<!-- CircleCI Token: b5b65862eb2617b9a8d39e79340b0a6b816da8cc -->
|
[](https://circleci.com/gh/jupyterhub/jupyterhub)<!-- CircleCI Token: b5b65862eb2617b9a8d39e79340b0a6b816da8cc -->
|
||||||
[](https://codecov.io/gh/jupyterhub/jupyterhub)
|
[](https://codecov.io/gh/jupyterhub/jupyterhub)
|
||||||
|
File diff suppressed because one or more lines are too long
@@ -21,7 +21,7 @@ Here is a quick breakdown of these three tools:
|
|||||||
* **The Jupyter Notebook** is a document specification (the `.ipynb`) file that interweaves
|
* **The Jupyter Notebook** is a document specification (the `.ipynb`) file that interweaves
|
||||||
narrative text with code cells and their outputs. It is also a graphical interface
|
narrative text with code cells and their outputs. It is also a graphical interface
|
||||||
that allows users to edit these documents. There are also several other graphical interfaces
|
that allows users to edit these documents. There are also several other graphical interfaces
|
||||||
that allow users to edit the `.ipynb` format (nteract, Jupyer Lab, Google Colab, Kaggle, etc).
|
that allow users to edit the `.ipynb` format (nteract, Jupyter Lab, Google Colab, Kaggle, etc).
|
||||||
* **JupyterLab** is a flexible and extendible user interface for interactive computing. It
|
* **JupyterLab** is a flexible and extendible user interface for interactive computing. It
|
||||||
has several extensions that are tailored for using Jupyter Notebooks, as well as extensions
|
has several extensions that are tailored for using Jupyter Notebooks, as well as extensions
|
||||||
for other parts of the data science stack.
|
for other parts of the data science stack.
|
||||||
|
@@ -6,7 +6,7 @@ version_info = (
|
|||||||
1,
|
1,
|
||||||
2,
|
2,
|
||||||
0,
|
0,
|
||||||
"b1", # release (b1, rc1, or "" for final or dev)
|
# "b1", # release (b1, rc1, or "" for final or dev)
|
||||||
# "dev", # dev or nothing for beta/rc/stable releases
|
# "dev", # dev or nothing for beta/rc/stable releases
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@@ -274,9 +274,26 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
|
|||||||
uri, http_method, body, headers = self.extract_oauth_params()
|
uri, http_method, body, headers = self.extract_oauth_params()
|
||||||
referer = self.request.headers.get('Referer', 'no referer')
|
referer = self.request.headers.get('Referer', 'no referer')
|
||||||
full_url = self.request.full_url()
|
full_url = self.request.full_url()
|
||||||
if referer != full_url:
|
# trim protocol, which cannot be trusted with multiple layers of proxies anyway
|
||||||
|
# Referer is set by browser, but full_url can be modified by proxy layers to appear as http
|
||||||
|
# when it is actually https
|
||||||
|
referer_proto, _, stripped_referer = referer.partition("://")
|
||||||
|
referer_proto = referer_proto.lower()
|
||||||
|
req_proto, _, stripped_full_url = full_url.partition("://")
|
||||||
|
req_proto = req_proto.lower()
|
||||||
|
if referer_proto != req_proto:
|
||||||
|
self.log.warning("Protocol mismatch: %s != %s", referer, full_url)
|
||||||
|
if req_proto == "https":
|
||||||
|
# insecure origin to secure target is not allowed
|
||||||
|
raise web.HTTPError(
|
||||||
|
403, "Not allowing authorization form submitted from insecure page"
|
||||||
|
)
|
||||||
|
if stripped_referer != stripped_full_url:
|
||||||
# OAuth post must be made to the URL it came from
|
# OAuth post must be made to the URL it came from
|
||||||
self.log.error("OAuth POST from %s != %s", referer, full_url)
|
self.log.error("Original OAuth POST from %s != %s", referer, full_url)
|
||||||
|
self.log.error(
|
||||||
|
"Stripped OAuth POST from %s != %s", stripped_referer, stripped_full_url
|
||||||
|
)
|
||||||
raise web.HTTPError(
|
raise web.HTTPError(
|
||||||
403, "Authorization form must be sent from authorization page"
|
403, "Authorization form must be sent from authorization page"
|
||||||
)
|
)
|
||||||
|
@@ -77,6 +77,7 @@ from .user import UserDict
|
|||||||
from .oauth.provider import make_provider
|
from .oauth.provider import make_provider
|
||||||
from ._data import DATA_FILES_PATH
|
from ._data import DATA_FILES_PATH
|
||||||
from .log import CoroutineLogFormatter, log_request
|
from .log import CoroutineLogFormatter, log_request
|
||||||
|
from .pagination import Pagination
|
||||||
from .proxy import Proxy, ConfigurableHTTPProxy
|
from .proxy import Proxy, ConfigurableHTTPProxy
|
||||||
from .traitlets import URLPrefix, Command, EntryPointType, Callable
|
from .traitlets import URLPrefix, Command, EntryPointType, Callable
|
||||||
from .utils import (
|
from .utils import (
|
||||||
@@ -279,7 +280,7 @@ class JupyterHub(Application):
|
|||||||
|
|
||||||
@default('classes')
|
@default('classes')
|
||||||
def _load_classes(self):
|
def _load_classes(self):
|
||||||
classes = [Spawner, Authenticator, CryptKeeper]
|
classes = [Spawner, Authenticator, CryptKeeper, Pagination]
|
||||||
for name, trait in self.traits(config=True).items():
|
for name, trait in self.traits(config=True).items():
|
||||||
# load entry point groups into configurable class list
|
# load entry point groups into configurable class list
|
||||||
# so that they show up in config files, etc.
|
# so that they show up in config files, etc.
|
||||||
|
@@ -453,7 +453,7 @@ class AdminHandler(BaseHandler):
|
|||||||
@web.authenticated
|
@web.authenticated
|
||||||
@admin_only
|
@admin_only
|
||||||
async def get(self):
|
async def get(self):
|
||||||
page, per_page, offset = Pagination.get_page_args(self)
|
page, per_page, offset = Pagination(config=self.config).get_page_args(self)
|
||||||
|
|
||||||
available = {'name', 'admin', 'running', 'last_activity'}
|
available = {'name', 'admin', 'running', 'last_activity'}
|
||||||
default_sort = ['admin', 'name']
|
default_sort = ['admin', 'name']
|
||||||
@@ -511,7 +511,11 @@ class AdminHandler(BaseHandler):
|
|||||||
|
|
||||||
total = self.db.query(orm.User.id).count()
|
total = self.db.query(orm.User.id).count()
|
||||||
pagination = Pagination(
|
pagination = Pagination(
|
||||||
url=self.request.uri, total=total, page=page, per_page=per_page,
|
url=self.request.uri,
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
per_page=per_page,
|
||||||
|
config=self.config,
|
||||||
)
|
)
|
||||||
|
|
||||||
auth_state = await self.current_user.get_auth_state()
|
auth_state = await self.current_user.get_auth_state()
|
||||||
|
@@ -1,69 +1,94 @@
|
|||||||
"""Basic class to manage pagination utils."""
|
"""Basic class to manage pagination utils."""
|
||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
from traitlets import Bool
|
||||||
|
from traitlets import default
|
||||||
|
from traitlets import Integer
|
||||||
|
from traitlets import observe
|
||||||
|
from traitlets import Unicode
|
||||||
|
from traitlets import validate
|
||||||
|
from traitlets.config import Configurable
|
||||||
|
|
||||||
|
|
||||||
class Pagination:
|
class Pagination(Configurable):
|
||||||
|
|
||||||
_page_name = 'page'
|
# configurable options
|
||||||
_per_page_name = 'per_page'
|
default_per_page = Integer(
|
||||||
_default_page = 1
|
100,
|
||||||
_default_per_page = 100
|
config=True,
|
||||||
_max_per_page = 250
|
help="Default number of entries per page for paginated results.",
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
max_per_page = Integer(
|
||||||
"""Potential parameters.
|
250,
|
||||||
**url**: URL in request
|
config=True,
|
||||||
**page**: current page in use
|
help="Maximum number of entries per page for paginated results.",
|
||||||
**per_page**: number of records to display in the page. By default 100
|
)
|
||||||
**total**: total records considered while paginating
|
|
||||||
"""
|
|
||||||
self.page = kwargs.get(self._page_name, 1)
|
|
||||||
|
|
||||||
if self.per_page > self._max_per_page:
|
# state variables
|
||||||
self.per_page = self._max_per_page
|
url = Unicode("")
|
||||||
|
page = Integer(1)
|
||||||
|
per_page = Integer(1, min=1)
|
||||||
|
|
||||||
self.total = int(kwargs.get('total', 0))
|
@default("per_page")
|
||||||
self.url = kwargs.get('url') or self.get_url()
|
def _default_per_page(self):
|
||||||
self.init_values()
|
return self.default_per_page
|
||||||
|
|
||||||
def init_values(self):
|
@validate("per_page")
|
||||||
self._cached = {}
|
def _limit_per_page(self, proposal):
|
||||||
self.skip = (self.page - 1) * self.per_page
|
if self.max_per_page and proposal.value > self.max_per_page:
|
||||||
pages = divmod(self.total, self.per_page)
|
return self.max_per_page
|
||||||
self.total_pages = pages[0] + 1 if pages[1] else pages[0]
|
if proposal.value <= 1:
|
||||||
|
return 1
|
||||||
|
return proposal.value
|
||||||
|
|
||||||
self.has_prev = self.page > 1
|
@observe("max_per_page")
|
||||||
self.has_next = self.page < self.total_pages
|
def _apply_max(self, change):
|
||||||
|
if change.new:
|
||||||
|
self.per_page = min(change.new, self.per_page)
|
||||||
|
|
||||||
|
total = Integer(0)
|
||||||
|
|
||||||
|
total_pages = Integer(0)
|
||||||
|
|
||||||
|
@default("total_pages")
|
||||||
|
def _calculate_total_pages(self):
|
||||||
|
total_pages = self.total // self.per_page
|
||||||
|
if self.total % self.per_page:
|
||||||
|
# there's a remainder, add 1
|
||||||
|
total_pages += 1
|
||||||
|
return total_pages
|
||||||
|
|
||||||
|
@observe("per_page", "total")
|
||||||
|
def _update_total_pages(self, change):
|
||||||
|
"""Update total_pages when per_page or total is changed"""
|
||||||
|
self.total_pages = self._calculate_total_pages()
|
||||||
|
|
||||||
|
separator = Unicode("...")
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_page_args(self, handler):
|
def get_page_args(self, handler):
|
||||||
"""
|
"""
|
||||||
This method gets the arguments used in the webpage to configurate the pagination
|
This method gets the arguments used in the webpage to configurate the pagination
|
||||||
In case of no arguments, it uses the default values from this class
|
In case of no arguments, it uses the default values from this class
|
||||||
|
|
||||||
It returns:
|
Returns:
|
||||||
- self.page: The page requested for paginating or the default value (1)
|
- page: The page requested for paginating or the default value (1)
|
||||||
- self.per_page: The number of items to return in this page. By default 100 and no more than 250
|
- per_page: The number of items to return in this page. No more than max_per_page
|
||||||
- self.per_page * (self.page - 1): The offset to consider when managing pagination via the ORM
|
- offset: The offset to consider when managing pagination via the ORM
|
||||||
"""
|
"""
|
||||||
self.page = handler.get_argument(self._page_name, self._default_page)
|
page = handler.get_argument("page", 1)
|
||||||
self.per_page = handler.get_argument(
|
per_page = handler.get_argument("per_page", self.default_per_page)
|
||||||
self._per_page_name, self._default_per_page
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
self.per_page = int(self.per_page)
|
self.per_page = int(per_page)
|
||||||
if self.per_page > self._max_per_page:
|
except Exception:
|
||||||
self.per_page = self._max_per_page
|
|
||||||
except:
|
|
||||||
self.per_page = self._default_per_page
|
self.per_page = self._default_per_page
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.page = int(self.page)
|
self.page = int(page)
|
||||||
if self.page < 1:
|
if self.page < 1:
|
||||||
self.page = self._default_page
|
self.page = 1
|
||||||
except:
|
except:
|
||||||
self.page = self._default_page
|
self.page = 1
|
||||||
|
|
||||||
return self.page, self.per_page, self.per_page * (self.page - 1)
|
return self.page, self.per_page, self.per_page * (self.page - 1)
|
||||||
|
|
||||||
@@ -91,38 +116,44 @@ class Pagination:
|
|||||||
(in case the current page + 5 does not overflow the total lenght of pages) and the first one for reference.
|
(in case the current page + 5 does not overflow the total lenght of pages) and the first one for reference.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
self.separator_character = '...'
|
before_page = 2
|
||||||
default_pages_to_render = 7
|
after_page = 2
|
||||||
after_page = 5
|
window_size = before_page + after_page + 1
|
||||||
before_end = 2
|
|
||||||
|
|
||||||
# Add 1 to self.total_pages since our default page is 1 and not 0
|
# Add 1 to total_pages since our starting page is 1 and not 0
|
||||||
total_pages = self.total_pages + 1
|
last_page = self.total_pages
|
||||||
|
|
||||||
pages = []
|
pages = []
|
||||||
|
|
||||||
if total_pages > default_pages_to_render:
|
# will default window + start, end fit without truncation?
|
||||||
if self.page > 1:
|
if self.total_pages > window_size + 2:
|
||||||
pages.extend([1, '...'])
|
if self.page - before_page > 1:
|
||||||
|
# before_page will not reach page 1
|
||||||
|
pages.append(1)
|
||||||
|
if self.page - before_page > 2:
|
||||||
|
# before_page will not reach page 2, need separator
|
||||||
|
pages.append(self.separator)
|
||||||
|
|
||||||
if total_pages < self.page + after_page:
|
pages.extend(range(max(1, self.page - before_page), self.page))
|
||||||
pages.extend(list(range(self.page, total_pages)))
|
# we now have up to but not including self.page
|
||||||
|
|
||||||
|
if self.page + after_page + 1 >= last_page:
|
||||||
|
# after_page gets us to the end
|
||||||
|
pages.extend(range(self.page, last_page + 1))
|
||||||
else:
|
else:
|
||||||
if total_pages >= self.page + after_page + before_end:
|
# add full after_page entries
|
||||||
pages.extend(list(range(self.page, self.page + after_page)))
|
pages.extend(range(self.page, self.page + after_page + 1))
|
||||||
pages.append('...')
|
# add separator *if* this doesn't get to last page - 1
|
||||||
pages.extend(list(range(total_pages - before_end, total_pages)))
|
if self.page + after_page < last_page - 1:
|
||||||
else:
|
pages.append(self.separator)
|
||||||
pages.extend(list(range(self.page, self.page + after_page)))
|
pages.append(last_page)
|
||||||
if self.page + after_page < total_pages:
|
|
||||||
# show only last page when the after_page window left space to show it
|
|
||||||
pages.append('...')
|
|
||||||
pages.extend(list(range(total_pages - 1, total_pages)))
|
|
||||||
|
|
||||||
return pages
|
return pages
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return list(range(1, total_pages))
|
# everything will fit, nothing to think about
|
||||||
|
# always return at least one page
|
||||||
|
return list(range(1, last_page + 1)) or [1]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def links(self):
|
def links(self):
|
||||||
@@ -155,9 +186,11 @@ class Pagination:
|
|||||||
page=page
|
page=page
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
elif page == self.separator_character:
|
elif page == self.separator:
|
||||||
links.append(
|
links.append(
|
||||||
'<li class="disabled"><span> <span aria-hidden="true">...</span></span></li>'
|
'<li class="disabled"><span> <span aria-hidden="true">{separator}</span></span></li>'.format(
|
||||||
|
separator=self.separator
|
||||||
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
links.append(
|
links.append(
|
||||||
|
@@ -43,6 +43,7 @@ from . import utils
|
|||||||
from .metrics import CHECK_ROUTES_DURATION_SECONDS
|
from .metrics import CHECK_ROUTES_DURATION_SECONDS
|
||||||
from .metrics import PROXY_POLL_DURATION_SECONDS
|
from .metrics import PROXY_POLL_DURATION_SECONDS
|
||||||
from .objects import Server
|
from .objects import Server
|
||||||
|
from .utils import exponential_backoff
|
||||||
from .utils import make_ssl_context
|
from .utils import make_ssl_context
|
||||||
from .utils import url_path_join
|
from .utils import url_path_join
|
||||||
from jupyterhub.traitlets import Command
|
from jupyterhub.traitlets import Command
|
||||||
@@ -768,9 +769,35 @@ class ConfigurableHTTPProxy(Proxy):
|
|||||||
method=method,
|
method=method,
|
||||||
headers={'Authorization': 'token {}'.format(self.auth_token)},
|
headers={'Authorization': 'token {}'.format(self.auth_token)},
|
||||||
body=body,
|
body=body,
|
||||||
|
connect_timeout=3, # default: 20s
|
||||||
|
request_timeout=10, # default: 20s
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _wait_for_api_request():
|
||||||
|
try:
|
||||||
async with self.semaphore:
|
async with self.semaphore:
|
||||||
result = await client.fetch(req)
|
return await client.fetch(req)
|
||||||
|
except HTTPError as e:
|
||||||
|
# Retry on potentially transient errors in CHP, typically
|
||||||
|
# numbered 500 and up. Note that CHP isn't able to emit 429
|
||||||
|
# errors.
|
||||||
|
if e.code >= 500:
|
||||||
|
self.log.warning(
|
||||||
|
"api_request to the proxy failed with status code {}, retrying...".format(
|
||||||
|
e.code
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return False # a falsy return value make exponential_backoff retry
|
||||||
|
else:
|
||||||
|
self.log.error("api_request to proxy failed: {0}".format(e))
|
||||||
|
# An unhandled error here will help the hub invoke cleanup logic
|
||||||
|
raise
|
||||||
|
|
||||||
|
result = await exponential_backoff(
|
||||||
|
_wait_for_api_request,
|
||||||
|
'Repeated api_request to proxy path "{}" failed.'.format(path),
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def add_route(self, routespec, target, data):
|
async def add_route(self, routespec, target, data):
|
||||||
|
45
jupyterhub/tests/test_pagination.py
Normal file
45
jupyterhub/tests/test_pagination.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""tests for pagination"""
|
||||||
|
from pytest import mark
|
||||||
|
from pytest import raises
|
||||||
|
from traitlets.config import Config
|
||||||
|
|
||||||
|
from jupyterhub.pagination import Pagination
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_page_bounds():
|
||||||
|
cfg = Config()
|
||||||
|
cfg.Pagination.max_per_page = 10
|
||||||
|
p = Pagination(config=cfg, per_page=20, total=100)
|
||||||
|
assert p.per_page == 10
|
||||||
|
with raises(Exception):
|
||||||
|
p.per_page = 0
|
||||||
|
|
||||||
|
|
||||||
|
@mark.parametrize(
|
||||||
|
"page, per_page, total, expected",
|
||||||
|
[
|
||||||
|
(1, 10, 99, [1, 2, 3, "...", 10]),
|
||||||
|
(2, 10, 99, [1, 2, 3, 4, "...", 10]),
|
||||||
|
(3, 10, 99, [1, 2, 3, 4, 5, "...", 10]),
|
||||||
|
(4, 10, 99, [1, 2, 3, 4, 5, 6, "...", 10]),
|
||||||
|
(5, 10, 99, [1, "...", 3, 4, 5, 6, 7, "...", 10]),
|
||||||
|
(6, 10, 99, [1, "...", 4, 5, 6, 7, 8, "...", 10]),
|
||||||
|
(7, 10, 99, [1, "...", 5, 6, 7, 8, 9, 10]),
|
||||||
|
(8, 10, 99, [1, "...", 6, 7, 8, 9, 10]),
|
||||||
|
(9, 10, 99, [1, "...", 7, 8, 9, 10]),
|
||||||
|
(1, 20, 99, [1, 2, 3, 4, 5]),
|
||||||
|
(1, 10, 0, [1]),
|
||||||
|
(1, 10, 1, [1]),
|
||||||
|
(1, 10, 10, [1]),
|
||||||
|
(1, 10, 11, [1, 2]),
|
||||||
|
(1, 10, 50, [1, 2, 3, 4, 5]),
|
||||||
|
(1, 10, 60, [1, 2, 3, 4, 5, 6]),
|
||||||
|
(1, 10, 70, [1, 2, 3, 4, 5, 6, 7]),
|
||||||
|
(1, 10, 80, [1, 2, 3, "...", 8]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_window(page, per_page, total, expected):
|
||||||
|
cfg = Config()
|
||||||
|
cfg.Pagination
|
||||||
|
pagination = Pagination(page=page, per_page=per_page, total=total)
|
||||||
|
assert pagination.calculate_pages_window() == expected
|
Reference in New Issue
Block a user