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
|
||||
time: "05:00"
|
||||
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
|
||||
make html
|
||||
|
||||
# Output broken and permanently redirected links in a readable format
|
||||
- name: check links
|
||||
run: |
|
||||
cd docs
|
||||
make linkcheck
|
||||
uses: manics/action-sphinx-linkcheck-summary@main
|
||||
with:
|
||||
docs-dir: docs
|
||||
build-dir: docs/_build
|
||||
|
||||
# make rediraffecheckdiff compares files for different changesets
|
||||
# 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
|
||||
pip install -r ci/oldest-dependencies/requirements.old -e .
|
||||
else
|
||||
pip install -e ".[test]"
|
||||
pip install --pre -e ".[test]"
|
||||
fi
|
||||
|
||||
if [ "${{ matrix.main_dependencies }}" != "" ]; then
|
||||
|
@@ -16,7 +16,7 @@ ci:
|
||||
repos:
|
||||
# autoformat and lint Python code
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.5.0
|
||||
rev: v0.6.3
|
||||
hooks:
|
||||
- id: ruff
|
||||
types_or:
|
||||
@@ -37,7 +37,7 @@ repos:
|
||||
|
||||
# autoformat HTML templates
|
||||
- repo: https://github.com/djlint/djLint
|
||||
rev: v1.34.1
|
||||
rev: v1.35.2
|
||||
hooks:
|
||||
- id: djlint-reformat-jinja
|
||||
files: ".*templates/.*.html"
|
||||
|
@@ -7,7 +7,7 @@ info:
|
||||
license:
|
||||
name: BSD-3-Clause
|
||||
identifier: BSD-3-Clause
|
||||
version: 5.1.0
|
||||
version: 5.2.1
|
||||
servers:
|
||||
- url: /hub/api
|
||||
security:
|
||||
|
@@ -3,7 +3,7 @@
|
||||
# JupyterHub: A conceptual overview
|
||||
|
||||
```{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!
|
||||
```
|
||||
|
||||
|
@@ -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:
|
||||
|
||||
- **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,
|
||||
Minnesota Supercomputing Institute, ALCF, CERN, Lawrence Livermore National Laboratory, HUNT
|
||||
- **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.
|
||||
|
||||
### 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...?
|
||||
|
||||
### 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.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?
|
||||
|
||||
While JupyterLab is still under active development, we have had users
|
||||
|
@@ -20,6 +20,82 @@ Contributors to major version bumps in JupyterHub include:
|
||||
|
||||
## [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.0 - 2024-07-31
|
||||
|
@@ -82,15 +82,6 @@ Within CERN, there are two noteworthy JupyterHub deployments in operation:
|
||||
- Advanced Computing
|
||||
- [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](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": []
|
||||
},
|
||||
"jest": {
|
||||
"fakeTimers": {
|
||||
"enableGlobally": true
|
||||
},
|
||||
"moduleNameMapper": {
|
||||
"\\.(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"
|
||||
@@ -29,44 +32,44 @@
|
||||
"testEnvironment": "jsdom"
|
||||
},
|
||||
"dependencies": {
|
||||
"bootstrap": "^5.2.3",
|
||||
"bootstrap": "^5.3.3",
|
||||
"history": "^5.3.0",
|
||||
"lodash": "^4.17.21",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^17.0.2",
|
||||
"react-bootstrap": "^2.10.1",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-icons": "^4.8.0",
|
||||
"react": "^18.3.1",
|
||||
"react-bootstrap": "^2.10.5",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.3.0",
|
||||
"react-multi-select-component": "^4.3.4",
|
||||
"react-redux": "^7.2.8",
|
||||
"react-router-dom": "^6.22.2",
|
||||
"react-redux": "^9.1.2",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"recompose": "npm:react-recompose@^0.33.0",
|
||||
"redux": "^4.2.1",
|
||||
"regenerator-runtime": "^0.13.11"
|
||||
"redux": "^5.0.1",
|
||||
"regenerator-runtime": "^0.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.21.4",
|
||||
"@babel/preset-env": "^7.21.4",
|
||||
"@babel/preset-react": "^7.18.6",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^12.1.5",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@babel/preset-env": "^7.25.4",
|
||||
"@babel/preset-react": "^7.24.7",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@webpack-cli/serve": "^2.0.1",
|
||||
"babel-jest": "^29.5.0",
|
||||
"babel-loader": "^9.1.2",
|
||||
"css-loader": "^6.7.3",
|
||||
"eslint": "^8.38.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"eslint-plugin-unused-imports": "^2.0.0",
|
||||
"babel-jest": "^29.7.0",
|
||||
"babel-loader": "^9.2.1",
|
||||
"css-loader": "^7.1.2",
|
||||
"eslint": "^9.11.1",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-react": "^7.37.1",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"file-loader": "^6.2.0",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^29.5.0",
|
||||
"jest-environment-jsdom": "^29.5.0",
|
||||
"prettier": "^2.8.7",
|
||||
"style-loader": "^3.3.2",
|
||||
"webpack": "^5.79.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"prettier": "^3.3.3",
|
||||
"style-loader": "^4.0.0",
|
||||
"webpack": "^5.95.0",
|
||||
"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 ReactDOM from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Provider } from "react-redux";
|
||||
import { createStore } from "redux";
|
||||
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 { act } from "react-dom/test-utils";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Provider, useDispatch, useSelector } from "react-redux";
|
||||
@@ -46,6 +45,7 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
useDispatch.mockClear();
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
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" } });
|
||||
await act(async () => {
|
||||
fireEvent.click(submit);
|
||||
await fireEvent.click(submit);
|
||||
});
|
||||
|
||||
expect(callbackSpy).toHaveBeenCalledWith(["foo", "bar", "a@b.co"], false);
|
||||
@@ -79,15 +79,15 @@ test("Correctly submits admin", async () => {
|
||||
await act(async () => {
|
||||
render(addUserJsx(callbackSpy));
|
||||
});
|
||||
|
||||
let textarea = screen.getByTestId("user-textarea");
|
||||
let submit = screen.getByTestId("submit");
|
||||
let check = screen.getByTestId("check");
|
||||
|
||||
userEvent.click(check);
|
||||
fireEvent.blur(textarea, { target: { value: "foo" } });
|
||||
await fireEvent.blur(textarea, { target: { value: "foo" } });
|
||||
await fireEvent.click(check);
|
||||
await fireEvent.click(submit);
|
||||
await act(async () => {
|
||||
fireEvent.click(submit);
|
||||
await jest.runAllTimers();
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(submit);
|
||||
await fireEvent.click(submit);
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(submit);
|
||||
await fireEvent.click(submit);
|
||||
});
|
||||
|
||||
let errorDialog = screen.getByText(
|
||||
|
@@ -1,6 +1,5 @@
|
||||
import React from "react";
|
||||
import React, { act } from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Provider, useDispatch, useSelector } from "react-redux";
|
||||
@@ -45,6 +44,7 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
useDispatch.mockClear();
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
test("Renders", async () => {
|
||||
@@ -63,9 +63,10 @@ test("Calls createGroup on submit", async () => {
|
||||
|
||||
let input = screen.getByTestId("group-input");
|
||||
let submit = screen.getByTestId("submit");
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
|
||||
userEvent.type(input, "groupname");
|
||||
await act(async () => fireEvent.click(submit));
|
||||
await user.type(input, "groupname");
|
||||
await act(async () => await fireEvent.click(submit));
|
||||
|
||||
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");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(submit);
|
||||
await fireEvent.click(submit);
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(submit);
|
||||
await fireEvent.click(submit);
|
||||
});
|
||||
|
||||
let errorDialog = screen.getByText(
|
||||
|
@@ -1,6 +1,5 @@
|
||||
import React from "react";
|
||||
import React, { act } from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { Provider, useDispatch, useSelector } from "react-redux";
|
||||
import { createStore } from "redux";
|
||||
@@ -58,6 +57,7 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
useDispatch.mockClear();
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(deleteUser);
|
||||
await fireEvent.click(deleteUser);
|
||||
});
|
||||
|
||||
expect(callbackSpy).toHaveBeenCalled();
|
||||
@@ -95,7 +95,7 @@ test("Submits the edits when the button is pressed", async () => {
|
||||
|
||||
let submit = screen.getByTestId("submit");
|
||||
await act(async () => {
|
||||
fireEvent.click(submit);
|
||||
await fireEvent.click(submit);
|
||||
});
|
||||
|
||||
expect(callbackSpy).toHaveBeenCalled();
|
||||
@@ -113,7 +113,7 @@ test("Shows a UI error dialogue when user edit fails", async () => {
|
||||
|
||||
fireEvent.blur(usernameInput, { target: { value: "whatever" } });
|
||||
await act(async () => {
|
||||
fireEvent.click(submit);
|
||||
await fireEvent.click(submit);
|
||||
});
|
||||
|
||||
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" } });
|
||||
await act(async () => {
|
||||
fireEvent.click(submit);
|
||||
await fireEvent.click(submit);
|
||||
});
|
||||
|
||||
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 { act } from "react-dom/test-utils";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Provider, useSelector } from "react-redux";
|
||||
@@ -58,6 +57,7 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
useSelector.mockClear();
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
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 validateUser = screen.getByTestId("validate-user");
|
||||
let submit = screen.getByTestId("submit");
|
||||
|
||||
userEvent.type(input, "bar");
|
||||
fireEvent.click(validateUser);
|
||||
await act(async () => okPacket);
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
await user.type(input, "bar");
|
||||
await user.click(validateUser);
|
||||
await act(async () => {
|
||||
await jest.runAllTimers();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(submit);
|
||||
await fireEvent.click(submit);
|
||||
});
|
||||
|
||||
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");
|
||||
fireEvent.click(selectedUser);
|
||||
await await fireEvent.click(selectedUser);
|
||||
|
||||
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 groupUser = screen.getByText("foo");
|
||||
fireEvent.click(groupUser);
|
||||
await fireEvent.click(groupUser);
|
||||
|
||||
let unselectedUser = screen.getByText("foo");
|
||||
expect(unselectedUser.className).toBe("item unselected");
|
||||
|
||||
// test deleteUser call
|
||||
await act(async () => {
|
||||
fireEvent.click(submit);
|
||||
await fireEvent.click(submit);
|
||||
});
|
||||
|
||||
expect(callbackSpy).toHaveBeenNthCalledWith(1, ["foo"], "group");
|
||||
@@ -140,7 +142,7 @@ test("Calls deleteGroup on button click", async () => {
|
||||
let deleteGroup = screen.getByTestId("delete-group");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(deleteGroup);
|
||||
await fireEvent.click(deleteGroup);
|
||||
});
|
||||
|
||||
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");
|
||||
fireEvent.click(groupUser);
|
||||
await fireEvent.click(groupUser);
|
||||
|
||||
let submit = screen.getByTestId("submit");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(submit);
|
||||
await fireEvent.click(submit);
|
||||
});
|
||||
|
||||
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");
|
||||
fireEvent.click(groupUser);
|
||||
await fireEvent.click(groupUser);
|
||||
|
||||
let submit = screen.getByTestId("submit");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(submit);
|
||||
await fireEvent.click(submit);
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(deleteGroup);
|
||||
await fireEvent.click(deleteGroup);
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(deleteGroup);
|
||||
await fireEvent.click(deleteGroup);
|
||||
});
|
||||
|
||||
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 { act } from "react-dom/test-utils";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { Provider, useSelector } from "react-redux";
|
||||
import { createStore } from "redux";
|
||||
@@ -71,6 +70,7 @@ afterEach(() => {
|
||||
useSelector.mockClear();
|
||||
mockReducers.mockClear();
|
||||
useSearchParams.mockClear();
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
test("Renders", async () => {
|
||||
@@ -138,7 +138,7 @@ test("Interacting with PaginationFooter causes page refresh", async () => {
|
||||
|
||||
let next = screen.getByTestId("paginate-next");
|
||||
await act(async () => {
|
||||
fireEvent.click(next);
|
||||
await fireEvent.click(next);
|
||||
});
|
||||
expect(updateGroupsSpy).toBeCalledWith(2, 2);
|
||||
// mocked updateGroups means callback after load doesn't fire
|
||||
|
@@ -2,8 +2,6 @@ import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Button, FormControl } from "react-bootstrap";
|
||||
|
||||
import "./pagination-footer.css";
|
||||
|
||||
const PaginationFooter = (props) => {
|
||||
const { offset, limit, visible, total, next, prev, handleLimit } = props;
|
||||
return (
|
||||
@@ -13,33 +11,45 @@ const PaginationFooter = (props) => {
|
||||
{total ? `of ${total}` : ""}
|
||||
<br />
|
||||
{offset >= 1 ? (
|
||||
<Button variant="light" size="sm">
|
||||
<span
|
||||
className="active-pagination"
|
||||
data-testid="paginate-prev"
|
||||
onClick={prev}
|
||||
>
|
||||
Previous
|
||||
</span>
|
||||
<Button
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={prev}
|
||||
className="me-2"
|
||||
data-testid="paginate-prev"
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="light" size="sm">
|
||||
<span className="inactive-pagination">Previous</span>
|
||||
<Button
|
||||
variant="light"
|
||||
size="sm"
|
||||
className="me-2"
|
||||
disabled
|
||||
aria-disabled="true"
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
)}
|
||||
{offset + visible < total ? (
|
||||
<Button variant="light" size="sm">
|
||||
<span
|
||||
className="active-pagination"
|
||||
data-testid="paginate-next"
|
||||
onClick={next}
|
||||
>
|
||||
Next
|
||||
</span>
|
||||
<Button
|
||||
variant="light"
|
||||
size="sm"
|
||||
className="me-2"
|
||||
onClick={next}
|
||||
data-testid="paginate-next"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="light" size="sm">
|
||||
<span className="inactive-pagination">Next</span>
|
||||
<Button
|
||||
variant="light"
|
||||
size="sm"
|
||||
className="me-2"
|
||||
disabled
|
||||
aria-disabled="true"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
)}
|
||||
<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);
|
||||
}}
|
||||
/>
|
||||
<Form.Check.Label for="active-servers-filter">
|
||||
<Form.Check.Label htmlFor="active-servers-filter">
|
||||
{"only active servers"}
|
||||
</Form.Check.Label>
|
||||
</Form.Check>
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import React from "react";
|
||||
import React, { act } from "react";
|
||||
import { withProps } from "recompose";
|
||||
import "@testing-library/jest-dom";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import {
|
||||
render,
|
||||
@@ -207,7 +206,6 @@ let mockUpdateUsers = jest.fn(({ offset, limit, sort, name_filter, state }) => {
|
||||
let searchParams = new URLSearchParams();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
useSelector.mockImplementation((callback) => {
|
||||
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);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(start_elems[0]);
|
||||
await fireEvent.click(start_elems[0]);
|
||||
});
|
||||
|
||||
expect(callbackSpy).toHaveBeenCalled();
|
||||
@@ -307,7 +305,7 @@ test("Invokes the stopServer event on button click", async () => {
|
||||
let stop = screen.getByText("Stop Server");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(stop);
|
||||
await fireEvent.click(stop);
|
||||
});
|
||||
|
||||
expect(callbackSpy).toHaveBeenCalled();
|
||||
@@ -323,7 +321,7 @@ test("Invokes the shutdownHub event on button click", async () => {
|
||||
let shutdown = screen.getByText("Shutdown Hub");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(shutdown);
|
||||
await fireEvent.click(shutdown);
|
||||
});
|
||||
|
||||
expect(callbackSpy).toHaveBeenCalled();
|
||||
@@ -338,7 +336,7 @@ test("Sorts according to username", async () => {
|
||||
|
||||
expect(searchParams.get("sort")).toEqual(null);
|
||||
let handler = screen.getByTestId(testId);
|
||||
fireEvent.click(handler);
|
||||
await fireEvent.click(handler);
|
||||
expect(searchParams.get("sort")).toEqual("name");
|
||||
|
||||
await act(async () => {
|
||||
@@ -346,7 +344,7 @@ test("Sorts according to username", async () => {
|
||||
handler = screen.getByTestId(testId);
|
||||
});
|
||||
|
||||
fireEvent.click(handler);
|
||||
await fireEvent.click(handler);
|
||||
expect(searchParams.get("sort")).toEqual("-name");
|
||||
|
||||
await act(async () => {
|
||||
@@ -354,7 +352,7 @@ test("Sorts according to username", async () => {
|
||||
handler = screen.getByTestId(testId);
|
||||
});
|
||||
|
||||
fireEvent.click(handler);
|
||||
await fireEvent.click(handler);
|
||||
expect(searchParams.get("sort")).toEqual("name");
|
||||
});
|
||||
|
||||
@@ -367,7 +365,7 @@ test("Sorts according to last activity", async () => {
|
||||
|
||||
expect(searchParams.get("sort")).toEqual(null);
|
||||
let handler = screen.getByTestId(testId);
|
||||
fireEvent.click(handler);
|
||||
await fireEvent.click(handler);
|
||||
expect(searchParams.get("sort")).toEqual("last_activity");
|
||||
|
||||
await act(async () => {
|
||||
@@ -375,7 +373,7 @@ test("Sorts according to last activity", async () => {
|
||||
handler = screen.getByTestId(testId);
|
||||
});
|
||||
|
||||
fireEvent.click(handler);
|
||||
await fireEvent.click(handler);
|
||||
expect(searchParams.get("sort")).toEqual("-last_activity");
|
||||
|
||||
await act(async () => {
|
||||
@@ -383,7 +381,7 @@ test("Sorts according to last activity", async () => {
|
||||
handler = screen.getByTestId(testId);
|
||||
});
|
||||
|
||||
fireEvent.click(handler);
|
||||
await fireEvent.click(handler);
|
||||
expect(searchParams.get("sort")).toEqual("last_activity");
|
||||
});
|
||||
|
||||
@@ -392,12 +390,10 @@ test("Filter according to server status (running/not running)", async () => {
|
||||
await act(async () => {
|
||||
rerender = render(serverDashboardJsx()).rerender;
|
||||
});
|
||||
console.log(rerender);
|
||||
console.log("begin test");
|
||||
const label = "only active servers";
|
||||
let handler = screen.getByLabelText(label);
|
||||
expect(handler.checked).toEqual(false);
|
||||
fireEvent.click(handler);
|
||||
await fireEvent.click(handler);
|
||||
|
||||
// FIXME: need to force a rerender to get updated checkbox
|
||||
// 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(handler.checked).toEqual(true);
|
||||
|
||||
fireEvent.click(handler);
|
||||
await fireEvent.click(handler);
|
||||
|
||||
await act(async () => {
|
||||
rerender(serverDashboardJsx());
|
||||
@@ -431,17 +427,14 @@ test("Shows server details with button click", async () => {
|
||||
expect(collapse).toHaveClass("collapse");
|
||||
expect(collapse).not.toHaveClass("show");
|
||||
expect(collapseBar).not.toHaveClass("show");
|
||||
|
||||
await fireEvent.click(button);
|
||||
await act(async () => {
|
||||
fireEvent.click(button);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(collapse).toHaveClass("collapse show");
|
||||
expect(collapseBar).not.toHaveClass("show");
|
||||
|
||||
await fireEvent.click(button);
|
||||
await act(async () => {
|
||||
fireEvent.click(button);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
@@ -449,8 +442,8 @@ test("Shows server details with button click", async () => {
|
||||
expect(collapse).not.toHaveClass("show");
|
||||
expect(collapseBar).not.toHaveClass("show");
|
||||
|
||||
await fireEvent.click(button);
|
||||
await act(async () => {
|
||||
fireEvent.click(button);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
@@ -480,7 +473,7 @@ test("Shows a UI error dialogue when start all servers fails", async () => {
|
||||
let startAll = screen.getByTestId("start-all");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(startAll);
|
||||
await fireEvent.click(startAll);
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(stopAll);
|
||||
await fireEvent.click(stopAll);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(start_elems[0]);
|
||||
await fireEvent.click(start_elems[0]);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(start_elems[0]);
|
||||
await fireEvent.click(start_elems[0]);
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(stop);
|
||||
await fireEvent.click(stop);
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(stop);
|
||||
await fireEvent.click(stop);
|
||||
});
|
||||
|
||||
let errorDialog = screen.getByText("Failed to stop server.");
|
||||
@@ -584,12 +577,13 @@ test("Search for user calls updateUsers with name filter", async () => {
|
||||
render(serverDashboardJsx());
|
||||
});
|
||||
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
let search = screen.getByLabelText("user-search");
|
||||
|
||||
expect(mockUpdateUsers.mock.calls).toHaveLength(1);
|
||||
|
||||
expect(searchParams.get("offset")).toEqual("2");
|
||||
userEvent.type(search, "a");
|
||||
await user.type(search, "a");
|
||||
expect(search.value).toEqual("a");
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
@@ -599,7 +593,7 @@ test("Search for user calls updateUsers with name filter", async () => {
|
||||
// FIXME: useSelector mocks prevent updateUsers from being called
|
||||
// expect(mockUpdateUsers.mock.calls).toHaveLength(2);
|
||||
// expect(mockUpdateUsers).toBeCalledWith(0, 100, "a");
|
||||
userEvent.type(search, "b");
|
||||
await user.type(search, "b");
|
||||
expect(search.value).toEqual("ab");
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
@@ -672,7 +666,7 @@ test("Start server and confirm pending state", async () => {
|
||||
expect(buttons[2].textContent).toBe("Edit User");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(buttons[0]);
|
||||
await fireEvent.click(buttons[0]);
|
||||
});
|
||||
expect(mockUpdateUsers.mock.calls).toHaveLength(1);
|
||||
|
||||
|
@@ -3,7 +3,7 @@ const base_url = jhdata.base_url || "/";
|
||||
const xsrfToken = jhdata.xsrf_token;
|
||||
|
||||
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) {
|
||||
api_url.searchParams.set("_xsrf", xsrfToken);
|
||||
}
|
||||
|
@@ -34,5 +34,5 @@ export const MainContainer = (props) => {
|
||||
MainContainer.propTypes = {
|
||||
errorAlert: PropTypes.string,
|
||||
setErrorAlert: PropTypes.func,
|
||||
children: PropTypes.array,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
@@ -3,7 +3,7 @@
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
# 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
|
||||
# 0.1.0rc1
|
||||
|
@@ -282,7 +282,7 @@ class JupyterHub(Application):
|
||||
|
||||
@default('classes')
|
||||
def _load_classes(self):
|
||||
classes = [Spawner, Authenticator, CryptKeeper]
|
||||
classes = {Spawner, Authenticator, CryptKeeper}
|
||||
for name, trait in self.traits(config=True).items():
|
||||
# load entry point groups into configurable class list
|
||||
# so that they show up in config files, etc.
|
||||
@@ -298,9 +298,9 @@ class JupyterHub(Application):
|
||||
e,
|
||||
)
|
||||
continue
|
||||
if cls not in classes and isinstance(cls, Configurable):
|
||||
classes.append(cls)
|
||||
return classes
|
||||
if issubclass(cls, Configurable):
|
||||
classes.add(cls)
|
||||
return list(classes)
|
||||
|
||||
load_groups = Dict(
|
||||
Union([Dict(), List()]),
|
||||
@@ -873,13 +873,7 @@ class JupyterHub(Application):
|
||||
but your identity provider is likely much more strict,
|
||||
allowing you to make assumptions about the name.
|
||||
|
||||
The default behavior is to have all services
|
||||
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,
|
||||
The 'idna' hook should produce a valid domain name for any user,
|
||||
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.
|
||||
|
||||
@@ -2182,7 +2176,11 @@ class JupyterHub(Application):
|
||||
# but changes to the allowed_users set can occur in the database,
|
||||
# and persist across sessions.
|
||||
total_users = 0
|
||||
blocked_users = self.authenticator.blocked_users
|
||||
for user in db.query(orm.User):
|
||||
if user.name in blocked_users:
|
||||
# don't call add_user with blocked users
|
||||
continue
|
||||
try:
|
||||
f = self.authenticator.add_user(user)
|
||||
if f:
|
||||
@@ -2238,6 +2236,35 @@ class JupyterHub(Application):
|
||||
await maybe_future(f)
|
||||
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):
|
||||
"""Load predefined groups into the database"""
|
||||
db = self.db
|
||||
@@ -2965,6 +2992,18 @@ class JupyterHub(Application):
|
||||
async def check_spawner(user, name, spawner):
|
||||
status = 0
|
||||
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:
|
||||
status = await spawner.poll()
|
||||
except Exception:
|
||||
@@ -3356,6 +3395,7 @@ class JupyterHub(Application):
|
||||
self.init_services()
|
||||
await self.init_api_tokens()
|
||||
await self.init_role_assignment()
|
||||
await self.init_blocked_users()
|
||||
self.init_tornado_settings()
|
||||
self.init_handlers()
|
||||
self.init_tornado_application()
|
||||
@@ -3414,7 +3454,6 @@ class JupyterHub(Application):
|
||||
metrics_collector = self.metrics_collector = PeriodicMetricsCollector(
|
||||
parent=self, db=self.db
|
||||
)
|
||||
metrics_collector.start()
|
||||
|
||||
async def cleanup(self):
|
||||
"""Shutdown managed services and various subprocesses. Cleanup runtime files."""
|
||||
@@ -3595,7 +3634,7 @@ class JupyterHub(Application):
|
||||
if service.managed:
|
||||
status = await service.spawner.poll()
|
||||
if status is not None:
|
||||
self.log.error(
|
||||
self.log.critical(
|
||||
"Service %s exited with status %s",
|
||||
service_name,
|
||||
status,
|
||||
@@ -3604,12 +3643,19 @@ class JupyterHub(Application):
|
||||
else:
|
||||
return True
|
||||
else:
|
||||
self.log.error(
|
||||
"Cannot connect to %s service %s at %s. Is it running?",
|
||||
service.kind,
|
||||
service_name,
|
||||
service.url,
|
||||
)
|
||||
if service.managed:
|
||||
self.log.critical(
|
||||
"Cannot connect to %s service %s",
|
||||
service_name,
|
||||
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 True
|
||||
|
||||
@@ -3643,6 +3689,9 @@ class JupyterHub(Application):
|
||||
loop.stop()
|
||||
return
|
||||
|
||||
# start collecting metrics
|
||||
self.metrics_collector.start()
|
||||
|
||||
# start the proxy
|
||||
if self.proxy.should_start:
|
||||
try:
|
||||
@@ -3697,18 +3746,8 @@ class JupyterHub(Application):
|
||||
# start the service(s)
|
||||
for service_name, service in self._service_map.items():
|
||||
service_ready = await self.start_service(service_name, service, ssl_context)
|
||||
if not service_ready:
|
||||
if service.from_config:
|
||||
# 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,
|
||||
)
|
||||
if not service_ready and service.managed:
|
||||
self.exit(1)
|
||||
|
||||
await self.proxy.check_routes(self.users, self._service_map)
|
||||
|
||||
@@ -3900,4 +3939,8 @@ UpgradeDB.classes.append(JupyterHub)
|
||||
main = JupyterHub.launch_instance
|
||||
|
||||
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.
|
||||
|
||||
.. 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
|
||||
`Authenticator.blacklist` renamed to `blocked_users`
|
||||
@@ -1230,7 +1238,20 @@ class PAMAuthenticator(LocalAuthenticator):
|
||||
|
||||
@default('executor')
|
||||
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(
|
||||
'utf8',
|
||||
|
@@ -1064,7 +1064,7 @@ class BaseHandler(RequestHandler):
|
||||
human_retry_time = "%i0 seconds" % math.ceil(retry_time / 10.0)
|
||||
else:
|
||||
# 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(
|
||||
'%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(),
|
||||
server_version=f'{__version__} {self.version_hash}',
|
||||
api_page_limit=self.settings["api_page_default_limit"],
|
||||
base_url=self.settings["base_url"],
|
||||
)
|
||||
self.finish(html)
|
||||
|
||||
|
@@ -1141,7 +1141,6 @@ class APIToken(Hashed, Base):
|
||||
expires_in=None,
|
||||
client_id=None,
|
||||
oauth_client=None,
|
||||
return_orm=False,
|
||||
):
|
||||
"""Generate a new API token for a 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}."
|
||||
" 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:
|
||||
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
|
||||
if SingleUserNotebookApp is not None:
|
||||
|
@@ -22,8 +22,6 @@ rather than keeing these monkey patches around.
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from jupyter_core import paths
|
||||
|
||||
|
||||
def _is_relative_to(path, prefix):
|
||||
"""
|
||||
@@ -68,6 +66,10 @@ def _disable_user_config(serverapp):
|
||||
2. Search paths for extensions, etc.
|
||||
3. import path
|
||||
"""
|
||||
# delayed import to avoid triggering early ImportError
|
||||
# with unmet dependencies
|
||||
from jupyter_core import paths
|
||||
|
||||
original_jupyter_path = paths.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"""
|
||||
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):
|
||||
# disable the exit confirmation for background notebook processes
|
||||
self.io_loop.add_callback_from_signal(self.io_loop.stop)
|
||||
def _handle_sigint(self, *args, **kwargs):
|
||||
self._signal_stop(*args, **kwargs)
|
||||
|
||||
def migrate_config(self):
|
||||
if self.disable_user_config:
|
||||
|
@@ -701,16 +701,7 @@ class Spawner(LoggingConfigurable):
|
||||
)
|
||||
|
||||
env_keep = List(
|
||||
[
|
||||
'PATH',
|
||||
'PYTHONPATH',
|
||||
'CONDA_ROOT',
|
||||
'CONDA_DEFAULT_ENV',
|
||||
'VIRTUAL_ENV',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
'JUPYTERHUB_SINGLEUSER_APP',
|
||||
],
|
||||
['JUPYTERHUB_SINGLEUSER_APP'],
|
||||
help="""
|
||||
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):
|
||||
"""
|
||||
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()
|
||||
# wait for progress message to appear
|
||||
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}/")):
|
||||
# wait for log messages to appear
|
||||
expected_messages = [
|
||||
@@ -293,7 +293,7 @@ async def test_spawn_pending_progress(
|
||||
]
|
||||
while not user.spawner.ready:
|
||||
logs_list = [
|
||||
await log.inner_text()
|
||||
await log.text_content()
|
||||
for log in await browser.locator("div.progress-log-event").all()
|
||||
]
|
||||
if progress_message:
|
||||
@@ -654,7 +654,7 @@ async def test_request_token_expiration(
|
||||
await api_token_table_area.locator("tr.token-row")
|
||||
.get_by_role("cell")
|
||||
.nth(0)
|
||||
.inner_text()
|
||||
.text_content()
|
||||
)
|
||||
|
||||
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")
|
||||
.get_by_role("cell")
|
||||
.nth(2)
|
||||
.inner_text()
|
||||
.text_content()
|
||||
)
|
||||
assert last_used_text == "Never"
|
||||
|
||||
@@ -671,7 +671,7 @@ async def test_request_token_expiration(
|
||||
await api_token_table_area.locator("tr.token-row")
|
||||
.get_by_role("cell")
|
||||
.nth(4)
|
||||
.inner_text()
|
||||
.text_content()
|
||||
)
|
||||
|
||||
if token_opt == "Never":
|
||||
@@ -734,7 +734,7 @@ async def test_request_token_permissions(
|
||||
if not granted:
|
||||
error_dialog = browser.locator("#error-dialog")
|
||||
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 expected_error in error_message
|
||||
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)
|
||||
await browser.goto(admin_page)
|
||||
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):
|
||||
@@ -1186,7 +1187,7 @@ async def test_paging_on_admin_page(
|
||||
re.compile(".*" + f"1-{min(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
|
||||
await btn_next.click()
|
||||
if users_count_db <= 100:
|
||||
@@ -1195,15 +1196,13 @@ async def test_paging_on_admin_page(
|
||||
)
|
||||
else:
|
||||
await expect(displaying).to_have_text(re.compile(".*" + "51-100" + ".*"))
|
||||
await expect(btn_next.locator("//span")).to_have_class("active-pagination")
|
||||
await expect(btn_previous.locator("//span")).to_have_class("active-pagination")
|
||||
await expect(btn_next).to_be_enabled()
|
||||
await expect(btn_previous).to_be_enabled()
|
||||
# click on Previous button
|
||||
await btn_previous.click()
|
||||
else:
|
||||
await expect(btn_next.locator("//span")).to_have_class("inactive-pagination")
|
||||
await expect(btn_previous.locator("//span")).to_have_class(
|
||||
"inactive-pagination"
|
||||
)
|
||||
await expect(btn_next).to_be_disabled()
|
||||
await expect(btn_previous).to_be_disabled()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -1256,6 +1255,7 @@ async def test_search_on_admin_page(
|
||||
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
|
||||
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"]')
|
||||
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(
|
||||
|
@@ -33,7 +33,9 @@ import sys
|
||||
from subprocess import TimeoutExpired
|
||||
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 tornado.httpclient import HTTPError
|
||||
from tornado.platform.asyncio import AsyncIOMainLoop
|
||||
@@ -57,6 +59,41 @@ from .utils import add_user
|
||||
# global db session object
|
||||
_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')
|
||||
def ssl_tmpdir(tmpdir_factory):
|
||||
@@ -125,15 +162,7 @@ def db():
|
||||
|
||||
|
||||
@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')
|
||||
async def io_loop(event_loop, request):
|
||||
async def io_loop(request):
|
||||
"""Mostly obsolete fixture for tornado event 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.
|
||||
"""
|
||||
io_loop = AsyncIOMainLoop()
|
||||
event_loop = asyncio.get_running_loop()
|
||||
assert asyncio.get_event_loop() is event_loop
|
||||
assert io_loop.asyncio_loop is event_loop
|
||||
|
||||
|
@@ -8,6 +8,7 @@ import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from subprocess import PIPE, Popen, check_output
|
||||
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
@@ -16,6 +17,8 @@ import pytest
|
||||
import traitlets
|
||||
from traitlets.config import Config
|
||||
|
||||
from jupyterhub.scopes import get_scopes_for
|
||||
|
||||
from .. import orm
|
||||
from ..app import COOKIE_SECRET_BYTES, JupyterHub
|
||||
from .mocking import MockHub
|
||||
@@ -289,8 +292,7 @@ def persist_db(tmpdir):
|
||||
def new_hub(request, tmpdir, persist_db):
|
||||
"""Fixture to launch a new hub for testing"""
|
||||
|
||||
async def new_hub():
|
||||
kwargs = {}
|
||||
async def new_hub(**kwargs):
|
||||
ssl_enabled = getattr(request.module, "ssl_enabled", False)
|
||||
if ssl_enabled:
|
||||
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 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()
|
||||
db = app.db
|
||||
# spawn a user's server
|
||||
@@ -537,3 +528,74 @@ async def test_recreate_service_from_database(
|
||||
# start one more, service should be gone
|
||||
app = await new_hub()
|
||||
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": {
|
||||
"version": "6.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.5.2.tgz",
|
||||
"integrity": "sha512-hRILoInAx8GNT5IMkrtIt9blOdrqHOnPBH+k70aWUAqPZPgopb9G5EQJFpaBx/S8zp2fC+mPW349Bziuk1o28Q==",
|
||||
"hasInstallScript": true,
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.6.0.tgz",
|
||||
"integrity": "sha512-60G28ke/sXdtS9KZCpZSHHkCbdsOGEhIUGlwq6yhY74UpTiToIh8np7A8yphhM4BWsvNFtIvLpi4co+h9Mr9Ow==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
@@ -39,31 +38,6 @@
|
||||
"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": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz",
|
||||
@@ -82,78 +56,19 @@
|
||||
"@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": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz",
|
||||
"integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
"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"
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.10.0"
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
"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": {
|
||||
@@ -162,98 +77,37 @@
|
||||
"integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==",
|
||||
"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": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.0.tgz",
|
||||
"integrity": "sha512-umpJ0/k8X0MvD1ds0P9SfowREz2LenHsQaxSohMZ5OMNEU2r0tf8pdeEFTHMFxWVxKNyU9rTtK3CWzUCTKJUeQ=="
|
||||
"version": "3.7.1",
|
||||
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
|
||||
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg=="
|
||||
},
|
||||
"node_modules/moment": {
|
||||
"version": "2.29.4",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
|
||||
"integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==",
|
||||
"version": "2.30.1",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.1.tgz",
|
||||
"integrity": "sha512-GkMg9uOTpIWWKbSsgwb5fA4EavTR+SG/PMPoAY8hkhHfEEY0/vqljY+XHqtDf2cr2IJtoNRDbrrEpZUiZCkYRw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.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"
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/requirejs": {
|
||||
"version": "2.3.6",
|
||||
"resolved": "https://registry.npmjs.org/requirejs/-/requirejs-2.3.6.tgz",
|
||||
"integrity": "sha512-ipEzlWQe6RK3jkzikgCupiTbTvm4S0/CAU5GlgptkN5SO6F3u0UD0K18wy6ErDqiCyP4J4YYe1HuAShvsxePLg==",
|
||||
"version": "2.3.7",
|
||||
"resolved": "https://registry.npmjs.org/requirejs/-/requirejs-2.3.7.tgz",
|
||||
"integrity": "sha512-DouTG8T1WanGok6Qjg2SXuCMzszOo0eHeH9hDZ5Y4x8Je+9JB38HdTLT4/VA8OaUhBa0JPVHJ0pyBkM1z+pDsw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"r_js": "bin/r.js",
|
||||
"r.js": "bin/r.js"
|
||||
@@ -263,12 +117,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.74.1",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.74.1.tgz",
|
||||
"integrity": "sha512-w0Z9p/rWZWelb88ISOLyvqTWGmtmu2QJICqDBGyNnfG4OUnPX9BBjjYIXUpXCMOOg5MQWNpqzt876la1fsTvUA==",
|
||||
"version": "1.79.4",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.79.4.tgz",
|
||||
"integrity": "sha512-K0QDSNPXgyqO4GZq2HO5Q70TLxTH6cIT59RdoCHMivrC8rqzaTw5ab9prjz9KUN1El4FLXrBXJhik61JR4HcGg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"chokidar": ">=3.0.0 <4.0.0",
|
||||
"chokidar": "^4.0.0",
|
||||
"immutable": "^4.0.0",
|
||||
"source-map-js": ">=0.6.2 <2.0.0"
|
||||
},
|
||||
@@ -287,18 +141,6 @@
|
||||
"engines": {
|
||||
"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
|
||||
[project]
|
||||
name = "jupyterhub"
|
||||
version = "5.1.0"
|
||||
version = "5.2.1"
|
||||
dynamic = ["readme", "dependencies"]
|
||||
description = "JupyterHub: A multi-user server for Jupyter notebooks"
|
||||
authors = [
|
||||
@@ -51,8 +51,7 @@ test = [
|
||||
# the test test_nbclassic_control_panel.
|
||||
"nbclassic",
|
||||
"pytest>=3.3",
|
||||
# FIXME: unpin pytest-asyncio
|
||||
"pytest-asyncio>=0.17,<0.23",
|
||||
"pytest-asyncio>=0.17,!=0.23.*",
|
||||
"pytest-cov",
|
||||
"pytest-rerunfailures",
|
||||
"requests-mock",
|
||||
@@ -147,7 +146,7 @@ indent_size = 2
|
||||
github_url = "https://github.com/jupyterhub/jupyterhub"
|
||||
|
||||
[tool.tbump.version]
|
||||
current = "5.1.0"
|
||||
current = "5.2.1"
|
||||
|
||||
# Example of a semver regexp.
|
||||
# Make sure this matches current_version before
|
||||
|
@@ -5,6 +5,8 @@
|
||||
|
||||
# automatically run coroutine tests with asyncio
|
||||
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
|
||||
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 {
|
||||
// no color change
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
// 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 name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% 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/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("js/darkmode.js") }}" type="text/javascript" charset="utf-8"></script>
|
||||
{% endblock scripts %}
|
||||
{# djlint js formatting doesn't handle template blocks in js #}
|
||||
{# djlint: off #}
|
||||
@@ -126,8 +129,8 @@
|
||||
</button>
|
||||
{% endif %}
|
||||
<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 %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ base_url }}home">Home</a>
|
||||
@@ -159,23 +162,33 @@
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endblock nav_bar_left_items %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
<ul class="nav navbar-nav me-2">
|
||||
{% 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">
|
||||
{% block login_widget %}
|
||||
<span id="login_widget">
|
||||
{% if user %}
|
||||
<span class="navbar-text me-1">{{ user.name }}</span>
|
||||
<span class="me-1">{{ user.name }}</span>
|
||||
<a id="logout"
|
||||
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>
|
||||
{% else %}
|
||||
<a id="login"
|
||||
role="button"
|
||||
class="btn btn-sm btn-outline-dark"
|
||||
class="btn btn-sm btn-outline-contrast"
|
||||
href="{{ login_url }}">Login</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
|
Reference in New Issue
Block a user