mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-07 18:14:10 +00:00
Compare commits
113 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
9749b6eb6a | ||
![]() |
979b47d1e0 | ||
![]() |
c12ccafe22 | ||
![]() |
acc51dbe24 | ||
![]() |
51dcbe4c80 | ||
![]() |
6da70e9960 | ||
![]() |
1cb98ce9ff | ||
![]() |
f2ecf6a307 | ||
![]() |
0a4c3bbfd3 | ||
![]() |
e4ae7ce4fe | ||
![]() |
ab43f6beb8 | ||
![]() |
e8806372c6 | ||
![]() |
6e353df033 | ||
![]() |
06507b426d | ||
![]() |
e282205139 | ||
![]() |
e4ff84b7c9 | ||
![]() |
8c4dbd7a32 | ||
![]() |
1336df621b | ||
![]() |
b66931306e | ||
![]() |
83003c7e3d | ||
![]() |
23b9400c53 | ||
![]() |
98e9117633 | ||
![]() |
b2d9f93601 | ||
![]() |
61c39972da | ||
![]() |
08f6ff52b0 | ||
![]() |
949496eb36 | ||
![]() |
7af4cc2fa9 | ||
![]() |
3d60ad3956 | ||
![]() |
689a5ba190 | ||
![]() |
80b9f02332 | ||
![]() |
8bd1219b92 | ||
![]() |
4ea74c4869 | ||
![]() |
24fb08d513 | ||
![]() |
6b22599149 | ||
![]() |
70ca293977 | ||
![]() |
aeaffa654f | ||
![]() |
86e4f42035 | ||
![]() |
6ccb809a2a | ||
![]() |
992bc98ff1 | ||
![]() |
43597febcb | ||
![]() |
6464e3629c | ||
![]() |
62d2a4bec2 | ||
![]() |
6e3913456b | ||
![]() |
de39fda9a7 | ||
![]() |
abca5546b7 | ||
![]() |
1b87e9c668 | ||
![]() |
70561c8727 | ||
![]() |
b13d3afa0f | ||
![]() |
5f6748abd4 | ||
![]() |
8b944a3293 | ||
![]() |
5dddd97132 | ||
![]() |
20a600ffa0 | ||
![]() |
de2841e00d | ||
![]() |
33af239911 | ||
![]() |
2aeb49690b | ||
![]() |
265fcbc874 | ||
![]() |
98a6338247 | ||
![]() |
d519bacd8a | ||
![]() |
ad39fe3823 | ||
![]() |
aca10da71d | ||
![]() |
e8b2bd82c8 | ||
![]() |
5616ade51d | ||
![]() |
b83f6d178b | ||
![]() |
3068e3911b | ||
![]() |
6867f3b141 | ||
![]() |
aec601dbff | ||
![]() |
748b6c98d5 | ||
![]() |
d6d03e8e38 | ||
![]() |
14d32c5bae | ||
![]() |
653922605a | ||
![]() |
52f5aacce1 | ||
![]() |
e00ef75f15 | ||
![]() |
50879db41c | ||
![]() |
8c4a170f4e | ||
![]() |
f36e5420f5 | ||
![]() |
27d83dd6c2 | ||
![]() |
aa43ce85bd | ||
![]() |
53205764ca | ||
![]() |
a7fc94c22a | ||
![]() |
9419c7f2c0 | ||
![]() |
73e0d7092e | ||
![]() |
562f86026d | ||
![]() |
3a64eb85a8 | ||
![]() |
e4340a467c | ||
![]() |
f8c00092d2 | ||
![]() |
bd00f376d7 | ||
![]() |
99b32dd372 | ||
![]() |
7a94830a29 | ||
![]() |
eeb867947a | ||
![]() |
ccac4aa53f | ||
![]() |
38c313eef7 | ||
![]() |
251aa1f12c | ||
![]() |
b6b596cd34 | ||
![]() |
2391d0f764 | ||
![]() |
959cd5a6e1 | ||
![]() |
036dcb644c | ||
![]() |
bdc7ee40f4 | ||
![]() |
5383a60d4a | ||
![]() |
78649b9118 | ||
![]() |
e63ec9aedc | ||
![]() |
6be699c333 | ||
![]() |
a377f8bc7f | ||
![]() |
7ba36ef760 | ||
![]() |
6f13355446 | ||
![]() |
a5f08035a2 | ||
![]() |
3d0256a757 | ||
![]() |
cca7cc6e92 | ||
![]() |
3ab54e6eeb | ||
![]() |
ce7e532ab6 | ||
![]() |
da79a89f22 | ||
![]() |
d75bcc03c0 | ||
![]() |
a03fd54982 | ||
![]() |
f4fa229645 |
41
.github/dependabot.yaml
vendored
41
.github/dependabot.yaml
vendored
@@ -14,3 +14,44 @@ updates:
|
|||||||
interval: monthly
|
interval: monthly
|
||||||
time: "05:00"
|
time: "05:00"
|
||||||
timezone: Etc/UTC
|
timezone: Etc/UTC
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: /
|
||||||
|
groups:
|
||||||
|
# one big pull request for minor bumps
|
||||||
|
npm-minor:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
update-types:
|
||||||
|
- minor
|
||||||
|
- patch
|
||||||
|
schedule:
|
||||||
|
interval: monthly
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: /jsx
|
||||||
|
groups:
|
||||||
|
# one big pull request for minor bumps
|
||||||
|
jsx-minor:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
update-types:
|
||||||
|
- minor
|
||||||
|
- patch
|
||||||
|
# group major bumps of react-related dependencies
|
||||||
|
jsx-react:
|
||||||
|
patterns:
|
||||||
|
- "react*"
|
||||||
|
- "redux*"
|
||||||
|
- "*react"
|
||||||
|
- "recompose"
|
||||||
|
update-types:
|
||||||
|
- major
|
||||||
|
# group major bumps of webpack-related dependencies
|
||||||
|
jsx-webpack:
|
||||||
|
patterns:
|
||||||
|
- "webpack*"
|
||||||
|
- "@babel/*"
|
||||||
|
- "*-loader"
|
||||||
|
update-types:
|
||||||
|
- major
|
||||||
|
schedule:
|
||||||
|
interval: monthly
|
||||||
|
8
.github/workflows/test-docs.yml
vendored
8
.github/workflows/test-docs.yml
vendored
@@ -81,10 +81,12 @@ jobs:
|
|||||||
cd docs
|
cd docs
|
||||||
make html
|
make html
|
||||||
|
|
||||||
|
# Output broken and permanently redirected links in a readable format
|
||||||
- name: check links
|
- name: check links
|
||||||
run: |
|
uses: manics/action-sphinx-linkcheck-summary@main
|
||||||
cd docs
|
with:
|
||||||
make linkcheck
|
docs-dir: docs
|
||||||
|
build-dir: docs/_build
|
||||||
|
|
||||||
# make rediraffecheckdiff compares files for different changesets
|
# make rediraffecheckdiff compares files for different changesets
|
||||||
# these diff targets aren't always available
|
# these diff targets aren't always available
|
||||||
|
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -173,7 +173,7 @@ jobs:
|
|||||||
# make sure our `>=` pins really do express our minimum supported versions
|
# make sure our `>=` pins really do express our minimum supported versions
|
||||||
pip install -r ci/oldest-dependencies/requirements.old -e .
|
pip install -r ci/oldest-dependencies/requirements.old -e .
|
||||||
else
|
else
|
||||||
pip install -e ".[test]"
|
pip install --pre -e ".[test]"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "${{ matrix.main_dependencies }}" != "" ]; then
|
if [ "${{ matrix.main_dependencies }}" != "" ]; then
|
||||||
|
@@ -16,7 +16,7 @@ ci:
|
|||||||
repos:
|
repos:
|
||||||
# autoformat and lint Python code
|
# autoformat and lint Python code
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.5.0
|
rev: v0.6.3
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
types_or:
|
types_or:
|
||||||
@@ -37,7 +37,7 @@ repos:
|
|||||||
|
|
||||||
# autoformat HTML templates
|
# autoformat HTML templates
|
||||||
- repo: https://github.com/djlint/djLint
|
- repo: https://github.com/djlint/djLint
|
||||||
rev: v1.34.1
|
rev: v1.35.2
|
||||||
hooks:
|
hooks:
|
||||||
- id: djlint-reformat-jinja
|
- id: djlint-reformat-jinja
|
||||||
files: ".*templates/.*.html"
|
files: ".*templates/.*.html"
|
||||||
|
@@ -7,7 +7,7 @@ info:
|
|||||||
license:
|
license:
|
||||||
name: BSD-3-Clause
|
name: BSD-3-Clause
|
||||||
identifier: BSD-3-Clause
|
identifier: BSD-3-Clause
|
||||||
version: 5.1.0
|
version: 5.2.1
|
||||||
servers:
|
servers:
|
||||||
- url: /hub/api
|
- url: /hub/api
|
||||||
security:
|
security:
|
||||||
|
@@ -3,7 +3,7 @@
|
|||||||
# JupyterHub: A conceptual overview
|
# JupyterHub: A conceptual overview
|
||||||
|
|
||||||
```{warning}
|
```{warning}
|
||||||
This page could is missing cross-links to other parts of
|
This page could be missing cross-links to other parts of
|
||||||
the documentation. You can help by adding them!
|
the documentation. You can help by adding them!
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@@ -66,7 +66,7 @@ industry, and government research labs. It is most-commonly used by two kinds of
|
|||||||
Here is a sample of organizations that use JupyterHub:
|
Here is a sample of organizations that use JupyterHub:
|
||||||
|
|
||||||
- **Universities and colleges**: UC Berkeley, UC San Diego, Cal Poly SLO, Harvard University, University of Chicago,
|
- **Universities and colleges**: UC Berkeley, UC San Diego, Cal Poly SLO, Harvard University, University of Chicago,
|
||||||
University of Oslo, University of Sheffield, Université Paris Sud, University of Versailles
|
University of Oslo, University of Sheffield, Université Paris Sud, University of Versailles, University of Portland
|
||||||
- **Research laboratories**: NASA, NCAR, NOAA, the Large Synoptic Survey Telescope, Brookhaven National Lab,
|
- **Research laboratories**: NASA, NCAR, NOAA, the Large Synoptic Survey Telescope, Brookhaven National Lab,
|
||||||
Minnesota Supercomputing Institute, ALCF, CERN, Lawrence Livermore National Laboratory, HUNT
|
Minnesota Supercomputing Institute, ALCF, CERN, Lawrence Livermore National Laboratory, HUNT
|
||||||
- **Online communities**: Pangeo, Quantopian, mybinder.org, MathHub, Open Humans
|
- **Online communities**: Pangeo, Quantopian, mybinder.org, MathHub, Open Humans
|
||||||
|
@@ -198,6 +198,23 @@ With a docker container, pass in the environment variable with the run command:
|
|||||||
|
|
||||||
[This example](https://github.com/jupyterhub/jupyterhub/tree/HEAD/examples/service-notebook/external) demonstrates how to combine the use of the `jupyterhub-singleuser` environment variables when launching a Notebook as an externally managed service.
|
[This example](https://github.com/jupyterhub/jupyterhub/tree/HEAD/examples/service-notebook/external) demonstrates how to combine the use of the `jupyterhub-singleuser` environment variables when launching a Notebook as an externally managed service.
|
||||||
|
|
||||||
|
### Jupyter Notebook/Lab can be launched, but notebooks seem to hang when trying to execute a cell
|
||||||
|
|
||||||
|
This often occurs when your browser is unable to open a websocket connection to a Jupyter kernel.
|
||||||
|
|
||||||
|
#### Diagnose
|
||||||
|
|
||||||
|
Open your browser console, e.g. [Chrome](https://developer.chrome.com/docs/devtools/console), [Firefox](https://firefox-source-docs.mozilla.org/devtools-user/web_console/).
|
||||||
|
If you see errors related to opening websockets this is likely to be the problem.
|
||||||
|
|
||||||
|
#### Solutions
|
||||||
|
|
||||||
|
This could be caused by anything related to the network between your computer/browser and the server running JupyterHub, such as:
|
||||||
|
|
||||||
|
- reverse proxies (see {ref}`howto:config:reverse-proxy` for example configurations)
|
||||||
|
- anti-virus or firewalls running on your computer or JupyterHub server
|
||||||
|
- transparent proxies running on your network
|
||||||
|
|
||||||
## How do I...?
|
## How do I...?
|
||||||
|
|
||||||
### Use a chained SSL certificate
|
### Use a chained SSL certificate
|
||||||
@@ -259,17 +276,6 @@ the entire filesystem and set the default to the user's home directory.
|
|||||||
c.Spawner.notebook_dir = '/'
|
c.Spawner.notebook_dir = '/'
|
||||||
c.Spawner.default_url = '/home/%U' # %U will be replaced with the username
|
c.Spawner.default_url = '/home/%U' # %U will be replaced with the username
|
||||||
|
|
||||||
### How do I increase the number of pySpark executors on YARN?
|
|
||||||
|
|
||||||
From the command line, pySpark executors can be configured using a command
|
|
||||||
similar to this one:
|
|
||||||
|
|
||||||
pyspark --total-executor-cores 2 --executor-memory 1G
|
|
||||||
|
|
||||||
[Cloudera documentation for configuring spark on YARN applications](https://www.cloudera.com/documentation/enterprise/latest/topics/cdh_ig_running_spark_on_yarn.html#spark_on_yarn_config_apps)
|
|
||||||
provides additional information. The [pySpark configuration documentation](https://spark.apache.org/docs/0.9.0/configuration.html)
|
|
||||||
is also helpful for programmatic configuration examples.
|
|
||||||
|
|
||||||
### How do I use JupyterLab's pre-release version with JupyterHub?
|
### How do I use JupyterLab's pre-release version with JupyterHub?
|
||||||
|
|
||||||
While JupyterLab is still under active development, we have had users
|
While JupyterLab is still under active development, we have had users
|
||||||
|
@@ -20,6 +20,82 @@ Contributors to major version bumps in JupyterHub include:
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## 5.2
|
||||||
|
|
||||||
|
### 5.2.1 - 2024-10-21
|
||||||
|
|
||||||
|
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/5.2.0...5.2.1))
|
||||||
|
|
||||||
|
#### Enhancements made
|
||||||
|
|
||||||
|
- informative error on missing dependencies for singleuser server [#4934](https://github.com/jupyterhub/jupyterhub/pull/4934) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
|
||||||
|
#### Bugs fixed
|
||||||
|
|
||||||
|
- Abort jupyterhub startup only if managed services fail [#4930](https://github.com/jupyterhub/jupyterhub/pull/4930) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk))
|
||||||
|
- Loaded EntryPointTypes are types, not instances [#4922](https://github.com/jupyterhub/jupyterhub/pull/4922) ([@manics](https://github.com/manics), [@minrk](https://github.com/minrk))
|
||||||
|
|
||||||
|
#### Documentation improvements
|
||||||
|
|
||||||
|
- Remove out-of-date info from subdomain_hook doc [#4932](https://github.com/jupyterhub/jupyterhub/pull/4932) ([@manics](https://github.com/manics), [@minrk](https://github.com/minrk))
|
||||||
|
|
||||||
|
#### Contributors to this release
|
||||||
|
|
||||||
|
The following people contributed discussions, new ideas, code and documentation contributions, and review.
|
||||||
|
See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports).
|
||||||
|
|
||||||
|
([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2024-10-01&to=2024-10-21&type=c))
|
||||||
|
|
||||||
|
@consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2024-10-01..2024-10-21&type=Issues)) | @manics ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amanics+updated%3A2024-10-01..2024-10-21&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2024-10-01..2024-10-21&type=Issues))
|
||||||
|
|
||||||
|
### 5.2.0 - 2024-10-01
|
||||||
|
|
||||||
|
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/5.1.0...5.2.0))
|
||||||
|
|
||||||
|
#### New features added
|
||||||
|
|
||||||
|
- add dark mode toggle [#4897](https://github.com/jupyterhub/jupyterhub/pull/4897) ([@minrk](https://github.com/minrk), [@benz0li](https://github.com/benz0li))
|
||||||
|
- Revoke all permissions from Authenticator.blocked_users [#4865](https://github.com/jupyterhub/jupyterhub/pull/4865) ([@minrk](https://github.com/minrk), [@manics](https://github.com/manics))
|
||||||
|
|
||||||
|
#### Enhancements made
|
||||||
|
|
||||||
|
- Add `LD_LIBRARY_PATH` to `LocalProcessSpawner.env_keep`, move most env_keep defaults to LocalProcessSpawner [#4904](https://github.com/jupyterhub/jupyterhub/pull/4904) ([@edmorley](https://github.com/edmorley), [@minrk](https://github.com/minrk), [@manics](https://github.com/manics))
|
||||||
|
- Add PAMAuthenticator.executor_threads configurable, increase default to 4 [#4863](https://github.com/jupyterhub/jupyterhub/pull/4863) ([@Will-Shanks](https://github.com/Will-Shanks), [@minrk](https://github.com/minrk))
|
||||||
|
|
||||||
|
#### Bugs fixed
|
||||||
|
|
||||||
|
- Fix incorrect rounding function with large spawn_throttle_retry_range upper bound [#4913](https://github.com/jupyterhub/jupyterhub/pull/4913) ([@emmanuel-ferdman](https://github.com/emmanuel-ferdman), [@minrk](https://github.com/minrk))
|
||||||
|
- fix python3 -m jupyterhub.app [#4876](https://github.com/jupyterhub/jupyterhub/pull/4876) ([@minrk](https://github.com/minrk), [@manics](https://github.com/manics))
|
||||||
|
- Admin pages: use inherited base_url from render_template [#4867](https://github.com/jupyterhub/jupyterhub/pull/4867) ([@manics](https://github.com/manics), [@minrk](https://github.com/minrk))
|
||||||
|
- singleuser: fix shutdown mixin [#4864](https://github.com/jupyterhub/jupyterhub/pull/4864) ([@oliver-sanders](https://github.com/oliver-sanders), [@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
|
||||||
|
#### Maintenance and upkeep improvements
|
||||||
|
|
||||||
|
- browser tests: use text_content instead of inner_text [#4906](https://github.com/jupyterhub/jupyterhub/pull/4906) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
- update-types typo in dependabot.yml [#4884](https://github.com/jupyterhub/jupyterhub/pull/4884) ([@minrk](https://github.com/minrk))
|
||||||
|
- only group minor dependencies in dependabot [#4882](https://github.com/jupyterhub/jupyterhub/pull/4882) ([@minrk](https://github.com/minrk))
|
||||||
|
- start metrics collector in start [#4870](https://github.com/jupyterhub/jupyterhub/pull/4870) ([@minrk](https://github.com/minrk), [@manics](https://github.com/manics))
|
||||||
|
- bump require.js [#4868](https://github.com/jupyterhub/jupyterhub/pull/4868) ([@minrk](https://github.com/minrk))
|
||||||
|
- unpin pytest-asyncio [#4664](https://github.com/jupyterhub/jupyterhub/pull/4664) ([@minrk](https://github.com/minrk), [@manics](https://github.com/manics), [@seifertm](https://github.com/seifertm))
|
||||||
|
- Display Sphinx linkcheck output in a more readable format [#4881](https://github.com/jupyterhub/jupyterhub/pull/4881) ([@manics](https://github.com/manics), [@minrk](https://github.com/minrk))
|
||||||
|
- add dependabot config for npm [#4869](https://github.com/jupyterhub/jupyterhub/pull/4869) ([@minrk](https://github.com/minrk), [@manics](https://github.com/manics))
|
||||||
|
|
||||||
|
#### Documentation improvements
|
||||||
|
|
||||||
|
- Fix typo in concepts.md [#4916](https://github.com/jupyterhub/jupyterhub/pull/4916) ([@dirtbirb](https://github.com/dirtbirb), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
- add University of Portland to sample orgs [#4896](https://github.com/jupyterhub/jupyterhub/pull/4896) ([@rinvii](https://github.com/rinvii), [@minrk](https://github.com/minrk))
|
||||||
|
- docs: remove some outdated links [#4883](https://github.com/jupyterhub/jupyterhub/pull/4883) ([@minrk](https://github.com/minrk), [@manics](https://github.com/manics))
|
||||||
|
- FAQ: websocket problems [#4880](https://github.com/jupyterhub/jupyterhub/pull/4880) ([@manics](https://github.com/manics), [@minrk](https://github.com/minrk))
|
||||||
|
|
||||||
|
#### Contributors to this release
|
||||||
|
|
||||||
|
The following people contributed discussions, new ideas, code and documentation contributions, and review.
|
||||||
|
See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports).
|
||||||
|
|
||||||
|
([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2024-07-31&to=2024-10-01&type=c))
|
||||||
|
|
||||||
|
@benz0li ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Abenz0li+updated%3A2024-07-31..2024-10-01&type=Issues)) | @consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2024-07-31..2024-10-01&type=Issues)) | @dirtbirb ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Adirtbirb+updated%3A2024-07-31..2024-10-01&type=Issues)) | @edmorley ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aedmorley+updated%3A2024-07-31..2024-10-01&type=Issues)) | @emmanuel-ferdman ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aemmanuel-ferdman+updated%3A2024-07-31..2024-10-01&type=Issues)) | @jelmd ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ajelmd+updated%3A2024-07-31..2024-10-01&type=Issues)) | @manics ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amanics+updated%3A2024-07-31..2024-10-01&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2024-07-31..2024-10-01&type=Issues)) | @oliver-sanders ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aoliver-sanders+updated%3A2024-07-31..2024-10-01&type=Issues)) | @rinvii ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Arinvii+updated%3A2024-07-31..2024-10-01&type=Issues)) | @seifertm ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aseifertm+updated%3A2024-07-31..2024-10-01&type=Issues)) | @Will-Shanks ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AWill-Shanks+updated%3A2024-07-31..2024-10-01&type=Issues))
|
||||||
|
|
||||||
## 5.1
|
## 5.1
|
||||||
|
|
||||||
### 5.1.0 - 2024-07-31
|
### 5.1.0 - 2024-07-31
|
||||||
|
@@ -82,15 +82,6 @@ Within CERN, there are two noteworthy JupyterHub deployments in operation:
|
|||||||
- Advanced Computing
|
- Advanced Computing
|
||||||
- [Palmetto cluster and JupyterHub](https://citi.sites.clemson.edu/2016/08/18/JupyterHub-for-Palmetto-Cluster.html)
|
- [Palmetto cluster and JupyterHub](https://citi.sites.clemson.edu/2016/08/18/JupyterHub-for-Palmetto-Cluster.html)
|
||||||
|
|
||||||
### University of Colorado Boulder
|
|
||||||
|
|
||||||
- (CU Research Computing) CURC
|
|
||||||
|
|
||||||
- [JupyterHub User Guide](https://curc.readthedocs.io/en/latest/gateways/jupyterhub.html)
|
|
||||||
- Slurm job dispatched on Crestone compute cluster
|
|
||||||
- log troubleshooting
|
|
||||||
- Profiles in IPython Clusters tab
|
|
||||||
|
|
||||||
### ETH Zurich
|
### ETH Zurich
|
||||||
|
|
||||||
[ETH Zurich](https://ethz.ch/en.html), (Federal Institute of Technology Zurich), is a public research university in Zürich, Switzerland, with focus on science, technology, engineering, and mathematics, although its 16 departments span a variety of disciplines and subjects.
|
[ETH Zurich](https://ethz.ch/en.html), (Federal Institute of Technology Zurich), is a public research university in Zürich, Switzerland, with focus on science, technology, engineering, and mathematics, although its 16 departments span a variety of disciplines and subjects.
|
||||||
|
5233
jsx/package-lock.json
generated
5233
jsx/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,9 @@
|
|||||||
"plugins": []
|
"plugins": []
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
|
"fakeTimers": {
|
||||||
|
"enableGlobally": true
|
||||||
|
},
|
||||||
"moduleNameMapper": {
|
"moduleNameMapper": {
|
||||||
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/__mocks__/fileMock.js",
|
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/__mocks__/fileMock.js",
|
||||||
"\\.(css|less)$": "identity-obj-proxy"
|
"\\.(css|less)$": "identity-obj-proxy"
|
||||||
@@ -29,44 +32,44 @@
|
|||||||
"testEnvironment": "jsdom"
|
"testEnvironment": "jsdom"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bootstrap": "^5.2.3",
|
"bootstrap": "^5.3.3",
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"react": "^17.0.2",
|
"react": "^18.3.1",
|
||||||
"react-bootstrap": "^2.10.1",
|
"react-bootstrap": "^2.10.5",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^18.3.1",
|
||||||
"react-icons": "^4.8.0",
|
"react-icons": "^5.3.0",
|
||||||
"react-multi-select-component": "^4.3.4",
|
"react-multi-select-component": "^4.3.4",
|
||||||
"react-redux": "^7.2.8",
|
"react-redux": "^9.1.2",
|
||||||
"react-router-dom": "^6.22.2",
|
"react-router-dom": "^6.26.2",
|
||||||
"recompose": "npm:react-recompose@^0.33.0",
|
"recompose": "npm:react-recompose@^0.33.0",
|
||||||
"redux": "^4.2.1",
|
"redux": "^5.0.1",
|
||||||
"regenerator-runtime": "^0.13.11"
|
"regenerator-runtime": "^0.14.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.21.4",
|
"@babel/core": "^7.21.4",
|
||||||
"@babel/preset-env": "^7.21.4",
|
"@babel/preset-env": "^7.25.4",
|
||||||
"@babel/preset-react": "^7.18.6",
|
"@babel/preset-react": "^7.24.7",
|
||||||
"@testing-library/jest-dom": "^5.16.5",
|
"@testing-library/jest-dom": "^6.5.0",
|
||||||
"@testing-library/react": "^12.1.5",
|
"@testing-library/react": "^16.0.1",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^14.5.2",
|
||||||
"@webpack-cli/serve": "^2.0.1",
|
"@webpack-cli/serve": "^2.0.1",
|
||||||
"babel-jest": "^29.5.0",
|
"babel-jest": "^29.7.0",
|
||||||
"babel-loader": "^9.1.2",
|
"babel-loader": "^9.2.1",
|
||||||
"css-loader": "^6.7.3",
|
"css-loader": "^7.1.2",
|
||||||
"eslint": "^8.38.0",
|
"eslint": "^9.11.1",
|
||||||
"eslint-plugin-prettier": "^4.2.1",
|
"eslint-plugin-prettier": "^5.2.1",
|
||||||
"eslint-plugin-react": "^7.32.2",
|
"eslint-plugin-react": "^7.37.1",
|
||||||
"eslint-plugin-unused-imports": "^2.0.0",
|
"eslint-plugin-unused-imports": "^4.1.4",
|
||||||
"file-loader": "^6.2.0",
|
"file-loader": "^6.2.0",
|
||||||
"identity-obj-proxy": "^3.0.0",
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"jest": "^29.5.0",
|
"jest": "^29.7.0",
|
||||||
"jest-environment-jsdom": "^29.5.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"prettier": "^2.8.7",
|
"prettier": "^3.3.3",
|
||||||
"style-loader": "^3.3.2",
|
"style-loader": "^4.0.0",
|
||||||
"webpack": "^5.79.0",
|
"webpack": "^5.95.0",
|
||||||
"webpack-cli": "^5.0.1",
|
"webpack-cli": "^5.0.1",
|
||||||
"webpack-dev-server": "^4.13.3"
|
"webpack-dev-server": "^5.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom";
|
import { createRoot } from "react-dom/client";
|
||||||
import { Provider } from "react-redux";
|
import { Provider } from "react-redux";
|
||||||
import { createStore } from "redux";
|
import { createStore } from "redux";
|
||||||
import { compose } from "recompose";
|
import { compose } from "recompose";
|
||||||
@@ -40,4 +40,5 @@ const App = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
ReactDOM.render(<App />, document.getElementById("react-admin-hook"));
|
const root = createRoot(document.getElementById("react-admin-hook"));
|
||||||
|
root.render(<App />);
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
import React from "react";
|
import React, { act } from "react";
|
||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import { act } from "react-dom/test-utils";
|
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { Provider, useDispatch, useSelector } from "react-redux";
|
import { Provider, useDispatch, useSelector } from "react-redux";
|
||||||
@@ -46,6 +45,7 @@ beforeEach(() => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
useDispatch.mockClear();
|
useDispatch.mockClear();
|
||||||
|
jest.runAllTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Renders", async () => {
|
test("Renders", async () => {
|
||||||
@@ -67,7 +67,7 @@ test("Removes users when they fail Regex", async () => {
|
|||||||
|
|
||||||
fireEvent.blur(textarea, { target: { value: "foo \n bar\na@b.co\n \n\n" } });
|
fireEvent.blur(textarea, { target: { value: "foo \n bar\na@b.co\n \n\n" } });
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(submit);
|
await fireEvent.click(submit);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(callbackSpy).toHaveBeenCalledWith(["foo", "bar", "a@b.co"], false);
|
expect(callbackSpy).toHaveBeenCalledWith(["foo", "bar", "a@b.co"], false);
|
||||||
@@ -79,15 +79,15 @@ test("Correctly submits admin", async () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
render(addUserJsx(callbackSpy));
|
render(addUserJsx(callbackSpy));
|
||||||
});
|
});
|
||||||
|
|
||||||
let textarea = screen.getByTestId("user-textarea");
|
let textarea = screen.getByTestId("user-textarea");
|
||||||
let submit = screen.getByTestId("submit");
|
let submit = screen.getByTestId("submit");
|
||||||
let check = screen.getByTestId("check");
|
let check = screen.getByTestId("check");
|
||||||
|
|
||||||
userEvent.click(check);
|
await fireEvent.blur(textarea, { target: { value: "foo" } });
|
||||||
fireEvent.blur(textarea, { target: { value: "foo" } });
|
await fireEvent.click(check);
|
||||||
|
await fireEvent.click(submit);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(submit);
|
await jest.runAllTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(callbackSpy).toHaveBeenCalledWith(["foo"], true);
|
expect(callbackSpy).toHaveBeenCalledWith(["foo"], true);
|
||||||
@@ -103,7 +103,7 @@ test("Shows a UI error dialogue when user creation fails", async () => {
|
|||||||
let submit = screen.getByTestId("submit");
|
let submit = screen.getByTestId("submit");
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(submit);
|
await fireEvent.click(submit);
|
||||||
});
|
});
|
||||||
|
|
||||||
let errorDialog = screen.getByText("Failed to create user.");
|
let errorDialog = screen.getByText("Failed to create user.");
|
||||||
@@ -122,7 +122,7 @@ test("Shows a more specific UI error dialogue when user creation returns an impr
|
|||||||
let submit = screen.getByTestId("submit");
|
let submit = screen.getByTestId("submit");
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(submit);
|
await fireEvent.click(submit);
|
||||||
});
|
});
|
||||||
|
|
||||||
let errorDialog = screen.getByText(
|
let errorDialog = screen.getByText(
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
import React from "react";
|
import React, { act } from "react";
|
||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import { act } from "react-dom/test-utils";
|
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { Provider, useDispatch, useSelector } from "react-redux";
|
import { Provider, useDispatch, useSelector } from "react-redux";
|
||||||
@@ -45,6 +44,7 @@ beforeEach(() => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
useDispatch.mockClear();
|
useDispatch.mockClear();
|
||||||
|
jest.runAllTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Renders", async () => {
|
test("Renders", async () => {
|
||||||
@@ -63,9 +63,10 @@ test("Calls createGroup on submit", async () => {
|
|||||||
|
|
||||||
let input = screen.getByTestId("group-input");
|
let input = screen.getByTestId("group-input");
|
||||||
let submit = screen.getByTestId("submit");
|
let submit = screen.getByTestId("submit");
|
||||||
|
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||||
|
|
||||||
userEvent.type(input, "groupname");
|
await user.type(input, "groupname");
|
||||||
await act(async () => fireEvent.click(submit));
|
await act(async () => await fireEvent.click(submit));
|
||||||
|
|
||||||
expect(callbackSpy).toHaveBeenNthCalledWith(1, "groupname");
|
expect(callbackSpy).toHaveBeenNthCalledWith(1, "groupname");
|
||||||
});
|
});
|
||||||
@@ -80,7 +81,7 @@ test("Shows a UI error dialogue when group creation fails", async () => {
|
|||||||
let submit = screen.getByTestId("submit");
|
let submit = screen.getByTestId("submit");
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(submit);
|
await fireEvent.click(submit);
|
||||||
});
|
});
|
||||||
|
|
||||||
let errorDialog = screen.getByText("Failed to create group.");
|
let errorDialog = screen.getByText("Failed to create group.");
|
||||||
@@ -99,7 +100,7 @@ test("Shows a more specific UI error dialogue when user creation returns an impr
|
|||||||
let submit = screen.getByTestId("submit");
|
let submit = screen.getByTestId("submit");
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(submit);
|
await fireEvent.click(submit);
|
||||||
});
|
});
|
||||||
|
|
||||||
let errorDialog = screen.getByText(
|
let errorDialog = screen.getByText(
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
import React from "react";
|
import React, { act } from "react";
|
||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import { act } from "react-dom/test-utils";
|
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { Provider, useDispatch, useSelector } from "react-redux";
|
import { Provider, useDispatch, useSelector } from "react-redux";
|
||||||
import { createStore } from "redux";
|
import { createStore } from "redux";
|
||||||
@@ -58,6 +57,7 @@ beforeEach(() => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
useDispatch.mockClear();
|
useDispatch.mockClear();
|
||||||
|
jest.runAllTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Renders", async () => {
|
test("Renders", async () => {
|
||||||
@@ -80,7 +80,7 @@ test("Calls the delete user function when the button is pressed", async () => {
|
|||||||
let deleteUser = screen.getByTestId("delete-user");
|
let deleteUser = screen.getByTestId("delete-user");
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(deleteUser);
|
await fireEvent.click(deleteUser);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(callbackSpy).toHaveBeenCalled();
|
expect(callbackSpy).toHaveBeenCalled();
|
||||||
@@ -95,7 +95,7 @@ test("Submits the edits when the button is pressed", async () => {
|
|||||||
|
|
||||||
let submit = screen.getByTestId("submit");
|
let submit = screen.getByTestId("submit");
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(submit);
|
await fireEvent.click(submit);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(callbackSpy).toHaveBeenCalled();
|
expect(callbackSpy).toHaveBeenCalled();
|
||||||
@@ -113,7 +113,7 @@ test("Shows a UI error dialogue when user edit fails", async () => {
|
|||||||
|
|
||||||
fireEvent.blur(usernameInput, { target: { value: "whatever" } });
|
fireEvent.blur(usernameInput, { target: { value: "whatever" } });
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(submit);
|
await fireEvent.click(submit);
|
||||||
});
|
});
|
||||||
|
|
||||||
let errorDialog = screen.getByText("Failed to edit user.");
|
let errorDialog = screen.getByText("Failed to edit user.");
|
||||||
@@ -134,7 +134,7 @@ test("Shows a UI error dialogue when user edit returns an improper status code",
|
|||||||
|
|
||||||
fireEvent.blur(usernameInput, { target: { value: "whatever" } });
|
fireEvent.blur(usernameInput, { target: { value: "whatever" } });
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(submit);
|
await fireEvent.click(submit);
|
||||||
});
|
});
|
||||||
|
|
||||||
let errorDialog = screen.getByText("Failed to edit user.");
|
let errorDialog = screen.getByText("Failed to edit user.");
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
import React from "react";
|
import React, { act } from "react";
|
||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import { act } from "react-dom/test-utils";
|
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { Provider, useSelector } from "react-redux";
|
import { Provider, useSelector } from "react-redux";
|
||||||
@@ -58,6 +57,7 @@ beforeEach(() => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
useSelector.mockClear();
|
useSelector.mockClear();
|
||||||
|
jest.runAllTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Renders", async () => {
|
test("Renders", async () => {
|
||||||
@@ -80,13 +80,15 @@ test("Adds user from input to user selectables on button click", async () => {
|
|||||||
let input = screen.getByTestId("username-input");
|
let input = screen.getByTestId("username-input");
|
||||||
let validateUser = screen.getByTestId("validate-user");
|
let validateUser = screen.getByTestId("validate-user");
|
||||||
let submit = screen.getByTestId("submit");
|
let submit = screen.getByTestId("submit");
|
||||||
|
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||||
userEvent.type(input, "bar");
|
await user.type(input, "bar");
|
||||||
fireEvent.click(validateUser);
|
await user.click(validateUser);
|
||||||
await act(async () => okPacket);
|
await act(async () => {
|
||||||
|
await jest.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(submit);
|
await fireEvent.click(submit);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(callbackSpy).toHaveBeenNthCalledWith(1, ["bar"], "group");
|
expect(callbackSpy).toHaveBeenNthCalledWith(1, ["bar"], "group");
|
||||||
@@ -100,7 +102,7 @@ test("Removes a user recently added from input from the selectables list", async
|
|||||||
});
|
});
|
||||||
|
|
||||||
let selectedUser = screen.getByText("foo");
|
let selectedUser = screen.getByText("foo");
|
||||||
fireEvent.click(selectedUser);
|
await await fireEvent.click(selectedUser);
|
||||||
|
|
||||||
let unselectedUser = screen.getByText("foo");
|
let unselectedUser = screen.getByText("foo");
|
||||||
|
|
||||||
@@ -117,14 +119,14 @@ test("Grays out a user, already in the group, when unselected and calls deleteUs
|
|||||||
let submit = screen.getByTestId("submit");
|
let submit = screen.getByTestId("submit");
|
||||||
|
|
||||||
let groupUser = screen.getByText("foo");
|
let groupUser = screen.getByText("foo");
|
||||||
fireEvent.click(groupUser);
|
await fireEvent.click(groupUser);
|
||||||
|
|
||||||
let unselectedUser = screen.getByText("foo");
|
let unselectedUser = screen.getByText("foo");
|
||||||
expect(unselectedUser.className).toBe("item unselected");
|
expect(unselectedUser.className).toBe("item unselected");
|
||||||
|
|
||||||
// test deleteUser call
|
// test deleteUser call
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(submit);
|
await fireEvent.click(submit);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(callbackSpy).toHaveBeenNthCalledWith(1, ["foo"], "group");
|
expect(callbackSpy).toHaveBeenNthCalledWith(1, ["foo"], "group");
|
||||||
@@ -140,7 +142,7 @@ test("Calls deleteGroup on button click", async () => {
|
|||||||
let deleteGroup = screen.getByTestId("delete-group");
|
let deleteGroup = screen.getByTestId("delete-group");
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(deleteGroup);
|
await fireEvent.click(deleteGroup);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(callbackSpy).toHaveBeenNthCalledWith(1, "group");
|
expect(callbackSpy).toHaveBeenNthCalledWith(1, "group");
|
||||||
@@ -154,12 +156,12 @@ test("Shows a UI error dialogue when group edit fails", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let groupUser = screen.getByText("foo");
|
let groupUser = screen.getByText("foo");
|
||||||
fireEvent.click(groupUser);
|
await fireEvent.click(groupUser);
|
||||||
|
|
||||||
let submit = screen.getByTestId("submit");
|
let submit = screen.getByTestId("submit");
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(submit);
|
await fireEvent.click(submit);
|
||||||
});
|
});
|
||||||
|
|
||||||
let errorDialog = screen.getByText("Failed to edit group.");
|
let errorDialog = screen.getByText("Failed to edit group.");
|
||||||
@@ -176,12 +178,12 @@ test("Shows a UI error dialogue when group edit returns an improper status code"
|
|||||||
});
|
});
|
||||||
|
|
||||||
let groupUser = screen.getByText("foo");
|
let groupUser = screen.getByText("foo");
|
||||||
fireEvent.click(groupUser);
|
await fireEvent.click(groupUser);
|
||||||
|
|
||||||
let submit = screen.getByTestId("submit");
|
let submit = screen.getByTestId("submit");
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(submit);
|
await fireEvent.click(submit);
|
||||||
});
|
});
|
||||||
|
|
||||||
let errorDialog = screen.getByText("Failed to edit group.");
|
let errorDialog = screen.getByText("Failed to edit group.");
|
||||||
@@ -200,7 +202,7 @@ test("Shows a UI error dialogue when group delete fails", async () => {
|
|||||||
let deleteGroup = screen.getByTestId("delete-group");
|
let deleteGroup = screen.getByTestId("delete-group");
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(deleteGroup);
|
await fireEvent.click(deleteGroup);
|
||||||
});
|
});
|
||||||
|
|
||||||
let errorDialog = screen.getByText("Failed to delete group.");
|
let errorDialog = screen.getByText("Failed to delete group.");
|
||||||
@@ -219,7 +221,7 @@ test("Shows a UI error dialogue when group delete returns an improper status cod
|
|||||||
let deleteGroup = screen.getByTestId("delete-group");
|
let deleteGroup = screen.getByTestId("delete-group");
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(deleteGroup);
|
await fireEvent.click(deleteGroup);
|
||||||
});
|
});
|
||||||
|
|
||||||
let errorDialog = screen.getByText("Failed to delete group.");
|
let errorDialog = screen.getByText("Failed to delete group.");
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
import React from "react";
|
import React, { act } from "react";
|
||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import { act } from "react-dom/test-utils";
|
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { Provider, useSelector } from "react-redux";
|
import { Provider, useSelector } from "react-redux";
|
||||||
import { createStore } from "redux";
|
import { createStore } from "redux";
|
||||||
@@ -71,6 +70,7 @@ afterEach(() => {
|
|||||||
useSelector.mockClear();
|
useSelector.mockClear();
|
||||||
mockReducers.mockClear();
|
mockReducers.mockClear();
|
||||||
useSearchParams.mockClear();
|
useSearchParams.mockClear();
|
||||||
|
jest.runAllTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Renders", async () => {
|
test("Renders", async () => {
|
||||||
@@ -138,7 +138,7 @@ test("Interacting with PaginationFooter causes page refresh", async () => {
|
|||||||
|
|
||||||
let next = screen.getByTestId("paginate-next");
|
let next = screen.getByTestId("paginate-next");
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(next);
|
await fireEvent.click(next);
|
||||||
});
|
});
|
||||||
expect(updateGroupsSpy).toBeCalledWith(2, 2);
|
expect(updateGroupsSpy).toBeCalledWith(2, 2);
|
||||||
// mocked updateGroups means callback after load doesn't fire
|
// mocked updateGroups means callback after load doesn't fire
|
||||||
|
@@ -2,8 +2,6 @@ import React from "react";
|
|||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { Button, FormControl } from "react-bootstrap";
|
import { Button, FormControl } from "react-bootstrap";
|
||||||
|
|
||||||
import "./pagination-footer.css";
|
|
||||||
|
|
||||||
const PaginationFooter = (props) => {
|
const PaginationFooter = (props) => {
|
||||||
const { offset, limit, visible, total, next, prev, handleLimit } = props;
|
const { offset, limit, visible, total, next, prev, handleLimit } = props;
|
||||||
return (
|
return (
|
||||||
@@ -13,33 +11,45 @@ const PaginationFooter = (props) => {
|
|||||||
{total ? `of ${total}` : ""}
|
{total ? `of ${total}` : ""}
|
||||||
<br />
|
<br />
|
||||||
{offset >= 1 ? (
|
{offset >= 1 ? (
|
||||||
<Button variant="light" size="sm">
|
<Button
|
||||||
<span
|
variant="light"
|
||||||
className="active-pagination"
|
size="sm"
|
||||||
data-testid="paginate-prev"
|
onClick={prev}
|
||||||
onClick={prev}
|
className="me-2"
|
||||||
>
|
data-testid="paginate-prev"
|
||||||
Previous
|
>
|
||||||
</span>
|
Previous
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="light" size="sm">
|
<Button
|
||||||
<span className="inactive-pagination">Previous</span>
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
className="me-2"
|
||||||
|
disabled
|
||||||
|
aria-disabled="true"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{offset + visible < total ? (
|
{offset + visible < total ? (
|
||||||
<Button variant="light" size="sm">
|
<Button
|
||||||
<span
|
variant="light"
|
||||||
className="active-pagination"
|
size="sm"
|
||||||
data-testid="paginate-next"
|
className="me-2"
|
||||||
onClick={next}
|
onClick={next}
|
||||||
>
|
data-testid="paginate-next"
|
||||||
Next
|
>
|
||||||
</span>
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="light" size="sm">
|
<Button
|
||||||
<span className="inactive-pagination">Next</span>
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
className="me-2"
|
||||||
|
disabled
|
||||||
|
aria-disabled="true"
|
||||||
|
>
|
||||||
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<label>
|
<label>
|
||||||
|
@@ -1,14 +0,0 @@
|
|||||||
@import url(../../style/root.css);
|
|
||||||
|
|
||||||
.pagination-footer * button {
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-footer * .inactive-pagination {
|
|
||||||
color: gray;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-footer * button.spaced {
|
|
||||||
color: var(--blue);
|
|
||||||
}
|
|
@@ -453,7 +453,7 @@ const ServerDashboard = (props) => {
|
|||||||
setStateFilter(event.target.checked ? "active" : null);
|
setStateFilter(event.target.checked ? "active" : null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Form.Check.Label for="active-servers-filter">
|
<Form.Check.Label htmlFor="active-servers-filter">
|
||||||
{"only active servers"}
|
{"only active servers"}
|
||||||
</Form.Check.Label>
|
</Form.Check.Label>
|
||||||
</Form.Check>
|
</Form.Check>
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
import React from "react";
|
import React, { act } from "react";
|
||||||
import { withProps } from "recompose";
|
import { withProps } from "recompose";
|
||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import { act } from "react-dom/test-utils";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import {
|
import {
|
||||||
render,
|
render,
|
||||||
@@ -207,7 +206,6 @@ let mockUpdateUsers = jest.fn(({ offset, limit, sort, name_filter, state }) => {
|
|||||||
let searchParams = new URLSearchParams();
|
let searchParams = new URLSearchParams();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.useFakeTimers();
|
|
||||||
useSelector.mockImplementation((callback) => {
|
useSelector.mockImplementation((callback) => {
|
||||||
return callback(mockAppState());
|
return callback(mockAppState());
|
||||||
});
|
});
|
||||||
@@ -291,7 +289,7 @@ test("Invokes the startServer event on button click", async () => {
|
|||||||
expect(start_elems.length).toBe(Object.keys(bar_servers).length);
|
expect(start_elems.length).toBe(Object.keys(bar_servers).length);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(start_elems[0]);
|
await fireEvent.click(start_elems[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(callbackSpy).toHaveBeenCalled();
|
expect(callbackSpy).toHaveBeenCalled();
|
||||||
@@ -307,7 +305,7 @@ test("Invokes the stopServer event on button click", async () => {
|
|||||||
let stop = screen.getByText("Stop Server");
|
let stop = screen.getByText("Stop Server");
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(stop);
|
await fireEvent.click(stop);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(callbackSpy).toHaveBeenCalled();
|
expect(callbackSpy).toHaveBeenCalled();
|
||||||
@@ -323,7 +321,7 @@ test("Invokes the shutdownHub event on button click", async () => {
|
|||||||
let shutdown = screen.getByText("Shutdown Hub");
|
let shutdown = screen.getByText("Shutdown Hub");
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(shutdown);
|
await fireEvent.click(shutdown);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(callbackSpy).toHaveBeenCalled();
|
expect(callbackSpy).toHaveBeenCalled();
|
||||||
@@ -338,7 +336,7 @@ test("Sorts according to username", async () => {
|
|||||||
|
|
||||||
expect(searchParams.get("sort")).toEqual(null);
|
expect(searchParams.get("sort")).toEqual(null);
|
||||||
let handler = screen.getByTestId(testId);
|
let handler = screen.getByTestId(testId);
|
||||||
fireEvent.click(handler);
|
await fireEvent.click(handler);
|
||||||
expect(searchParams.get("sort")).toEqual("name");
|
expect(searchParams.get("sort")).toEqual("name");
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -346,7 +344,7 @@ test("Sorts according to username", async () => {
|
|||||||
handler = screen.getByTestId(testId);
|
handler = screen.getByTestId(testId);
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(handler);
|
await fireEvent.click(handler);
|
||||||
expect(searchParams.get("sort")).toEqual("-name");
|
expect(searchParams.get("sort")).toEqual("-name");
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -354,7 +352,7 @@ test("Sorts according to username", async () => {
|
|||||||
handler = screen.getByTestId(testId);
|
handler = screen.getByTestId(testId);
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(handler);
|
await fireEvent.click(handler);
|
||||||
expect(searchParams.get("sort")).toEqual("name");
|
expect(searchParams.get("sort")).toEqual("name");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -367,7 +365,7 @@ test("Sorts according to last activity", async () => {
|
|||||||
|
|
||||||
expect(searchParams.get("sort")).toEqual(null);
|
expect(searchParams.get("sort")).toEqual(null);
|
||||||
let handler = screen.getByTestId(testId);
|
let handler = screen.getByTestId(testId);
|
||||||
fireEvent.click(handler);
|
await fireEvent.click(handler);
|
||||||
expect(searchParams.get("sort")).toEqual("last_activity");
|
expect(searchParams.get("sort")).toEqual("last_activity");
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -375,7 +373,7 @@ test("Sorts according to last activity", async () => {
|
|||||||
handler = screen.getByTestId(testId);
|
handler = screen.getByTestId(testId);
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(handler);
|
await fireEvent.click(handler);
|
||||||
expect(searchParams.get("sort")).toEqual("-last_activity");
|
expect(searchParams.get("sort")).toEqual("-last_activity");
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -383,7 +381,7 @@ test("Sorts according to last activity", async () => {
|
|||||||
handler = screen.getByTestId(testId);
|
handler = screen.getByTestId(testId);
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(handler);
|
await fireEvent.click(handler);
|
||||||
expect(searchParams.get("sort")).toEqual("last_activity");
|
expect(searchParams.get("sort")).toEqual("last_activity");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -392,12 +390,10 @@ test("Filter according to server status (running/not running)", async () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
rerender = render(serverDashboardJsx()).rerender;
|
rerender = render(serverDashboardJsx()).rerender;
|
||||||
});
|
});
|
||||||
console.log(rerender);
|
|
||||||
console.log("begin test");
|
|
||||||
const label = "only active servers";
|
const label = "only active servers";
|
||||||
let handler = screen.getByLabelText(label);
|
let handler = screen.getByLabelText(label);
|
||||||
expect(handler.checked).toEqual(false);
|
expect(handler.checked).toEqual(false);
|
||||||
fireEvent.click(handler);
|
await fireEvent.click(handler);
|
||||||
|
|
||||||
// FIXME: need to force a rerender to get updated checkbox
|
// FIXME: need to force a rerender to get updated checkbox
|
||||||
// I don't think this should be required
|
// I don't think this should be required
|
||||||
@@ -408,7 +404,7 @@ test("Filter according to server status (running/not running)", async () => {
|
|||||||
expect(searchParams.get("state")).toEqual("active");
|
expect(searchParams.get("state")).toEqual("active");
|
||||||
expect(handler.checked).toEqual(true);
|
expect(handler.checked).toEqual(true);
|
||||||
|
|
||||||
fireEvent.click(handler);
|
await fireEvent.click(handler);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
rerender(serverDashboardJsx());
|
rerender(serverDashboardJsx());
|
||||||
@@ -431,17 +427,14 @@ test("Shows server details with button click", async () => {
|
|||||||
expect(collapse).toHaveClass("collapse");
|
expect(collapse).toHaveClass("collapse");
|
||||||
expect(collapse).not.toHaveClass("show");
|
expect(collapse).not.toHaveClass("show");
|
||||||
expect(collapseBar).not.toHaveClass("show");
|
expect(collapseBar).not.toHaveClass("show");
|
||||||
|
await fireEvent.click(button);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(button);
|
|
||||||
jest.runAllTimers();
|
jest.runAllTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(collapse).toHaveClass("collapse show");
|
expect(collapse).toHaveClass("collapse show");
|
||||||
expect(collapseBar).not.toHaveClass("show");
|
expect(collapseBar).not.toHaveClass("show");
|
||||||
|
await fireEvent.click(button);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(button);
|
|
||||||
jest.runAllTimers();
|
jest.runAllTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -449,8 +442,8 @@ test("Shows server details with button click", async () => {
|
|||||||
expect(collapse).not.toHaveClass("show");
|
expect(collapse).not.toHaveClass("show");
|
||||||
expect(collapseBar).not.toHaveClass("show");
|
expect(collapseBar).not.toHaveClass("show");
|
||||||
|
|
||||||
|
await fireEvent.click(button);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(button);
|
|
||||||
jest.runAllTimers();
|
jest.runAllTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -480,7 +473,7 @@ test("Shows a UI error dialogue when start all servers fails", async () => {
|
|||||||
let startAll = screen.getByTestId("start-all");
|
let startAll = screen.getByTestId("start-all");
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(startAll);
|
await fireEvent.click(startAll);
|
||||||
});
|
});
|
||||||
|
|
||||||
let errorDialog = screen.getByText("Failed to start servers.");
|
let errorDialog = screen.getByText("Failed to start servers.");
|
||||||
@@ -496,7 +489,7 @@ test("Shows a UI error dialogue when stop all servers fails", async () => {
|
|||||||
let stopAll = screen.getByTestId("stop-all");
|
let stopAll = screen.getByTestId("stop-all");
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(stopAll);
|
await fireEvent.click(stopAll);
|
||||||
});
|
});
|
||||||
|
|
||||||
let errorDialog = screen.getByText("Failed to stop servers.");
|
let errorDialog = screen.getByText("Failed to stop servers.");
|
||||||
@@ -513,7 +506,7 @@ test("Shows a UI error dialogue when start user server fails", async () => {
|
|||||||
expect(start_elems.length).toBe(Object.keys(bar_servers).length);
|
expect(start_elems.length).toBe(Object.keys(bar_servers).length);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(start_elems[0]);
|
await fireEvent.click(start_elems[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
let errorDialog = screen.getByText("Failed to start server.");
|
let errorDialog = screen.getByText("Failed to start server.");
|
||||||
@@ -531,7 +524,7 @@ test("Shows a UI error dialogue when start user server returns an improper statu
|
|||||||
expect(start_elems.length).toBe(Object.keys(bar_servers).length);
|
expect(start_elems.length).toBe(Object.keys(bar_servers).length);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(start_elems[0]);
|
await fireEvent.click(start_elems[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
let errorDialog = screen.getByText("Failed to start server.");
|
let errorDialog = screen.getByText("Failed to start server.");
|
||||||
@@ -550,7 +543,7 @@ test("Shows a UI error dialogue when stop user servers fails", async () => {
|
|||||||
let stop = screen.getByText("Stop Server");
|
let stop = screen.getByText("Stop Server");
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(stop);
|
await fireEvent.click(stop);
|
||||||
});
|
});
|
||||||
|
|
||||||
let errorDialog = screen.getByText("Failed to stop server.");
|
let errorDialog = screen.getByText("Failed to stop server.");
|
||||||
@@ -569,7 +562,7 @@ test("Shows a UI error dialogue when stop user server returns an improper status
|
|||||||
let stop = screen.getByText("Stop Server");
|
let stop = screen.getByText("Stop Server");
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(stop);
|
await fireEvent.click(stop);
|
||||||
});
|
});
|
||||||
|
|
||||||
let errorDialog = screen.getByText("Failed to stop server.");
|
let errorDialog = screen.getByText("Failed to stop server.");
|
||||||
@@ -584,12 +577,13 @@ test("Search for user calls updateUsers with name filter", async () => {
|
|||||||
render(serverDashboardJsx());
|
render(serverDashboardJsx());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||||
let search = screen.getByLabelText("user-search");
|
let search = screen.getByLabelText("user-search");
|
||||||
|
|
||||||
expect(mockUpdateUsers.mock.calls).toHaveLength(1);
|
expect(mockUpdateUsers.mock.calls).toHaveLength(1);
|
||||||
|
|
||||||
expect(searchParams.get("offset")).toEqual("2");
|
expect(searchParams.get("offset")).toEqual("2");
|
||||||
userEvent.type(search, "a");
|
await user.type(search, "a");
|
||||||
expect(search.value).toEqual("a");
|
expect(search.value).toEqual("a");
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
jest.runAllTimers();
|
jest.runAllTimers();
|
||||||
@@ -599,7 +593,7 @@ test("Search for user calls updateUsers with name filter", async () => {
|
|||||||
// FIXME: useSelector mocks prevent updateUsers from being called
|
// FIXME: useSelector mocks prevent updateUsers from being called
|
||||||
// expect(mockUpdateUsers.mock.calls).toHaveLength(2);
|
// expect(mockUpdateUsers.mock.calls).toHaveLength(2);
|
||||||
// expect(mockUpdateUsers).toBeCalledWith(0, 100, "a");
|
// expect(mockUpdateUsers).toBeCalledWith(0, 100, "a");
|
||||||
userEvent.type(search, "b");
|
await user.type(search, "b");
|
||||||
expect(search.value).toEqual("ab");
|
expect(search.value).toEqual("ab");
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
jest.runAllTimers();
|
jest.runAllTimers();
|
||||||
@@ -672,7 +666,7 @@ test("Start server and confirm pending state", async () => {
|
|||||||
expect(buttons[2].textContent).toBe("Edit User");
|
expect(buttons[2].textContent).toBe("Edit User");
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(buttons[0]);
|
await fireEvent.click(buttons[0]);
|
||||||
});
|
});
|
||||||
expect(mockUpdateUsers.mock.calls).toHaveLength(1);
|
expect(mockUpdateUsers.mock.calls).toHaveLength(1);
|
||||||
|
|
||||||
|
@@ -3,7 +3,7 @@ const base_url = jhdata.base_url || "/";
|
|||||||
const xsrfToken = jhdata.xsrf_token;
|
const xsrfToken = jhdata.xsrf_token;
|
||||||
|
|
||||||
export const jhapiRequest = (endpoint, method, data) => {
|
export const jhapiRequest = (endpoint, method, data) => {
|
||||||
let api_url = new URL(`${base_url}hub/api` + endpoint, location.origin);
|
let api_url = new URL(`${base_url}api` + endpoint, location.origin);
|
||||||
if (xsrfToken) {
|
if (xsrfToken) {
|
||||||
api_url.searchParams.set("_xsrf", xsrfToken);
|
api_url.searchParams.set("_xsrf", xsrfToken);
|
||||||
}
|
}
|
||||||
|
@@ -34,5 +34,5 @@ export const MainContainer = (props) => {
|
|||||||
MainContainer.propTypes = {
|
MainContainer.propTypes = {
|
||||||
errorAlert: PropTypes.string,
|
errorAlert: PropTypes.string,
|
||||||
setErrorAlert: PropTypes.func,
|
setErrorAlert: PropTypes.func,
|
||||||
children: PropTypes.array,
|
children: PropTypes.node,
|
||||||
};
|
};
|
||||||
|
@@ -3,7 +3,7 @@
|
|||||||
# 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.
|
||||||
# version_info updated by running `tbump`
|
# version_info updated by running `tbump`
|
||||||
version_info = (5, 1, 0, "", "")
|
version_info = (5, 2, 1, "", "")
|
||||||
|
|
||||||
# pep 440 version: no dot before beta/rc, but before .dev
|
# pep 440 version: no dot before beta/rc, but before .dev
|
||||||
# 0.1.0rc1
|
# 0.1.0rc1
|
||||||
|
@@ -282,7 +282,7 @@ class JupyterHub(Application):
|
|||||||
|
|
||||||
@default('classes')
|
@default('classes')
|
||||||
def _load_classes(self):
|
def _load_classes(self):
|
||||||
classes = [Spawner, Authenticator, CryptKeeper]
|
classes = {Spawner, Authenticator, CryptKeeper}
|
||||||
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.
|
||||||
@@ -298,9 +298,9 @@ class JupyterHub(Application):
|
|||||||
e,
|
e,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
if cls not in classes and isinstance(cls, Configurable):
|
if issubclass(cls, Configurable):
|
||||||
classes.append(cls)
|
classes.add(cls)
|
||||||
return classes
|
return list(classes)
|
||||||
|
|
||||||
load_groups = Dict(
|
load_groups = Dict(
|
||||||
Union([Dict(), List()]),
|
Union([Dict(), List()]),
|
||||||
@@ -873,13 +873,7 @@ class JupyterHub(Application):
|
|||||||
but your identity provider is likely much more strict,
|
but your identity provider is likely much more strict,
|
||||||
allowing you to make assumptions about the name.
|
allowing you to make assumptions about the name.
|
||||||
|
|
||||||
The default behavior is to have all services
|
The 'idna' hook should produce a valid domain name for any user,
|
||||||
on a single `services.{domain}` subdomain,
|
|
||||||
and each user on `{username}.{domain}`.
|
|
||||||
This is the 'legacy' scheme,
|
|
||||||
and doesn't work for all usernames.
|
|
||||||
|
|
||||||
The 'idna' scheme is a new scheme that should produce a valid domain name for any user,
|
|
||||||
using IDNA encoding for unicode usernames, and a truncate-and-hash approach for
|
using IDNA encoding for unicode usernames, and a truncate-and-hash approach for
|
||||||
any usernames that can't be easily encoded into a domain component.
|
any usernames that can't be easily encoded into a domain component.
|
||||||
|
|
||||||
@@ -2182,7 +2176,11 @@ class JupyterHub(Application):
|
|||||||
# but changes to the allowed_users set can occur in the database,
|
# but changes to the allowed_users set can occur in the database,
|
||||||
# and persist across sessions.
|
# and persist across sessions.
|
||||||
total_users = 0
|
total_users = 0
|
||||||
|
blocked_users = self.authenticator.blocked_users
|
||||||
for user in db.query(orm.User):
|
for user in db.query(orm.User):
|
||||||
|
if user.name in blocked_users:
|
||||||
|
# don't call add_user with blocked users
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
f = self.authenticator.add_user(user)
|
f = self.authenticator.add_user(user)
|
||||||
if f:
|
if f:
|
||||||
@@ -2238,6 +2236,35 @@ class JupyterHub(Application):
|
|||||||
await maybe_future(f)
|
await maybe_future(f)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
async def init_blocked_users(self):
|
||||||
|
"""Revoke all permissions for users in Authenticator.blocked_users"""
|
||||||
|
blocked_users = self.authenticator.blocked_users
|
||||||
|
if not blocked_users:
|
||||||
|
# nothing to check
|
||||||
|
return
|
||||||
|
db = self.db
|
||||||
|
for user in db.query(orm.User).filter(orm.User.name.in_(blocked_users)):
|
||||||
|
# revoke permissions from blocked users
|
||||||
|
# so already-issued credentials have no access to the API
|
||||||
|
self.log.debug(f"Found blocked user in database: {user.name}")
|
||||||
|
if user.admin:
|
||||||
|
self.log.warning(
|
||||||
|
f"Removing admin permissions from blocked user {user.name}"
|
||||||
|
)
|
||||||
|
user.admin = False
|
||||||
|
if user.roles:
|
||||||
|
self.log.warning(
|
||||||
|
f"Removing blocked user {user.name} from roles: {', '.join(role.name for role in user.roles)}"
|
||||||
|
)
|
||||||
|
user.roles = []
|
||||||
|
if user.groups:
|
||||||
|
self.log.warning(
|
||||||
|
f"Removing blocked user {user.name} from groups: {', '.join(group.name for group in user.groups)}"
|
||||||
|
)
|
||||||
|
user.groups = []
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
async def init_groups(self):
|
async def init_groups(self):
|
||||||
"""Load predefined groups into the database"""
|
"""Load predefined groups into the database"""
|
||||||
db = self.db
|
db = self.db
|
||||||
@@ -2965,6 +2992,18 @@ class JupyterHub(Application):
|
|||||||
async def check_spawner(user, name, spawner):
|
async def check_spawner(user, name, spawner):
|
||||||
status = 0
|
status = 0
|
||||||
if spawner.server:
|
if spawner.server:
|
||||||
|
if user.name in self.authenticator.blocked_users:
|
||||||
|
self.log.warning(
|
||||||
|
f"Stopping spawner for blocked user: {spawner._log_name}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await user.stop(name)
|
||||||
|
except Exception:
|
||||||
|
self.log.exception(
|
||||||
|
f"Failed to stop {spawner._log_name}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
status = await spawner.poll()
|
status = await spawner.poll()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -3356,6 +3395,7 @@ class JupyterHub(Application):
|
|||||||
self.init_services()
|
self.init_services()
|
||||||
await self.init_api_tokens()
|
await self.init_api_tokens()
|
||||||
await self.init_role_assignment()
|
await self.init_role_assignment()
|
||||||
|
await self.init_blocked_users()
|
||||||
self.init_tornado_settings()
|
self.init_tornado_settings()
|
||||||
self.init_handlers()
|
self.init_handlers()
|
||||||
self.init_tornado_application()
|
self.init_tornado_application()
|
||||||
@@ -3414,7 +3454,6 @@ class JupyterHub(Application):
|
|||||||
metrics_collector = self.metrics_collector = PeriodicMetricsCollector(
|
metrics_collector = self.metrics_collector = PeriodicMetricsCollector(
|
||||||
parent=self, db=self.db
|
parent=self, db=self.db
|
||||||
)
|
)
|
||||||
metrics_collector.start()
|
|
||||||
|
|
||||||
async def cleanup(self):
|
async def cleanup(self):
|
||||||
"""Shutdown managed services and various subprocesses. Cleanup runtime files."""
|
"""Shutdown managed services and various subprocesses. Cleanup runtime files."""
|
||||||
@@ -3595,7 +3634,7 @@ class JupyterHub(Application):
|
|||||||
if service.managed:
|
if service.managed:
|
||||||
status = await service.spawner.poll()
|
status = await service.spawner.poll()
|
||||||
if status is not None:
|
if status is not None:
|
||||||
self.log.error(
|
self.log.critical(
|
||||||
"Service %s exited with status %s",
|
"Service %s exited with status %s",
|
||||||
service_name,
|
service_name,
|
||||||
status,
|
status,
|
||||||
@@ -3604,12 +3643,19 @@ class JupyterHub(Application):
|
|||||||
else:
|
else:
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
self.log.error(
|
if service.managed:
|
||||||
"Cannot connect to %s service %s at %s. Is it running?",
|
self.log.critical(
|
||||||
service.kind,
|
"Cannot connect to %s service %s",
|
||||||
service_name,
|
service_name,
|
||||||
service.url,
|
service.kind,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
self.log.warning(
|
||||||
|
"Cannot connect to %s service %s at %s. Is it running?",
|
||||||
|
service.kind,
|
||||||
|
service_name,
|
||||||
|
service.url,
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -3643,6 +3689,9 @@ class JupyterHub(Application):
|
|||||||
loop.stop()
|
loop.stop()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# start collecting metrics
|
||||||
|
self.metrics_collector.start()
|
||||||
|
|
||||||
# start the proxy
|
# start the proxy
|
||||||
if self.proxy.should_start:
|
if self.proxy.should_start:
|
||||||
try:
|
try:
|
||||||
@@ -3697,18 +3746,8 @@ class JupyterHub(Application):
|
|||||||
# start the service(s)
|
# start the service(s)
|
||||||
for service_name, service in self._service_map.items():
|
for service_name, service in self._service_map.items():
|
||||||
service_ready = await self.start_service(service_name, service, ssl_context)
|
service_ready = await self.start_service(service_name, service, ssl_context)
|
||||||
if not service_ready:
|
if not service_ready and service.managed:
|
||||||
if service.from_config:
|
self.exit(1)
|
||||||
# Stop the application if a config-based service failed to start.
|
|
||||||
self.exit(1)
|
|
||||||
else:
|
|
||||||
# Only warn for database-based service, so that admin can connect
|
|
||||||
# to hub to remove the service.
|
|
||||||
self.log.error(
|
|
||||||
"Failed to reach externally managed service %s",
|
|
||||||
service_name,
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
await self.proxy.check_routes(self.users, self._service_map)
|
await self.proxy.check_routes(self.users, self._service_map)
|
||||||
|
|
||||||
@@ -3900,4 +3939,8 @@ UpgradeDB.classes.append(JupyterHub)
|
|||||||
main = JupyterHub.launch_instance
|
main = JupyterHub.launch_instance
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
# don't invoke __main__.main here because __main__.JupyterHub
|
||||||
|
# is not jupyterhub.app.JupyterHub. There will be two!
|
||||||
|
from jupyterhub import app
|
||||||
|
|
||||||
|
app.JupyterHub.launch_instance()
|
||||||
|
@@ -300,6 +300,14 @@ class Authenticator(LoggingConfigurable):
|
|||||||
If empty, does not perform any additional restriction.
|
If empty, does not perform any additional restriction.
|
||||||
|
|
||||||
.. versionadded: 0.9
|
.. versionadded: 0.9
|
||||||
|
|
||||||
|
.. versionchanged:: 5.2
|
||||||
|
Users blocked via `blocked_users` that may have logged in in the past
|
||||||
|
have all permissions and group membership revoked
|
||||||
|
and all servers stopped at JupyterHub startup.
|
||||||
|
Previously, User permissions (e.g. API tokens)
|
||||||
|
and servers were unaffected and required additional
|
||||||
|
administrator operations to block after a user is added to `blocked_users`.
|
||||||
|
|
||||||
.. versionchanged:: 1.2
|
.. versionchanged:: 1.2
|
||||||
`Authenticator.blacklist` renamed to `blocked_users`
|
`Authenticator.blacklist` renamed to `blocked_users`
|
||||||
@@ -1230,7 +1238,20 @@ class PAMAuthenticator(LocalAuthenticator):
|
|||||||
|
|
||||||
@default('executor')
|
@default('executor')
|
||||||
def _default_executor(self):
|
def _default_executor(self):
|
||||||
return ThreadPoolExecutor(1)
|
return ThreadPoolExecutor(self.executor_threads)
|
||||||
|
|
||||||
|
executor_threads = Integer(
|
||||||
|
4,
|
||||||
|
config=True,
|
||||||
|
help="""
|
||||||
|
Number of executor threads.
|
||||||
|
|
||||||
|
PAM auth requests happen in this thread, so it is mostly
|
||||||
|
waiting for the pam stack. One thread is usually enough,
|
||||||
|
unless your pam stack is doing something slow like network
|
||||||
|
requests
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
encoding = Unicode(
|
encoding = Unicode(
|
||||||
'utf8',
|
'utf8',
|
||||||
|
@@ -1064,7 +1064,7 @@ class BaseHandler(RequestHandler):
|
|||||||
human_retry_time = "%i0 seconds" % math.ceil(retry_time / 10.0)
|
human_retry_time = "%i0 seconds" % math.ceil(retry_time / 10.0)
|
||||||
else:
|
else:
|
||||||
# round number of minutes
|
# round number of minutes
|
||||||
human_retry_time = "%i minutes" % math.round(retry_time / 60.0)
|
human_retry_time = "%i minutes" % round(retry_time / 60.0)
|
||||||
|
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
'%s pending spawns, throttling. Suggested retry in %s seconds.',
|
'%s pending spawns, throttling. Suggested retry in %s seconds.',
|
||||||
|
@@ -466,7 +466,6 @@ class AdminHandler(BaseHandler):
|
|||||||
named_server_limit_per_user=await self.get_current_user_named_server_limit(),
|
named_server_limit_per_user=await self.get_current_user_named_server_limit(),
|
||||||
server_version=f'{__version__} {self.version_hash}',
|
server_version=f'{__version__} {self.version_hash}',
|
||||||
api_page_limit=self.settings["api_page_default_limit"],
|
api_page_limit=self.settings["api_page_default_limit"],
|
||||||
base_url=self.settings["base_url"],
|
|
||||||
)
|
)
|
||||||
self.finish(html)
|
self.finish(html)
|
||||||
|
|
||||||
|
@@ -1141,7 +1141,6 @@ class APIToken(Hashed, Base):
|
|||||||
expires_in=None,
|
expires_in=None,
|
||||||
client_id=None,
|
client_id=None,
|
||||||
oauth_client=None,
|
oauth_client=None,
|
||||||
return_orm=False,
|
|
||||||
):
|
):
|
||||||
"""Generate a new API token for a user or service"""
|
"""Generate a new API token for a user or service"""
|
||||||
assert user or service
|
assert user or service
|
||||||
|
@@ -63,9 +63,29 @@ if _as_extension:
|
|||||||
f"Cannot use JUPYTERHUB_SINGLEUSER_EXTENSION={_extension_env} with JUPYTERHUB_SINGLEUSER_APP={_app_env}."
|
f"Cannot use JUPYTERHUB_SINGLEUSER_EXTENSION={_extension_env} with JUPYTERHUB_SINGLEUSER_APP={_app_env}."
|
||||||
" Please pick one or the other."
|
" Please pick one or the other."
|
||||||
)
|
)
|
||||||
from .extension import main
|
try:
|
||||||
|
from .extension import main
|
||||||
|
except ImportError as e:
|
||||||
|
# raise from to preserve original import error
|
||||||
|
raise ImportError(
|
||||||
|
"Failed to import JupyterHub singleuser extension."
|
||||||
|
" Make sure to install dependencies for your single-user server, e.g.\n"
|
||||||
|
" pip install jupyterlab"
|
||||||
|
) from e
|
||||||
else:
|
else:
|
||||||
from .app import SingleUserNotebookApp, main
|
try:
|
||||||
|
from .app import SingleUserNotebookApp, main
|
||||||
|
except ImportError as e:
|
||||||
|
# raise from to preserve original import error
|
||||||
|
if _app_env:
|
||||||
|
_app_env_log = f"JUPYTERHUB_SINGLEUSER_APP={_app_env}"
|
||||||
|
else:
|
||||||
|
_app_env_log = "default single-user server"
|
||||||
|
raise ImportError(
|
||||||
|
f"Failed to import {_app_env_log}."
|
||||||
|
" Make sure to install dependencies for your single-user server, e.g.\n"
|
||||||
|
" pip install jupyterlab"
|
||||||
|
) from e
|
||||||
|
|
||||||
# backward-compatibility
|
# backward-compatibility
|
||||||
if SingleUserNotebookApp is not None:
|
if SingleUserNotebookApp is not None:
|
||||||
|
@@ -22,8 +22,6 @@ rather than keeing these monkey patches around.
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from jupyter_core import paths
|
|
||||||
|
|
||||||
|
|
||||||
def _is_relative_to(path, prefix):
|
def _is_relative_to(path, prefix):
|
||||||
"""
|
"""
|
||||||
@@ -68,6 +66,10 @@ def _disable_user_config(serverapp):
|
|||||||
2. Search paths for extensions, etc.
|
2. Search paths for extensions, etc.
|
||||||
3. import path
|
3. import path
|
||||||
"""
|
"""
|
||||||
|
# delayed import to avoid triggering early ImportError
|
||||||
|
# with unmet dependencies
|
||||||
|
from jupyter_core import paths
|
||||||
|
|
||||||
original_jupyter_path = paths.jupyter_path()
|
original_jupyter_path = paths.jupyter_path()
|
||||||
jupyter_path_without_home = list(_exclude_home(original_jupyter_path))
|
jupyter_path_without_home = list(_exclude_home(original_jupyter_path))
|
||||||
|
|
||||||
|
@@ -361,9 +361,8 @@ class SingleUserNotebookAppMixin(Configurable):
|
|||||||
"""override default log format to include time"""
|
"""override default log format to include time"""
|
||||||
return "%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s %(module)s:%(lineno)d]%(end_color)s %(message)s"
|
return "%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s %(module)s:%(lineno)d]%(end_color)s %(message)s"
|
||||||
|
|
||||||
def _confirm_exit(self):
|
def _handle_sigint(self, *args, **kwargs):
|
||||||
# disable the exit confirmation for background notebook processes
|
self._signal_stop(*args, **kwargs)
|
||||||
self.io_loop.add_callback_from_signal(self.io_loop.stop)
|
|
||||||
|
|
||||||
def migrate_config(self):
|
def migrate_config(self):
|
||||||
if self.disable_user_config:
|
if self.disable_user_config:
|
||||||
|
@@ -701,16 +701,7 @@ class Spawner(LoggingConfigurable):
|
|||||||
)
|
)
|
||||||
|
|
||||||
env_keep = List(
|
env_keep = List(
|
||||||
[
|
['JUPYTERHUB_SINGLEUSER_APP'],
|
||||||
'PATH',
|
|
||||||
'PYTHONPATH',
|
|
||||||
'CONDA_ROOT',
|
|
||||||
'CONDA_DEFAULT_ENV',
|
|
||||||
'VIRTUAL_ENV',
|
|
||||||
'LANG',
|
|
||||||
'LC_ALL',
|
|
||||||
'JUPYTERHUB_SINGLEUSER_APP',
|
|
||||||
],
|
|
||||||
help="""
|
help="""
|
||||||
List of environment variables for the single-user server to inherit from the JupyterHub process.
|
List of environment variables for the single-user server to inherit from the JupyterHub process.
|
||||||
|
|
||||||
@@ -1716,6 +1707,20 @@ class LocalProcessSpawner(Spawner):
|
|||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@default("env_keep")
|
||||||
|
def _env_keep_default(self):
|
||||||
|
return [
|
||||||
|
"CONDA_DEFAULT_ENV",
|
||||||
|
"CONDA_ROOT",
|
||||||
|
"JUPYTERHUB_SINGLEUSER_APP",
|
||||||
|
"LANG",
|
||||||
|
"LC_ALL",
|
||||||
|
"LD_LIBRARY_PATH",
|
||||||
|
"PATH",
|
||||||
|
"PYTHONPATH",
|
||||||
|
"VIRTUAL_ENV",
|
||||||
|
]
|
||||||
|
|
||||||
def make_preexec_fn(self, name):
|
def make_preexec_fn(self, name):
|
||||||
"""
|
"""
|
||||||
Return a function that can be used to set the user id of the spawned process to user with name `name`
|
Return a function that can be used to set the user id of the spawned process to user with name `name`
|
||||||
|
@@ -283,7 +283,7 @@ async def test_spawn_pending_progress(
|
|||||||
await launch_btn.click()
|
await launch_btn.click()
|
||||||
# wait for progress message to appear
|
# wait for progress message to appear
|
||||||
progress = browser.locator("#progress-message")
|
progress = browser.locator("#progress-message")
|
||||||
progress_message = await progress.inner_text()
|
progress_message = await progress.text_content()
|
||||||
async with browser.expect_navigation(url=re.compile(".*/user/" + f"{urlname}/")):
|
async with browser.expect_navigation(url=re.compile(".*/user/" + f"{urlname}/")):
|
||||||
# wait for log messages to appear
|
# wait for log messages to appear
|
||||||
expected_messages = [
|
expected_messages = [
|
||||||
@@ -293,7 +293,7 @@ async def test_spawn_pending_progress(
|
|||||||
]
|
]
|
||||||
while not user.spawner.ready:
|
while not user.spawner.ready:
|
||||||
logs_list = [
|
logs_list = [
|
||||||
await log.inner_text()
|
await log.text_content()
|
||||||
for log in await browser.locator("div.progress-log-event").all()
|
for log in await browser.locator("div.progress-log-event").all()
|
||||||
]
|
]
|
||||||
if progress_message:
|
if progress_message:
|
||||||
@@ -654,7 +654,7 @@ async def test_request_token_expiration(
|
|||||||
await api_token_table_area.locator("tr.token-row")
|
await api_token_table_area.locator("tr.token-row")
|
||||||
.get_by_role("cell")
|
.get_by_role("cell")
|
||||||
.nth(0)
|
.nth(0)
|
||||||
.inner_text()
|
.text_content()
|
||||||
)
|
)
|
||||||
|
|
||||||
assert note_on_page == expected_note
|
assert note_on_page == expected_note
|
||||||
@@ -663,7 +663,7 @@ async def test_request_token_expiration(
|
|||||||
await api_token_table_area.locator("tr.token-row")
|
await api_token_table_area.locator("tr.token-row")
|
||||||
.get_by_role("cell")
|
.get_by_role("cell")
|
||||||
.nth(2)
|
.nth(2)
|
||||||
.inner_text()
|
.text_content()
|
||||||
)
|
)
|
||||||
assert last_used_text == "Never"
|
assert last_used_text == "Never"
|
||||||
|
|
||||||
@@ -671,7 +671,7 @@ async def test_request_token_expiration(
|
|||||||
await api_token_table_area.locator("tr.token-row")
|
await api_token_table_area.locator("tr.token-row")
|
||||||
.get_by_role("cell")
|
.get_by_role("cell")
|
||||||
.nth(4)
|
.nth(4)
|
||||||
.inner_text()
|
.text_content()
|
||||||
)
|
)
|
||||||
|
|
||||||
if token_opt == "Never":
|
if token_opt == "Never":
|
||||||
@@ -734,7 +734,7 @@ async def test_request_token_permissions(
|
|||||||
if not granted:
|
if not granted:
|
||||||
error_dialog = browser.locator("#error-dialog")
|
error_dialog = browser.locator("#error-dialog")
|
||||||
await expect(error_dialog).to_be_visible()
|
await expect(error_dialog).to_be_visible()
|
||||||
error_message = await error_dialog.locator(".modal-body").inner_text()
|
error_message = await error_dialog.locator(".modal-body").text_content()
|
||||||
assert "API request failed (400)" in error_message
|
assert "API request failed (400)" in error_message
|
||||||
assert expected_error in error_message
|
assert expected_error in error_message
|
||||||
await error_dialog.locator("button[aria-label='Close']").click()
|
await error_dialog.locator("button[aria-label='Close']").click()
|
||||||
@@ -1087,6 +1087,7 @@ async def open_admin_page(app, browser, login_as=None):
|
|||||||
# url = url_path_join(public_host(app), app.hub.base_url, "/login?next=" + admin_page)
|
# url = url_path_join(public_host(app), app.hub.base_url, "/login?next=" + admin_page)
|
||||||
await browser.goto(admin_page)
|
await browser.goto(admin_page)
|
||||||
await expect(browser).to_have_url(re.compile(".*/hub/admin"))
|
await expect(browser).to_have_url(re.compile(".*/hub/admin"))
|
||||||
|
await browser.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
|
||||||
def create_list_of_users(create_user_with_scopes, n):
|
def create_list_of_users(create_user_with_scopes, n):
|
||||||
@@ -1186,7 +1187,7 @@ async def test_paging_on_admin_page(
|
|||||||
re.compile(".*" + f"1-{min(users_count_db, 50)}" + ".*")
|
re.compile(".*" + f"1-{min(users_count_db, 50)}" + ".*")
|
||||||
)
|
)
|
||||||
if users_count_db > 50:
|
if users_count_db > 50:
|
||||||
await expect(btn_next.locator("//span")).to_have_class("active-pagination")
|
await expect(btn_next).to_be_enabled()
|
||||||
# click on Next button
|
# click on Next button
|
||||||
await btn_next.click()
|
await btn_next.click()
|
||||||
if users_count_db <= 100:
|
if users_count_db <= 100:
|
||||||
@@ -1195,15 +1196,13 @@ async def test_paging_on_admin_page(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await expect(displaying).to_have_text(re.compile(".*" + "51-100" + ".*"))
|
await expect(displaying).to_have_text(re.compile(".*" + "51-100" + ".*"))
|
||||||
await expect(btn_next.locator("//span")).to_have_class("active-pagination")
|
await expect(btn_next).to_be_enabled()
|
||||||
await expect(btn_previous.locator("//span")).to_have_class("active-pagination")
|
await expect(btn_previous).to_be_enabled()
|
||||||
# click on Previous button
|
# click on Previous button
|
||||||
await btn_previous.click()
|
await btn_previous.click()
|
||||||
else:
|
else:
|
||||||
await expect(btn_next.locator("//span")).to_have_class("inactive-pagination")
|
await expect(btn_next).to_be_disabled()
|
||||||
await expect(btn_previous.locator("//span")).to_have_class(
|
await expect(btn_previous).to_be_disabled()
|
||||||
"inactive-pagination"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -1256,6 +1255,7 @@ async def test_search_on_admin_page(
|
|||||||
await expect(displaying).to_contain_text(re.compile("1-50"))
|
await expect(displaying).to_contain_text(re.compile("1-50"))
|
||||||
# click on Next button to verify that the rest part of filtered list is displayed on the next page
|
# click on Next button to verify that the rest part of filtered list is displayed on the next page
|
||||||
await browser.get_by_role("button", name="Next").click()
|
await browser.get_by_role("button", name="Next").click()
|
||||||
|
await browser.wait_for_load_state("networkidle")
|
||||||
filtered_list_on_next_page = browser.locator('//tr[@class="user-row"]')
|
filtered_list_on_next_page = browser.locator('//tr[@class="user-row"]')
|
||||||
await expect(filtered_list_on_page).to_have_count(users_count_db_filtered - 50)
|
await expect(filtered_list_on_page).to_have_count(users_count_db_filtered - 50)
|
||||||
for element in await filtered_list_on_next_page.get_by_test_id(
|
for element in await filtered_list_on_next_page.get_by_test_id(
|
||||||
|
@@ -33,7 +33,9 @@ import sys
|
|||||||
from subprocess import TimeoutExpired
|
from subprocess import TimeoutExpired
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from pytest import fixture, raises
|
import pytest_asyncio
|
||||||
|
from packaging.version import parse as parse_version
|
||||||
|
from pytest import fixture, mark, raises
|
||||||
from sqlalchemy import event
|
from sqlalchemy import event
|
||||||
from tornado.httpclient import HTTPError
|
from tornado.httpclient import HTTPError
|
||||||
from tornado.platform.asyncio import AsyncIOMainLoop
|
from tornado.platform.asyncio import AsyncIOMainLoop
|
||||||
@@ -57,6 +59,41 @@ from .utils import add_user
|
|||||||
# global db session object
|
# global db session object
|
||||||
_db = None
|
_db = None
|
||||||
|
|
||||||
|
_pytest_asyncio_24 = parse_version(pytest_asyncio.__version__) >= parse_version(
|
||||||
|
"0.24.0.dev0"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_collection_modifyitems(items):
|
||||||
|
if _pytest_asyncio_24:
|
||||||
|
# apply loop_scope="module" to all async tests by default
|
||||||
|
# this is only for pytest_asyncio >= 0.24
|
||||||
|
# pytest_asyncio < 0.24 uses overridden `event_loop` fixture
|
||||||
|
# this can be hopefully be removed in favor of config if
|
||||||
|
# https://github.com/pytest-dev/pytest-asyncio/issues/793
|
||||||
|
# is addressed
|
||||||
|
pytest_asyncio_tests = (
|
||||||
|
item for item in items if pytest_asyncio.is_async_test(item)
|
||||||
|
)
|
||||||
|
asyncio_scope_marker = mark.asyncio(loop_scope="module")
|
||||||
|
for async_test in pytest_asyncio_tests:
|
||||||
|
# add asyncio marker _if_ not already present
|
||||||
|
asyncio_marker = async_test.get_closest_marker('asyncio')
|
||||||
|
if not asyncio_marker or not asyncio_marker.kwargs:
|
||||||
|
async_test.add_marker(asyncio_scope_marker, append=False)
|
||||||
|
|
||||||
|
|
||||||
|
if not _pytest_asyncio_24:
|
||||||
|
# pre-pytest-asyncio 0.24, overriding event_loop fixture
|
||||||
|
# was the way to change scope of event_loop
|
||||||
|
# post-0.24 uses modifyitems above
|
||||||
|
@fixture(scope='module')
|
||||||
|
def event_loop(request):
|
||||||
|
"""Same as pytest-asyncio.event_loop, but re-scoped to module-level"""
|
||||||
|
event_loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(event_loop)
|
||||||
|
return event_loop
|
||||||
|
|
||||||
|
|
||||||
@fixture(scope='module')
|
@fixture(scope='module')
|
||||||
def ssl_tmpdir(tmpdir_factory):
|
def ssl_tmpdir(tmpdir_factory):
|
||||||
@@ -125,15 +162,7 @@ def db():
|
|||||||
|
|
||||||
|
|
||||||
@fixture(scope='module')
|
@fixture(scope='module')
|
||||||
def event_loop(request):
|
async def io_loop(request):
|
||||||
"""Same as pytest-asyncio.event_loop, but re-scoped to module-level"""
|
|
||||||
event_loop = asyncio.new_event_loop()
|
|
||||||
asyncio.set_event_loop(event_loop)
|
|
||||||
return event_loop
|
|
||||||
|
|
||||||
|
|
||||||
@fixture(scope='module')
|
|
||||||
async def io_loop(event_loop, request):
|
|
||||||
"""Mostly obsolete fixture for tornado event loop
|
"""Mostly obsolete fixture for tornado event loop
|
||||||
|
|
||||||
Main purpose is to register cleanup (close) after we're done with the loop.
|
Main purpose is to register cleanup (close) after we're done with the loop.
|
||||||
@@ -141,6 +170,7 @@ async def io_loop(event_loop, request):
|
|||||||
happens before the io_loop is closed.
|
happens before the io_loop is closed.
|
||||||
"""
|
"""
|
||||||
io_loop = AsyncIOMainLoop()
|
io_loop = AsyncIOMainLoop()
|
||||||
|
event_loop = asyncio.get_running_loop()
|
||||||
assert asyncio.get_event_loop() is event_loop
|
assert asyncio.get_event_loop() is event_loop
|
||||||
assert io_loop.asyncio_loop is event_loop
|
assert io_loop.asyncio_loop is event_loop
|
||||||
|
|
||||||
|
@@ -8,6 +8,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from subprocess import PIPE, Popen, check_output
|
from subprocess import PIPE, Popen, check_output
|
||||||
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
@@ -16,6 +17,8 @@ import pytest
|
|||||||
import traitlets
|
import traitlets
|
||||||
from traitlets.config import Config
|
from traitlets.config import Config
|
||||||
|
|
||||||
|
from jupyterhub.scopes import get_scopes_for
|
||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from ..app import COOKIE_SECRET_BYTES, JupyterHub
|
from ..app import COOKIE_SECRET_BYTES, JupyterHub
|
||||||
from .mocking import MockHub
|
from .mocking import MockHub
|
||||||
@@ -289,8 +292,7 @@ def persist_db(tmpdir):
|
|||||||
def new_hub(request, tmpdir, persist_db):
|
def new_hub(request, tmpdir, persist_db):
|
||||||
"""Fixture to launch a new hub for testing"""
|
"""Fixture to launch a new hub for testing"""
|
||||||
|
|
||||||
async def new_hub():
|
async def new_hub(**kwargs):
|
||||||
kwargs = {}
|
|
||||||
ssl_enabled = getattr(request.module, "ssl_enabled", False)
|
ssl_enabled = getattr(request.module, "ssl_enabled", False)
|
||||||
if ssl_enabled:
|
if ssl_enabled:
|
||||||
kwargs['internal_certs_location'] = str(tmpdir)
|
kwargs['internal_certs_location'] = str(tmpdir)
|
||||||
@@ -306,17 +308,6 @@ def new_hub(request, tmpdir, persist_db):
|
|||||||
|
|
||||||
|
|
||||||
async def test_resume_spawners(tmpdir, request, new_hub):
|
async def test_resume_spawners(tmpdir, request, new_hub):
|
||||||
async def new_hub():
|
|
||||||
kwargs = {}
|
|
||||||
ssl_enabled = getattr(request.module, "ssl_enabled", False)
|
|
||||||
if ssl_enabled:
|
|
||||||
kwargs['internal_certs_location'] = str(tmpdir)
|
|
||||||
app = MockHub(test_clean_db=False, **kwargs)
|
|
||||||
app.config.ConfigurableHTTPProxy.should_start = False
|
|
||||||
app.config.ConfigurableHTTPProxy.auth_token = 'unused'
|
|
||||||
await app.initialize([])
|
|
||||||
return app
|
|
||||||
|
|
||||||
app = await new_hub()
|
app = await new_hub()
|
||||||
db = app.db
|
db = app.db
|
||||||
# spawn a user's server
|
# spawn a user's server
|
||||||
@@ -537,3 +528,74 @@ async def test_recreate_service_from_database(
|
|||||||
# start one more, service should be gone
|
# start one more, service should be gone
|
||||||
app = await new_hub()
|
app = await new_hub()
|
||||||
assert service_name not in app._service_map
|
assert service_name not in app._service_map
|
||||||
|
|
||||||
|
|
||||||
|
async def test_revoke_blocked_users(username, groupname, new_hub):
|
||||||
|
config = Config()
|
||||||
|
config.Authenticator.admin_users = {username}
|
||||||
|
kept_username = username + "-kept"
|
||||||
|
config.Authenticator.allowed_users = {username, kept_username}
|
||||||
|
config.JupyterHub.load_groups = {
|
||||||
|
groupname: {
|
||||||
|
"users": [username],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
config.JupyterHub.load_roles = [
|
||||||
|
{
|
||||||
|
"name": "testrole",
|
||||||
|
"scopes": ["access:services"],
|
||||||
|
"groups": [groupname],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
app = await new_hub(config=config)
|
||||||
|
user = app.users[username]
|
||||||
|
|
||||||
|
# load some credentials, start server
|
||||||
|
await user.spawn()
|
||||||
|
# await app.proxy.add_user(user)
|
||||||
|
spawner = user.spawners['']
|
||||||
|
token = user.new_api_token()
|
||||||
|
orm_token = orm.APIToken.find(app.db, token)
|
||||||
|
app.cleanup_servers = False
|
||||||
|
app.stop()
|
||||||
|
|
||||||
|
# before state
|
||||||
|
assert await spawner.poll() is None
|
||||||
|
assert sorted(role.name for role in user.roles) == ['admin', 'user']
|
||||||
|
assert [g.name for g in user.groups] == [groupname]
|
||||||
|
assert user.admin
|
||||||
|
user_scopes = get_scopes_for(user)
|
||||||
|
assert "access:servers" in user_scopes
|
||||||
|
token_scopes = get_scopes_for(orm_token)
|
||||||
|
assert "access:servers" in token_scopes
|
||||||
|
|
||||||
|
# start a new hub, now with blocked users
|
||||||
|
config = Config()
|
||||||
|
name_doesnt_exist = user.name + "-doesntexist"
|
||||||
|
config.Authenticator.blocked_users = {user.name, name_doesnt_exist}
|
||||||
|
config.JupyterHub.init_spawners_timeout = 60
|
||||||
|
# background spawner.proc.wait to avoid waiting for zombie process here
|
||||||
|
with ThreadPoolExecutor(1) as pool:
|
||||||
|
pool.submit(spawner.proc.wait)
|
||||||
|
app2 = await new_hub(config=config)
|
||||||
|
assert app2.db_url == app.db_url
|
||||||
|
|
||||||
|
# check that blocked user has no permissions
|
||||||
|
user2 = app2.users[user.name]
|
||||||
|
assert user2.roles == []
|
||||||
|
assert user2.groups == []
|
||||||
|
assert user2.admin is False
|
||||||
|
user_scopes = get_scopes_for(user2)
|
||||||
|
assert user_scopes == set()
|
||||||
|
orm_token = orm.APIToken.find(app2.db, token)
|
||||||
|
token_scopes = get_scopes_for(orm_token)
|
||||||
|
assert token_scopes == set()
|
||||||
|
|
||||||
|
# spawner stopped
|
||||||
|
assert user2.spawners == {}
|
||||||
|
assert await spawner.poll() is not None
|
||||||
|
|
||||||
|
# (sanity check) didn't lose other user
|
||||||
|
kept_user = app2.users[kept_username]
|
||||||
|
assert 'user' in [r.name for r in kept_user.roles]
|
||||||
|
app2.stop()
|
||||||
|
216
package-lock.json
generated
216
package-lock.json
generated
@@ -21,10 +21,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fortawesome/fontawesome-free": {
|
"node_modules/@fortawesome/fontawesome-free": {
|
||||||
"version": "6.5.2",
|
"version": "6.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.6.0.tgz",
|
||||||
"integrity": "sha512-hRILoInAx8GNT5IMkrtIt9blOdrqHOnPBH+k70aWUAqPZPgopb9G5EQJFpaBx/S8zp2fC+mPW349Bziuk1o28Q==",
|
"integrity": "sha512-60G28ke/sXdtS9KZCpZSHHkCbdsOGEhIUGlwq6yhY74UpTiToIh8np7A8yphhM4BWsvNFtIvLpi4co+h9Mr9Ow==",
|
||||||
"hasInstallScript": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
@@ -39,31 +38,6 @@
|
|||||||
"url": "https://opencollective.com/popperjs"
|
"url": "https://opencollective.com/popperjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/anymatch": {
|
|
||||||
"version": "3.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
|
||||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"normalize-path": "^3.0.0",
|
|
||||||
"picomatch": "^2.0.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/binary-extensions": {
|
|
||||||
"version": "2.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
|
||||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/bootstrap": {
|
"node_modules/bootstrap": {
|
||||||
"version": "5.3.3",
|
"version": "5.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz",
|
||||||
@@ -82,78 +56,19 @@
|
|||||||
"@popperjs/core": "^2.11.8"
|
"@popperjs/core": "^2.11.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/braces": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"fill-range": "^7.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "3.6.0",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz",
|
||||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
"integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"anymatch": "~3.1.2",
|
"readdirp": "^4.0.1"
|
||||||
"braces": "~3.0.2",
|
|
||||||
"glob-parent": "~5.1.2",
|
|
||||||
"is-binary-path": "~2.1.0",
|
|
||||||
"is-glob": "~4.0.1",
|
|
||||||
"normalize-path": "~3.0.0",
|
|
||||||
"readdirp": "~3.6.0"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 8.10.0"
|
"node": ">= 14.16.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"fsevents": "~2.3.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fill-range": {
|
|
||||||
"version": "7.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
|
||||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"to-regex-range": "^5.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fsevents": {
|
|
||||||
"version": "2.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/glob-parent": {
|
|
||||||
"version": "5.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
|
||||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"is-glob": "^4.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/immutable": {
|
"node_modules/immutable": {
|
||||||
@@ -162,98 +77,37 @@
|
|||||||
"integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==",
|
"integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/is-binary-path": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"binary-extensions": "^2.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/is-extglob": {
|
|
||||||
"version": "2.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
|
||||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/is-glob": {
|
|
||||||
"version": "4.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
|
||||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"is-extglob": "^2.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/is-number": {
|
|
||||||
"version": "7.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
|
||||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.12.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jquery": {
|
"node_modules/jquery": {
|
||||||
"version": "3.7.0",
|
"version": "3.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
|
||||||
"integrity": "sha512-umpJ0/k8X0MvD1ds0P9SfowREz2LenHsQaxSohMZ5OMNEU2r0tf8pdeEFTHMFxWVxKNyU9rTtK3CWzUCTKJUeQ=="
|
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg=="
|
||||||
},
|
},
|
||||||
"node_modules/moment": {
|
"node_modules/moment": {
|
||||||
"version": "2.29.4",
|
"version": "2.30.1",
|
||||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
|
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||||
"integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==",
|
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/normalize-path": {
|
"node_modules/readdirp": {
|
||||||
"version": "3.0.0",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.1.tgz",
|
||||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
"integrity": "sha512-GkMg9uOTpIWWKbSsgwb5fA4EavTR+SG/PMPoAY8hkhHfEEY0/vqljY+XHqtDf2cr2IJtoNRDbrrEpZUiZCkYRw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">= 14.16.0"
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/picomatch": {
|
|
||||||
"version": "2.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8.6"
|
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"type": "individual",
|
||||||
}
|
"url": "https://paulmillr.com/funding/"
|
||||||
},
|
|
||||||
"node_modules/readdirp": {
|
|
||||||
"version": "3.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
|
||||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"picomatch": "^2.2.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8.10.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/requirejs": {
|
"node_modules/requirejs": {
|
||||||
"version": "2.3.6",
|
"version": "2.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/requirejs/-/requirejs-2.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/requirejs/-/requirejs-2.3.7.tgz",
|
||||||
"integrity": "sha512-ipEzlWQe6RK3jkzikgCupiTbTvm4S0/CAU5GlgptkN5SO6F3u0UD0K18wy6ErDqiCyP4J4YYe1HuAShvsxePLg==",
|
"integrity": "sha512-DouTG8T1WanGok6Qjg2SXuCMzszOo0eHeH9hDZ5Y4x8Je+9JB38HdTLT4/VA8OaUhBa0JPVHJ0pyBkM1z+pDsw==",
|
||||||
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"r_js": "bin/r.js",
|
"r_js": "bin/r.js",
|
||||||
"r.js": "bin/r.js"
|
"r.js": "bin/r.js"
|
||||||
@@ -263,12 +117,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass": {
|
"node_modules/sass": {
|
||||||
"version": "1.74.1",
|
"version": "1.79.4",
|
||||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.74.1.tgz",
|
"resolved": "https://registry.npmjs.org/sass/-/sass-1.79.4.tgz",
|
||||||
"integrity": "sha512-w0Z9p/rWZWelb88ISOLyvqTWGmtmu2QJICqDBGyNnfG4OUnPX9BBjjYIXUpXCMOOg5MQWNpqzt876la1fsTvUA==",
|
"integrity": "sha512-K0QDSNPXgyqO4GZq2HO5Q70TLxTH6cIT59RdoCHMivrC8rqzaTw5ab9prjz9KUN1El4FLXrBXJhik61JR4HcGg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": ">=3.0.0 <4.0.0",
|
"chokidar": "^4.0.0",
|
||||||
"immutable": "^4.0.0",
|
"immutable": "^4.0.0",
|
||||||
"source-map-js": ">=0.6.2 <2.0.0"
|
"source-map-js": ">=0.6.2 <2.0.0"
|
||||||
},
|
},
|
||||||
@@ -287,18 +141,6 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"node_modules/to-regex-range": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"is-number": "^7.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8.0"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
# ref: https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html
|
# ref: https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html
|
||||||
[project]
|
[project]
|
||||||
name = "jupyterhub"
|
name = "jupyterhub"
|
||||||
version = "5.1.0"
|
version = "5.2.1"
|
||||||
dynamic = ["readme", "dependencies"]
|
dynamic = ["readme", "dependencies"]
|
||||||
description = "JupyterHub: A multi-user server for Jupyter notebooks"
|
description = "JupyterHub: A multi-user server for Jupyter notebooks"
|
||||||
authors = [
|
authors = [
|
||||||
@@ -51,8 +51,7 @@ test = [
|
|||||||
# the test test_nbclassic_control_panel.
|
# the test test_nbclassic_control_panel.
|
||||||
"nbclassic",
|
"nbclassic",
|
||||||
"pytest>=3.3",
|
"pytest>=3.3",
|
||||||
# FIXME: unpin pytest-asyncio
|
"pytest-asyncio>=0.17,!=0.23.*",
|
||||||
"pytest-asyncio>=0.17,<0.23",
|
|
||||||
"pytest-cov",
|
"pytest-cov",
|
||||||
"pytest-rerunfailures",
|
"pytest-rerunfailures",
|
||||||
"requests-mock",
|
"requests-mock",
|
||||||
@@ -147,7 +146,7 @@ indent_size = 2
|
|||||||
github_url = "https://github.com/jupyterhub/jupyterhub"
|
github_url = "https://github.com/jupyterhub/jupyterhub"
|
||||||
|
|
||||||
[tool.tbump.version]
|
[tool.tbump.version]
|
||||||
current = "5.1.0"
|
current = "5.2.1"
|
||||||
|
|
||||||
# Example of a semver regexp.
|
# Example of a semver regexp.
|
||||||
# Make sure this matches current_version before
|
# Make sure this matches current_version before
|
||||||
|
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
# automatically run coroutine tests with asyncio
|
# automatically run coroutine tests with asyncio
|
||||||
asyncio_mode = auto
|
asyncio_mode = auto
|
||||||
|
# use module-level loop scope (requires pytest-asyncio 0.24)
|
||||||
|
asyncio_default_fixture_loop_scope = module
|
||||||
|
|
||||||
# jupyter_server plugin is incompatible with notebook imports
|
# jupyter_server plugin is incompatible with notebook imports
|
||||||
addopts = -p no:jupyter_server -m 'not browser' --color yes --durations 10 --verbose
|
addopts = -p no:jupyter_server -m 'not browser' --color yes --durations 10 --verbose
|
||||||
|
59
share/jupyterhub/static/js/darkmode.js
Normal file
59
share/jupyterhub/static/js/darkmode.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"use strict";
|
||||||
|
/* Simplified from bootstrap dark mode toggle
|
||||||
|
https://getbootstrap.com/docs/5.3/customize/color-modes/#javascript
|
||||||
|
*/
|
||||||
|
|
||||||
|
// theme is stored in localStorage
|
||||||
|
const getStoredTheme = () => localStorage.getItem("jupyterhub-bs-theme");
|
||||||
|
const setStoredTheme = (theme) =>
|
||||||
|
localStorage.setItem("jupyterhub-bs-theme", theme);
|
||||||
|
|
||||||
|
const getPreferredTheme = () => {
|
||||||
|
// return chosen theme. Pick value in localStorage if there,
|
||||||
|
// otherwise use system setting if defined
|
||||||
|
const storedTheme = getStoredTheme();
|
||||||
|
if (storedTheme) {
|
||||||
|
return storedTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTheme = (theme) => {
|
||||||
|
if (theme === "auto") {
|
||||||
|
document.documentElement.setAttribute(
|
||||||
|
"data-bs-theme",
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
document.documentElement.setAttribute("data-bs-theme", theme);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setTheme(getPreferredTheme());
|
||||||
|
|
||||||
|
window
|
||||||
|
.matchMedia("(prefers-color-scheme: dark)")
|
||||||
|
.addEventListener("change", () => {
|
||||||
|
// handle system change if no explicit theme preference is stored
|
||||||
|
const storedTheme = getStoredTheme();
|
||||||
|
if (storedTheme !== "light" && storedTheme !== "dark") {
|
||||||
|
setTheme(getPreferredTheme());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("DOMContentLoaded", () => {
|
||||||
|
// clicking #dark-theme-toggle toggles dark theme
|
||||||
|
// (in page.html)
|
||||||
|
const toggle = document.getElementById("dark-theme-toggle");
|
||||||
|
toggle.addEventListener("click", () => {
|
||||||
|
const currentTheme = document.documentElement.getAttribute("data-bs-theme");
|
||||||
|
const theme = currentTheme == "dark" ? "light" : "dark";
|
||||||
|
setStoredTheme(theme);
|
||||||
|
setTheme(theme);
|
||||||
|
});
|
||||||
|
});
|
@@ -18,7 +18,16 @@ $grid-float-breakpoint: map-get($grid-breakpoints, "sm");
|
|||||||
&:focus {
|
&:focus {
|
||||||
// no color change
|
// no color change
|
||||||
color: var(--#{$prefix}navbar-color);
|
color: var(--#{$prefix}navbar-color);
|
||||||
background-color: darken($body-tertiary-bg, 10%);
|
background-color: shift-color($body-tertiary-bg, 10%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .navbar-nav {
|
||||||
|
.nav-link {
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
background-color: shift-color($body-tertiary-bg-dark, -20%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,3 +100,24 @@ $grid-float-breakpoint: map-get($grid-breakpoints, "sm");
|
|||||||
$hover-color: #fff
|
$hover-color: #fff
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// contrast button variants
|
||||||
|
// same as btn-dark on light and btn-light on dark
|
||||||
|
.btn-contrast,
|
||||||
|
[data-bs-theme="light"] .btn-contrast {
|
||||||
|
@extend .btn-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-contrast,
|
||||||
|
[data-bs-theme="light"] .btn-outline-contrast {
|
||||||
|
@extend .btn-outline-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] {
|
||||||
|
.btn-contrast {
|
||||||
|
@extend .btn-light;
|
||||||
|
}
|
||||||
|
.btn-outline-contrast {
|
||||||
|
@extend .btn-outline-light;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -39,15 +39,18 @@
|
|||||||
<meta http-equiv="X-UA-Compatible" content="chrome=1">
|
<meta http-equiv="X-UA-Compatible" content="chrome=1">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
{% block stylesheet %}
|
{% block stylesheet %}
|
||||||
<link rel="stylesheet" href="{{ static_url("css/style.min.css") }}" type="text/css" />
|
<link rel="stylesheet"
|
||||||
|
href="{{ static_url('css/style.min.css') }}"
|
||||||
|
type="text/css" />
|
||||||
{% endblock stylesheet %}
|
{% endblock stylesheet %}
|
||||||
{% block favicon %}
|
{% block favicon %}
|
||||||
<link rel="icon" href="{{ static_url("favicon.ico") }}" type="image/x-icon">
|
<link rel="icon" href="{{ static_url('favicon.ico') }}" type="image/x-icon">
|
||||||
{% endblock favicon %}
|
{% endblock favicon %}
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{static_url("components/bootstrap/dist/js/bootstrap.bundle.min.js") }}" type="text/javascript" charset="utf-8"></script>
|
<script src="{{static_url("components/bootstrap/dist/js/bootstrap.bundle.min.js") }}" type="text/javascript" charset="utf-8"></script>
|
||||||
<script src="{{static_url("components/requirejs/require.js") }}" type="text/javascript" charset="utf-8"></script>
|
<script src="{{static_url("components/requirejs/require.js") }}" type="text/javascript" charset="utf-8"></script>
|
||||||
<script src="{{static_url("components/jquery/dist/jquery.min.js") }}" type="text/javascript" charset="utf-8"></script>
|
<script src="{{static_url("components/jquery/dist/jquery.min.js") }}" type="text/javascript" charset="utf-8"></script>
|
||||||
|
<script src="{{static_url("js/darkmode.js") }}" type="text/javascript" charset="utf-8"></script>
|
||||||
{% endblock scripts %}
|
{% endblock scripts %}
|
||||||
{# djlint js formatting doesn't handle template blocks in js #}
|
{# djlint js formatting doesn't handle template blocks in js #}
|
||||||
{# djlint: off #}
|
{# djlint: off #}
|
||||||
@@ -126,8 +129,8 @@
|
|||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="collapse navbar-collapse" id="thenavbar">
|
<div class="collapse navbar-collapse" id="thenavbar">
|
||||||
{% if user %}
|
<ul class="navbar-nav me-auto mb-0">
|
||||||
<ul class="navbar-nav me-auto mb-0">
|
{% if user %}
|
||||||
{% block nav_bar_left_items %}
|
{% block nav_bar_left_items %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{{ base_url }}home">Home</a>
|
<a class="nav-link" href="{{ base_url }}home">Home</a>
|
||||||
@@ -159,23 +162,33 @@
|
|||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock nav_bar_left_items %}
|
{% endblock nav_bar_left_items %}
|
||||||
</ul>
|
{% endif %}
|
||||||
{% endif %}
|
</ul>
|
||||||
<ul class="nav navbar-nav me-2">
|
<ul class="nav navbar-nav me-2">
|
||||||
{% block nav_bar_right_items %}
|
{% block nav_bar_right_items %}
|
||||||
|
<li class="nav-item">
|
||||||
|
{% block theme_toggle %}
|
||||||
|
<button class="btn btn-sm"
|
||||||
|
id="dark-theme-toggle"
|
||||||
|
aria-label="Toggle dark mode"
|
||||||
|
title="Toggle dark mode">
|
||||||
|
<i aria-hidden="true" class="fa fa-circle-half-stroke"></i>
|
||||||
|
</button>
|
||||||
|
{% endblock theme_toggle %}
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
{% block login_widget %}
|
{% block login_widget %}
|
||||||
<span id="login_widget">
|
<span id="login_widget">
|
||||||
{% if user %}
|
{% if user %}
|
||||||
<span class="navbar-text me-1">{{ user.name }}</span>
|
<span class="me-1">{{ user.name }}</span>
|
||||||
<a id="logout"
|
<a id="logout"
|
||||||
role="button"
|
role="button"
|
||||||
class="btn btn-sm btn-outline-dark"
|
class="btn btn-sm btn-outline-contrast"
|
||||||
href="{{ logout_url }}"> <i aria-hidden="true" class="fa fa-sign-out"></i> Logout</a>
|
href="{{ logout_url }}"> <i aria-hidden="true" class="fa fa-sign-out"></i> Logout</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a id="login"
|
<a id="login"
|
||||||
role="button"
|
role="button"
|
||||||
class="btn btn-sm btn-outline-dark"
|
class="btn btn-sm btn-outline-contrast"
|
||||||
href="{{ login_url }}">Login</a>
|
href="{{ login_url }}">Login</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
|
Reference in New Issue
Block a user