From 78492a4a8e789af55d24fb76e8f9f7a08de1a8a4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 19 Feb 2022 07:35:42 +0000 Subject: [PATCH 01/51] Bump url-parse from 1.5.3 to 1.5.7 in /jsx Bumps [url-parse](https://github.com/unshiftio/url-parse) from 1.5.3 to 1.5.7. - [Release notes](https://github.com/unshiftio/url-parse/releases) - [Commits](https://github.com/unshiftio/url-parse/compare/1.5.3...1.5.7) --- updated-dependencies: - dependency-name: url-parse dependency-type: indirect ... Signed-off-by: dependabot[bot] --- jsx/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/jsx/yarn.lock b/jsx/yarn.lock index 85d2da54..02eca65a 100644 --- a/jsx/yarn.lock +++ b/jsx/yarn.lock @@ -7878,9 +7878,9 @@ urix@^0.1.0: integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= url-parse@^1.4.3: - version "1.5.3" - resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.3.tgz#71c1303d38fb6639ade183c2992c8cc0686df862" - integrity sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ== + version "1.5.7" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.7.tgz#00780f60dbdae90181f51ed85fb24109422c932a" + integrity sha512-HxWkieX+STA38EDk7CE9MEryFeHCKzgagxlGvsdS7WBImq9Mk+PGwiT56w82WI3aicwJA8REp42Cxo98c8FZMA== dependencies: querystringify "^2.1.1" requires-port "^1.0.0" From 55f0579dccf041b77a1cb1aa6e5481e21061ea6b Mon Sep 17 00:00:00 2001 From: Thorin Tabor Date: Tue, 22 Feb 2022 13:16:37 -0800 Subject: [PATCH 02/51] idle-culler example config missing closing bracket --- docs/source/reference/services.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/source/reference/services.md b/docs/source/reference/services.md index 9a0cfa1b..611ed603 100644 --- a/docs/source/reference/services.md +++ b/docs/source/reference/services.md @@ -83,6 +83,7 @@ c.JupyterHub.load_roles = [ # 'admin:users' # needed if culling idle users as well ] } +] c.JupyterHub.services = [ { From 687a41a46798741666aa72323f2831760d1b1fe5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Feb 2022 04:27:03 +0000 Subject: [PATCH 03/51] Bump url-parse from 1.5.7 to 1.5.10 in /jsx Bumps [url-parse](https://github.com/unshiftio/url-parse) from 1.5.7 to 1.5.10. - [Release notes](https://github.com/unshiftio/url-parse/releases) - [Commits](https://github.com/unshiftio/url-parse/compare/1.5.7...1.5.10) --- updated-dependencies: - dependency-name: url-parse dependency-type: indirect ... Signed-off-by: dependabot[bot] --- jsx/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/jsx/yarn.lock b/jsx/yarn.lock index 02eca65a..0f726ee7 100644 --- a/jsx/yarn.lock +++ b/jsx/yarn.lock @@ -7878,9 +7878,9 @@ urix@^0.1.0: integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= url-parse@^1.4.3: - version "1.5.7" - resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.7.tgz#00780f60dbdae90181f51ed85fb24109422c932a" - integrity sha512-HxWkieX+STA38EDk7CE9MEryFeHCKzgagxlGvsdS7WBImq9Mk+PGwiT56w82WI3aicwJA8REp42Cxo98c8FZMA== + version "1.5.10" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" + integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ== dependencies: querystringify "^2.1.1" requires-port "^1.0.0" From b1ffd4b10be51bce9452fd29bef060eb1e7df982 Mon Sep 17 00:00:00 2001 From: Simon Li Date: Mon, 28 Feb 2022 21:46:53 +0000 Subject: [PATCH 04/51] Apache: set X-Forwarded-Proto header --- docs/source/reference/config-proxy.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/source/reference/config-proxy.md b/docs/source/reference/config-proxy.md index 28c01fd1..1e5c6bf7 100644 --- a/docs/source/reference/config-proxy.md +++ b/docs/source/reference/config-proxy.md @@ -208,6 +208,7 @@ Listen 443 # proxy to JupyterHub ProxyPass http://127.0.0.1:8000/ ProxyPassReverse http://127.0.0.1:8000/ + RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME} ``` From 27cb56429b5563d83c3e604e4ad884f651c31d7f Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 1 Mar 2022 09:43:01 +0100 Subject: [PATCH 05/51] HubAuth.get_token returns oauth token stored in cookie Useful for backend services that want to use the user's token. Added `in_cookie` bool argument to exclude cookies (previous behavior), since notebook servers do some things differently when auth is in query param or header vs cookies --- jupyterhub/services/auth.py | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/jupyterhub/services/auth.py b/jupyterhub/services/auth.py index 9315564d..716dfaac 100644 --- a/jupyterhub/services/auth.py +++ b/jupyterhub/services/auth.py @@ -501,11 +501,17 @@ class HubAuth(SingletonConfigurable): auth_header_name = 'Authorization' auth_header_pat = re.compile(r'(?:token|bearer)\s+(.+)', re.IGNORECASE) - def get_token(self, handler): - """Get the user token from a request + def get_token(self, handler, in_cookie=True): + """Get the token authenticating a request + + .. versionchanged:: 2.2 + in_cookie added. + Previously, only URL params and header were considered. + Pass `in_cookie=False` to preserve that behavior. - in URL parameters: ?token= - in header: Authorization: token + - in cookie (stored after oauth), if in_cookie is True """ user_token = handler.get_argument('token', '') @@ -516,8 +522,14 @@ class HubAuth(SingletonConfigurable): ) if m: user_token = m.group(1) + if not user_token and in_cookie: + user_token = self._get_token_cookie(handler) return user_token + def _get_token_cookie(self, handler): + """Base class doesn't store tokens in cookies""" + return None + def _get_user_cookie(self, handler): """Get the user model from a cookie""" # overridden in HubOAuth to store the access token after oauth @@ -553,8 +565,10 @@ class HubAuth(SingletonConfigurable): handler._cached_hub_user = user_model = None session_id = self.get_session_id(handler) - # check token first - token = self.get_token(handler) + # check token first, ignoring cookies + # because some checks are different when a request + # is token-authenticated (CORS-related) + token = self.get_token(handler, in_cookie=False) if token: user_model = self.user_for_token(token, session_id=session_id) if user_model: @@ -614,11 +628,18 @@ class HubOAuth(HubAuth): """ return self.cookie_name + '-oauth-state' - def _get_user_cookie(self, handler): + def _get_token_cookie(self, handler): + """Base class doesn't store tokens in cookies""" token = handler.get_secure_cookie(self.cookie_name) + if token: + # decode cookie bytes + token = token.decode('ascii', 'replace') + return token + + def _get_user_cookie(self, handler): + token = self._get_token_cookie(handler) session_id = self.get_session_id(handler) if token: - token = token.decode('ascii', 'replace') user_model = self.user_for_token(token, session_id=session_id) if user_model is None: app_log.warning("Token stored in cookie may have expired") From a0b60f911870389d5291356dae9020dccd5d2957 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 1 Mar 2022 09:45:14 +0100 Subject: [PATCH 06/51] place JupyterHub token in JupyterLab PageConfig restores token field useful for javascript-originating API requests, removed in 1.5 / 2.0 for security reasons because it was the wrong token. This places the _user's_ token in PageConfig, so it should have the right permissions. requires jupyterlab_server 2.9, has no effect on earlier versions. --- jupyterhub/singleuser/mixins.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/jupyterhub/singleuser/mixins.py b/jupyterhub/singleuser/mixins.py index 857de247..d2a5ec6a 100755 --- a/jupyterhub/singleuser/mixins.py +++ b/jupyterhub/singleuser/mixins.py @@ -16,7 +16,6 @@ import random import secrets import sys import warnings -from datetime import datetime from datetime import timezone from importlib import import_module from textwrap import dedent @@ -680,6 +679,7 @@ class SingleUserNotebookAppMixin(Configurable): s['hub_prefix'] = self.hub_prefix s['hub_host'] = self.hub_host s['hub_auth'] = self.hub_auth + s['page_config_hook'] = self.page_config_hook csp_report_uri = s['csp_report_uri'] = self.hub_host + url_path_join( self.hub_prefix, 'security/csp-report' ) @@ -707,6 +707,18 @@ class SingleUserNotebookAppMixin(Configurable): self.patch_default_headers() self.patch_templates() + def page_config_hook(self, handler, page_config): + """JupyterLab page config hook + + Adds JupyterHub info to page config. + + Places the JupyterHub API token in PageConfig.token. + + Only has effect on jupyterlab_server >=2.9 + """ + page_config["token"] = self.hub_auth.get_token(handler) or "" + return page_config + def patch_default_headers(self): if hasattr(RequestHandler, '_orig_set_default_headers'): return From 9c498aa5d4dec09758ff4c1c5c43da6074a0d0f1 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 1 Mar 2022 10:03:15 +0100 Subject: [PATCH 07/51] Document HubOAuth.get_token for requests on behalf of users --- docs/source/reference/services.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/source/reference/services.md b/docs/source/reference/services.md index 611ed603..00c8446a 100644 --- a/docs/source/reference/services.md +++ b/docs/source/reference/services.md @@ -246,6 +246,19 @@ action. HubAuth also caches the Hub's response for a number of seconds, configurable by the `cookie_cache_max_age` setting (default: five minutes). +If your service would like to make further requests _on behalf of users_, +it should use the token issued by this OAuth process. +If you are using tornado, +you can access the token authenticating the current request with {meth}`.HubAuth.get_token`. + +:::{versionchanged} 2.2 + +{meth}`.HubAuth.get_token` adds support for retrieving +tokens stored in tornado cookies after completion of OAuth. +Previously, it only retrieved tokens from URL parameters or the Authorization header. +Passing `get_token(handler, in_cookie=False)` preserves this behavior. +::: + ### Flask Example For example, you have a Flask service that returns information about a user. From 960f7cbeb9de0cc2ad0597d6bee0a788cdb71104 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 1 Mar 2022 11:40:00 +0100 Subject: [PATCH 08/51] Keep Spawner.server in sync with underlying orm_spawner.server Rather than one-time sets of ._server allowing it to become out-of-sync with underlying orm_spawner.server --- jupyterhub/spawner.py | 34 +++++++++++++++++++---- jupyterhub/tests/test_spawner.py | 46 ++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index 5f445096..1ba82df4 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -184,17 +184,39 @@ class Spawner(LoggingConfigurable): def last_activity(self): return self.orm_spawner.last_activity + # Spawner.server is a wrapper of the ORM orm_spawner.server + # make sure it's always in sync with the underlying state + # this is harder to do with traitlets, + # which do not run on every access, only on set and first-get + _server = None + @property def server(self): - if hasattr(self, '_server'): - return self._server - if self.orm_spawner and self.orm_spawner.server: - return Server(orm_server=self.orm_spawner.server) + # always check that we're in sync with orm_spawner + if not self.orm_spawner: + # no ORM spawner, nothing to check + self._server = None + return None + + orm_server = self.orm_spawner.server + + if orm_server is not None and ( + self._server is None or orm_server is not self._server.orm_server + ): + # self._server is not connected to orm_spawner + self._server = Server(orm_server=self.orm_spawner.server) + elif orm_server is None: + # no ORM server, clear it + self._server = None + return self._server @server.setter def server(self, server): self._server = server - if self.orm_spawner: + if self.orm_spawner is not None: + if server is not None and server.orm_server == self.orm_spawner.server: + # no change + return if self.orm_spawner.server is not None: # delete the old value db = inspect(self.orm_spawner.server).session @@ -202,6 +224,8 @@ class Spawner(LoggingConfigurable): if server is None: self.orm_spawner.server = None else: + if server.orm_server is None: + self.log.warning(f"No ORM server for {self._log_name}") self.orm_spawner.server = server.orm_server @property diff --git a/jupyterhub/tests/test_spawner.py b/jupyterhub/tests/test_spawner.py index bf59a4eb..455a5c03 100644 --- a/jupyterhub/tests/test_spawner.py +++ b/jupyterhub/tests/test_spawner.py @@ -483,3 +483,49 @@ async def test_spawner_options_from_form_with_spawner(db): for key, value in form_data.items(): assert key in result assert result[key] == value + + +def test_spawner_server(db): + spawner = new_spawner(db) + spawner.orm_spawner = None + orm_spawner = orm.Spawner() + orm_server = orm.Server(base_url="/1/") + orm_spawner.server = orm_server + db.add(orm_spawner) + db.add(orm_server) + db.commit() + # initial: no orm_spawner + assert spawner.server is None + # assigning spawner.orm_spawner updates spawner.server + spawner.orm_spawner = orm_spawner + assert spawner.server is not None + assert spawner.server.orm_server is orm_server + # update orm_spawner.server without direct access on Spawner + orm_spawner.server = new_server = orm.Server(base_url="/2/") + db.commit() + assert spawner.server is not None + assert spawner.server.orm_server is not orm_server + assert spawner.server.orm_server is new_server + # clear orm_server via orm_spawner clears spawner.server + orm_spawner.server = None + db.commit() + assert spawner.server is None + # assigning spawner.server updates orm_spawner.server + orm_server = orm.Server(base_url="/3/") + db.add(orm_server) + db.commit() + spawner.server = server = Server(orm_server=orm_server) + db.commit() + assert spawner.server is server + assert spawner.orm_spawner.server is orm_server + # change orm spawner.server + orm_server = orm.Server(base_url="/4/") + db.add(orm_server) + db.commit() + spawner.server = server2 = Server(orm_server=orm_server) + assert spawner.server is server2 + assert spawner.orm_spawner.server is orm_server + # clear server via spawner.server + spawner.server = None + db.commit() + assert spawner.orm_spawner.server is None From 2fc4f26832ba4de2d61998194685c30a4561168d Mon Sep 17 00:00:00 2001 From: Konstantin Taletskiy Date: Tue, 1 Mar 2022 12:25:54 -0800 Subject: [PATCH 09/51] Update example to not reference an undefined scope Fixes #3811 --- docs/source/reference/rest.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/source/reference/rest.md b/docs/source/reference/rest.md index 1824c5bd..12f8f44e 100644 --- a/docs/source/reference/rest.md +++ b/docs/source/reference/rest.md @@ -113,7 +113,6 @@ c.JupyterHub.load_roles = [ "scopes": [ # specify the permissions the token should have "admin:users", - "admin:services", ], "services": [ # assign the service the above permissions From 10d5157e95eaad5d720ab64ee5a45acb46bceb60 Mon Sep 17 00:00:00 2001 From: Richard Zowalla Date: Thu, 3 Mar 2022 16:30:47 +0100 Subject: [PATCH 10/51] Apache2 Documentation: Updates Reverse Proxy Configuration (TLS/SSL, Protocols, Headers) --- docs/source/reference/config-proxy.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/source/reference/config-proxy.md b/docs/source/reference/config-proxy.md index 1e5c6bf7..0bc28e73 100644 --- a/docs/source/reference/config-proxy.md +++ b/docs/source/reference/config-proxy.md @@ -165,7 +165,7 @@ As with nginx above, you can use [Apache](https://httpd.apache.org) as the rever First, we will need to enable the apache modules that we are going to need: ```bash -a2enmod ssl rewrite proxy proxy_http proxy_wstunnel +a2enmod ssl rewrite proxy headers proxy_http proxy_wstunnel ``` Our Apache configuration is equivalent to the nginx configuration above: @@ -188,13 +188,24 @@ Listen 443 ServerName HUB.DOMAIN.TLD + # enable HTTP/2, if available + Protocols h2 http/1.1 + + # HTTP Strict Transport Security (mod_headers is required) (63072000 seconds) + Header always set Strict-Transport-Security "max-age=63072000" + # configure SSL SSLEngine on SSLCertificateFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/fullchain.pem SSLCertificateKeyFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/privkey.pem - SSLProtocol All -SSLv2 -SSLv3 SSLOpenSSLConfCmd DHParameters /etc/ssl/certs/dhparam.pem - SSLCipherSuite EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH + + # intermediate configuration from ssl-config.mozilla.org (2022-03-03) + # Please note, that this configuration might be out-dated - please update it accordingly using https://ssl-config.mozilla.org/ + SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1 + SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384 + SSLHonorCipherOrder off + SSLSessionTickets off # Use RewriteEngine to handle websocket connection upgrades RewriteEngine On From 99cb1f17f009fa37748dc05ffb03deb84b6cf384 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 3 Mar 2022 15:41:16 +0000 Subject: [PATCH 11/51] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docs/source/reference/config-proxy.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/reference/config-proxy.md b/docs/source/reference/config-proxy.md index 0bc28e73..c1a8d538 100644 --- a/docs/source/reference/config-proxy.md +++ b/docs/source/reference/config-proxy.md @@ -193,13 +193,13 @@ Listen 443 # HTTP Strict Transport Security (mod_headers is required) (63072000 seconds) Header always set Strict-Transport-Security "max-age=63072000" - + # configure SSL SSLEngine on SSLCertificateFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/fullchain.pem SSLCertificateKeyFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/privkey.pem SSLOpenSSLConfCmd DHParameters /etc/ssl/certs/dhparam.pem - + # intermediate configuration from ssl-config.mozilla.org (2022-03-03) # Please note, that this configuration might be out-dated - please update it accordingly using https://ssl-config.mozilla.org/ SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1 From 8f18303e504b505f596c95bfb6434c162f6092fb Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 4 Mar 2022 10:41:11 +0100 Subject: [PATCH 12/51] fix some links revealed by myst mostly pre-myst markdown links --- docs/source/conf.py | 1 + docs/source/reference/authenticators.md | 17 ++++++++--------- docs/source/reference/services.md | 15 +++++---------- docs/source/troubleshooting.md | 2 +- 4 files changed, 15 insertions(+), 20 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index c3f10040..2c6dc76e 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -21,6 +21,7 @@ extensions = [ 'myst_parser', ] +myst_heading_anchors = 2 myst_enable_extensions = [ 'colon_fence', 'deflist', diff --git a/docs/source/reference/authenticators.md b/docs/source/reference/authenticators.md index 61f8ecbe..0d3ac3cd 100644 --- a/docs/source/reference/authenticators.md +++ b/docs/source/reference/authenticators.md @@ -1,6 +1,6 @@ # Authenticators -The [Authenticator][] is the mechanism for authorizing users to use the +The {class}`.Authenticator` is the mechanism for authorizing users to use the Hub and single user notebook servers. ## The default PAM Authenticator @@ -137,8 +137,8 @@ via other mechanisms. One such example is using [GitHub OAuth][]. Because the username is passed from the Authenticator to the Spawner, a custom Authenticator and Spawner are often used together. -For example, the Authenticator methods, [pre_spawn_start(user, spawner)][] -and [post_spawn_stop(user, spawner)][], are hooks that can be used to do +For example, the Authenticator methods, {meth}`.Authenticator.pre_spawn_start` +and {meth}`.Authenticator.post_spawn_stop`, are hooks that can be used to do auth-related startup (e.g. opening PAM sessions) and cleanup (e.g. closing PAM sessions). @@ -223,7 +223,7 @@ If there are multiple keys present, the **first** key is always used to persist Typically, if `auth_state` is persisted it is desirable to affect the Spawner environment in some way. This may mean defining environment variables, placing certificate in the user's home directory, etc. -The `Authenticator.pre_spawn_start` method can be used to pass information from authenticator state +The {meth}`Authenticator.pre_spawn_start` method can be used to pass information from authenticator state to Spawner environment: ```python @@ -247,6 +247,8 @@ class MyAuthenticator(Authenticator): spawner.environment['UPSTREAM_TOKEN'] = auth_state['upstream_token'] ``` +(authenticator-groups)= + ## Authenticator-managed group membership :::{versionadded} 2.2 @@ -279,8 +281,8 @@ all group-management via the API is disabled. ## pre_spawn_start and post_spawn_stop hooks -Authenticators uses two hooks, [pre_spawn_start(user, spawner)][] and -[post_spawn_stop(user, spawner)][] to add pass additional state information +Authenticators uses two hooks, {meth}`.Authenticator.pre_spawn_start` and +{meth}`.Authenticator.post_spawn_stop(user, spawner)` to add pass additional state information between the authenticator and a spawner. These hooks are typically used auth-related startup, i.e. opening a PAM session, and auth-related cleanup, i.e. closing a PAM session. @@ -289,10 +291,7 @@ PAM session. Beginning with version 0.8, JupyterHub is an OAuth provider. -[authenticator]: https://github.com/jupyterhub/jupyterhub/blob/HEAD/jupyterhub/auth.py [pam]: https://en.wikipedia.org/wiki/Pluggable_authentication_module [oauth]: https://en.wikipedia.org/wiki/OAuth [github oauth]: https://developer.github.com/v3/oauth/ [oauthenticator]: https://github.com/jupyterhub/oauthenticator -[pre_spawn_start(user, spawner)]: https://jupyterhub.readthedocs.io/en/latest/api/auth.html#jupyterhub.auth.Authenticator.pre_spawn_start -[post_spawn_stop(user, spawner)]: https://jupyterhub.readthedocs.io/en/latest/api/auth.html#jupyterhub.auth.Authenticator.post_spawn_stop diff --git a/docs/source/reference/services.md b/docs/source/reference/services.md index 611ed603..ca82249f 100644 --- a/docs/source/reference/services.md +++ b/docs/source/reference/services.md @@ -209,23 +209,23 @@ can be used by services. You may go beyond this reference implementation and create custom hub-authenticating clients and services. We describe the process below. -The reference, or base, implementation is the [`HubAuth`][hubauth] class, +The reference, or base, implementation is the {class}`.HubAuth` class, which implements the API requests to the Hub that resolve a token to a User model. There are two levels of authentication with the Hub: -- [`HubAuth`][hubauth] - the most basic authentication, +- {class}`.HubAuth` - the most basic authentication, for services that should only accept API requests authorized with a token. -- [`HubOAuth`][huboauth] - For services that should use oauth to authenticate with the Hub. +- {class}`.HubOAuth` - For services that should use oauth to authenticate with the Hub. This should be used for any service that serves pages that should be visited with a browser. To use HubAuth, you must set the `.api_token`, either programmatically when constructing the class, or via the `JUPYTERHUB_API_TOKEN` environment variable. Most of the logic for authentication implementation is found in the -[`HubAuth.user_for_token`][hubauth.user_for_token] -methods, which makes a request of the Hub, and returns: +{meth}`.HubAuth.user_for_token` methods, +which makes a request of the Hub, and returns: - None, if no user could be identified, or - a dict of the following form: @@ -371,11 +371,6 @@ section on securing the notebook viewer. [requests]: http://docs.python-requests.org/en/master/ [services_auth]: ../api/services.auth.html -[hubauth]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth -[huboauth]: ../api/services.auth.html#jupyterhub.services.auth.HubOAuth -[hubauth.user_for_token]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth.user_for_token -[hubauthenticated]: ../api/services.auth.html#jupyterhub.services.auth.HubAuthenticated -[huboauthenticated]: ../api/services.auth.html#jupyterhub.services.auth.HubOAuthenticated [nbviewer example]: https://github.com/jupyter/nbviewer#securing-the-notebook-viewer [fastapi example]: https://github.com/jupyterhub/jupyterhub/tree/HEAD/examples/service-fastapi [fastapi]: https://fastapi.tiangolo.com diff --git a/docs/source/troubleshooting.md b/docs/source/troubleshooting.md index 898b46c8..efca64d0 100644 --- a/docs/source/troubleshooting.md +++ b/docs/source/troubleshooting.md @@ -275,7 +275,7 @@ where `ssl_cert` is example-chained.crt and ssl_key to your private key. Then restart JupyterHub. -See also [JupyterHub SSL encryption](./getting-started/security-basics.html#ssl-encryption). +See also {ref}`ssl-encryption`. ### Install JupyterHub without a network connection From 7861662e17a973015e6db7b4636a045e0ef6cb51 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 22 Feb 2022 10:42:45 +0100 Subject: [PATCH 13/51] Replace failed spawners when starting new launch Avoids leaving stale state when re-using a spawner that failed the last time it started we keep failed spawners around to track their errors, but we don't want to re-use them when it comes time to start a new launch. adds User.get_spawner(server_name, replace_failed=True) to always get a non-failed Spawner --- jupyterhub/apihandlers/users.py | 2 +- jupyterhub/handlers/pages.py | 14 +++++--------- jupyterhub/tests/test_api.py | 9 ++++++++- jupyterhub/tests/test_pages.py | 34 ++++++++++++++++++++++++++++++++- jupyterhub/user.py | 18 ++++++++++++++++- 5 files changed, 64 insertions(+), 13 deletions(-) diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py index 2e3cbae3..321a0851 100644 --- a/jupyterhub/apihandlers/users.py +++ b/jupyterhub/apihandlers/users.py @@ -515,7 +515,7 @@ class UserServerAPIHandler(APIHandler): user_name, self.named_server_limit_per_user ), ) - spawner = user.spawners[server_name] + spawner = user.get_spawner(server_name, replace_failed=True) pending = spawner.pending if pending == 'spawn': self.set_header('Content-Type', 'text/plain') diff --git a/jupyterhub/handlers/pages.py b/jupyterhub/handlers/pages.py index 3a063f62..cd311375 100644 --- a/jupyterhub/handlers/pages.py +++ b/jupyterhub/handlers/pages.py @@ -151,7 +151,7 @@ class SpawnHandler(BaseHandler): self.redirect(url) return - spawner = user.spawners[server_name] + spawner = user.get_spawner(server_name, replace_failed=True) pending_url = self._get_pending_url(user, server_name) @@ -237,7 +237,7 @@ class SpawnHandler(BaseHandler): if user is None: raise web.HTTPError(404, "No such user: %s" % for_user) - spawner = user.spawners[server_name] + spawner = user.get_spawner(server_name, replace_failed=True) if spawner.ready: raise web.HTTPError(400, "%s is already running" % (spawner._log_name)) @@ -369,13 +369,9 @@ class SpawnPendingHandler(BaseHandler): auth_state = await user.get_auth_state() # First, check for previous failure. - if ( - not spawner.active - and spawner._spawn_future - and spawner._spawn_future.done() - and spawner._spawn_future.exception() - ): - # Condition: spawner not active and _spawn_future exists and contains an Exception + if not spawner.active and spawner._failed: + # Condition: spawner not active and last spawn failed + # (failure is available as spawner._spawn_future.exception()). # Implicit spawn on /user/:name is not allowed if the user's last spawn failed. # We should point the user to Home if the most recent spawn failed. exc = spawner._spawn_future.exception() diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index cc63e78d..38ad4a5b 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -1030,7 +1030,7 @@ async def test_never_spawn(app, no_patience, never_spawn): assert not app_user.spawner._spawn_pending status = await app_user.spawner.poll() assert status is not None - # failed spawn should decrements pending count + # failed spawn should decrement pending count assert app.users.count_active_users()['pending'] == 0 @@ -1039,9 +1039,16 @@ async def test_bad_spawn(app, bad_spawn): name = 'prim' user = add_user(db, app=app, name=name) r = await api_request(app, 'users', name, 'server', method='post') + # check that we don't re-use spawners that failed + user.spawners[''].reused = True assert r.status_code == 500 assert app.users.count_active_users()['pending'] == 0 + r = await api_request(app, 'users', name, 'server', method='post') + # check that we don't re-use spawners that failed + spawner = user.spawners[''] + assert not getattr(spawner, 'reused', False) + async def test_spawn_nosuch_user(app): r = await api_request(app, 'users', "nosuchuser", 'server', method='post') diff --git a/jupyterhub/tests/test_pages.py b/jupyterhub/tests/test_pages.py index d5a84d4e..0edfd2db 100644 --- a/jupyterhub/tests/test_pages.py +++ b/jupyterhub/tests/test_pages.py @@ -128,11 +128,20 @@ async def test_admin_sort(app, sort): assert r.status_code == 200 -async def test_spawn_redirect(app): +@pytest.mark.parametrize("last_failed", [True, False]) +async def test_spawn_redirect(app, last_failed): name = 'wash' cookies = await app.login_user(name) u = app.users[orm.User.find(app.db, name)] + if last_failed: + # mock a failed spawn + last_spawner = u.spawners[''] + last_spawner._spawn_future = asyncio.Future() + last_spawner._spawn_future.set_exception(RuntimeError("I failed!")) + else: + last_spawner = None + status = await u.spawner.poll() assert status is not None @@ -141,6 +150,10 @@ async def test_spawn_redirect(app): r.raise_for_status() print(urlparse(r.url)) path = urlparse(r.url).path + + # ensure we got a new spawner + assert u.spawners[''] is not last_spawner + # make sure we visited hub/spawn-pending after spawn # if spawn was really quick, we might get redirected all the way to the running server, # so check history instead of r.url @@ -258,6 +271,25 @@ async def test_spawn_page(app): assert FormSpawner.options_form in r.text +async def test_spawn_page_after_failed(app, user): + cookies = await app.login_user(user.name) + + # mock a failed spawn + last_spawner = user.spawners[''] + last_spawner._spawn_future = asyncio.Future() + last_spawner._spawn_future.set_exception(RuntimeError("I failed!")) + + with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}): + r = await get_page('spawn', app, cookies=cookies) + spawner = user.spawners[''] + # make sure we didn't reuse last spawner + assert isinstance(spawner, FormSpawner) + assert spawner is not last_spawner + assert r.url.endswith('/spawn') + spawner = user.spawners[''] + assert FormSpawner.options_form in r.text + + async def test_spawn_page_falsy_callable(app): with mock.patch.dict( app.users.settings, {'spawner_class': FalsyCallableFormSpawner} diff --git a/jupyterhub/user.py b/jupyterhub/user.py index b841e22c..71ee6ce5 100644 --- a/jupyterhub/user.py +++ b/jupyterhub/user.py @@ -253,6 +253,22 @@ class User: def spawner_class(self): return self.settings.get('spawner_class', LocalProcessSpawner) + def get_spawner(self, server_name="", replace_failed=False): + """Get a spawner by name + + replace_failed governs whether a failed spawner should be replaced + or returned (default: returned). + + .. versionadded:: 2.2 + """ + spawner = self.spawners[server_name] + if replace_failed and spawner._failed: + self.log.debug(f"Discarding failed spawner {spawner._log_name}") + # remove failed spawner, create a new one + self.spawners.pop(server_name) + spawner = self.spawners[server_name] + return spawner + def sync_groups(self, group_names): """Synchronize groups with database""" @@ -628,7 +644,7 @@ class User: api_token = self.new_api_token(note=note, roles=['server']) db.commit() - spawner = self.spawners[server_name] + spawner = self.get_spawner(server_name, replace_failed=True) spawner.server = server = Server(orm_server=orm_server) assert spawner.orm_spawner.server is orm_server From 4a6c9c3a014a08e1ef847002897a5cbae08c12c6 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 4 Mar 2022 10:10:11 +0100 Subject: [PATCH 14/51] Prepare changelog for 2.2 --- docs/source/changelog.md | 48 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/docs/source/changelog.md b/docs/source/changelog.md index 8a19b2c9..5ec3b8d3 100644 --- a/docs/source/changelog.md +++ b/docs/source/changelog.md @@ -6,6 +6,54 @@ command line for details. ## [Unreleased] +## 2.2 + +# 2.2.0 2021-03-07 + +JupyterHub 2.2.0 is a small release. +The main new feature is the ability of Authenticators to [manage group membership](authenticator-groups), +e.g. when the identity provider has its own concept of groups that should be preserved +in JupyterHub. + +The links to access user servers from the admin page have been restored. + +([full changelog](https://github.com/jupyterhub/jupyterhub/compare/2.1.1...2.2.0)) + +#### New features added + +- Enable `options_from_form(spawner, form_data)` signature from configuration file [#3791](https://github.com/jupyterhub/jupyterhub/pull/3791) ([@rcthomas](https://github.com/rcthomas), [@minrk](https://github.com/minrk)) +- Authenticator user group management [#3548](https://github.com/jupyterhub/jupyterhub/pull/3548) ([@thomafred](https://github.com/thomafred), [@minrk](https://github.com/minrk)) + +#### Enhancements made + +- Add user token to JupyterLab PageConfig [#3809](https://github.com/jupyterhub/jupyterhub/pull/3809) ([@minrk](https://github.com/minrk), [@manics](https://github.com/manics), [@consideRatio](https://github.com/consideRatio)) +- show insecure-login-warning for all authenticators [#3793](https://github.com/jupyterhub/jupyterhub/pull/3793) ([@satra](https://github.com/satra), [@minrk](https://github.com/minrk)) +- short-circuit token permission check if token and owner share role [#3792](https://github.com/jupyterhub/jupyterhub/pull/3792) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio)) +- Named server support, access links in admin page [#3790](https://github.com/jupyterhub/jupyterhub/pull/3790) ([@NarekA](https://github.com/NarekA), [@minrk](https://github.com/minrk), [@ykazakov](https://github.com/ykazakov), [@manics](https://github.com/manics)) + +#### Bugs fixed + +- Keep Spawner.server in sync with underlying orm_spawner.server [#3810](https://github.com/jupyterhub/jupyterhub/pull/3810) ([@minrk](https://github.com/minrk), [@manics](https://github.com/manics), [@GeorgianaElena](https://github.com/GeorgianaElena), [@consideRatio](https://github.com/consideRatio)) +- Replace failed spawners when starting new launch [#3802](https://github.com/jupyterhub/jupyterhub/pull/3802) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio)) +- Log proxy's public_url only when started by JupyterHub [#3781](https://github.com/jupyterhub/jupyterhub/pull/3781) ([@cqzlxl](https://github.com/cqzlxl), [@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) + +#### Documentation improvements + +- Apache2 Documentation: Updates Reverse Proxy Configuration (TLS/SSL, Protocols, Headers) [#3813](https://github.com/jupyterhub/jupyterhub/pull/3813) ([@rzo1](https://github.com/rzo1), [@minrk](https://github.com/minrk)) +- Update example to not reference an undefined scope [#3812](https://github.com/jupyterhub/jupyterhub/pull/3812) ([@ktaletsk](https://github.com/ktaletsk), [@minrk](https://github.com/minrk)) +- Apache: set X-Forwarded-Proto header [#3808](https://github.com/jupyterhub/jupyterhub/pull/3808) ([@manics](https://github.com/manics), [@consideRatio](https://github.com/consideRatio), [@rzo1](https://github.com/rzo1), [@tobi45](https://github.com/tobi45)) +- idle-culler example config missing closing bracket [#3803](https://github.com/jupyterhub/jupyterhub/pull/3803) ([@tmtabor](https://github.com/tmtabor), [@consideRatio](https://github.com/consideRatio)) + +#### Behavior Changes + +- Stop opening PAM sessions by default [#3787](https://github.com/jupyterhub/jupyterhub/pull/3787) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio)) + +#### Contributors to this release + +([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2022-01-25&to=2022-03-07&type=c)) + +[@blink1073](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ablink1073+updated%3A2022-01-25..2022-03-07&type=Issues) | [@clkao](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aclkao+updated%3A2022-01-25..2022-03-07&type=Issues) | [@consideRatio](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2022-01-25..2022-03-07&type=Issues) | [@cqzlxl](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Acqzlxl+updated%3A2022-01-25..2022-03-07&type=Issues) | [@dependabot](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Adependabot+updated%3A2022-01-25..2022-03-07&type=Issues) | [@dtaniwaki](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Adtaniwaki+updated%3A2022-01-25..2022-03-07&type=Issues) | [@fcollonval](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Afcollonval+updated%3A2022-01-25..2022-03-07&type=Issues) | [@GeorgianaElena](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AGeorgianaElena+updated%3A2022-01-25..2022-03-07&type=Issues) | [@github-actions](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Agithub-actions+updated%3A2022-01-25..2022-03-07&type=Issues) | [@kshitija08](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Akshitija08+updated%3A2022-01-25..2022-03-07&type=Issues) | [@ktaletsk](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aktaletsk+updated%3A2022-01-25..2022-03-07&type=Issues) | [@manics](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amanics+updated%3A2022-01-25..2022-03-07&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2022-01-25..2022-03-07&type=Issues) | [@NarekA](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3ANarekA+updated%3A2022-01-25..2022-03-07&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Apre-commit-ci+updated%3A2022-01-25..2022-03-07&type=Issues) | [@rajat404](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Arajat404+updated%3A2022-01-25..2022-03-07&type=Issues) | [@rcthomas](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Arcthomas+updated%3A2022-01-25..2022-03-07&type=Issues) | [@ryogesh](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aryogesh+updated%3A2022-01-25..2022-03-07&type=Issues) | [@rzo1](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Arzo1+updated%3A2022-01-25..2022-03-07&type=Issues) | [@satra](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Asatra+updated%3A2022-01-25..2022-03-07&type=Issues) | [@thomafred](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Athomafred+updated%3A2022-01-25..2022-03-07&type=Issues) | [@tmtabor](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Atmtabor+updated%3A2022-01-25..2022-03-07&type=Issues) | [@tobi45](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Atobi45+updated%3A2022-01-25..2022-03-07&type=Issues) | [@ykazakov](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aykazakov+updated%3A2022-01-25..2022-03-07&type=Issues) + ## 2.1 ### 2.1.1 2021-01-25 From c23cddeb515dbcc0bc59c063ddba32d191624dcb Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 7 Mar 2022 14:35:46 +0100 Subject: [PATCH 15/51] Bump to 2.2.0 --- docs/source/_static/rest-api.yml | 2 +- jupyterhub/_version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/source/_static/rest-api.yml b/docs/source/_static/rest-api.yml index 17fbf9d2..7677a4a1 100644 --- a/docs/source/_static/rest-api.yml +++ b/docs/source/_static/rest-api.yml @@ -6,7 +6,7 @@ info: description: The REST API for JupyterHub license: name: BSD-3-Clause - version: 2.2.0.dev + version: 2.2.0 servers: - url: /hub/api security: diff --git a/jupyterhub/_version.py b/jupyterhub/_version.py index 0cb882c1..d70c4a6f 100644 --- a/jupyterhub/_version.py +++ b/jupyterhub/_version.py @@ -2,7 +2,7 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. # version_info updated by running `tbump` -version_info = (2, 2, 0, "", "dev") +version_info = (2, 2, 0, "", "") # pep 440 version: no dot before beta/rc, but before .dev # 0.1.0rc1 diff --git a/pyproject.toml b/pyproject.toml index 20d21964..44f813c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ target_version = [ github_url = "https://github.com/jupyterhub/jupyterhub" [tool.tbump.version] -current = "2.2.0.dev" +current = "2.2.0" # Example of a semver regexp. # Make sure this matches current_version before From 45132b72444537a0df914211127b69c2ad74b18c Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 10 Mar 2022 15:40:01 +0100 Subject: [PATCH 16/51] allow Spawner.server to be mocked without underlying orm_spawner --- jupyterhub/spawner.py | 7 +++++-- jupyterhub/tests/test_spawner.py | 7 +++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index 1ba82df4..9b755cfb 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -195,8 +195,7 @@ class Spawner(LoggingConfigurable): # always check that we're in sync with orm_spawner if not self.orm_spawner: # no ORM spawner, nothing to check - self._server = None - return None + return self._server orm_server = self.orm_spawner.server @@ -227,6 +226,10 @@ class Spawner(LoggingConfigurable): if server.orm_server is None: self.log.warning(f"No ORM server for {self._log_name}") self.orm_spawner.server = server.orm_server + elif server is not None: + self.log.warning( + "Setting Spawner.server for {self._log_name} with no underlying orm_spawner" + ) @property def name(self): diff --git a/jupyterhub/tests/test_spawner.py b/jupyterhub/tests/test_spawner.py index 455a5c03..4c01944e 100644 --- a/jupyterhub/tests/test_spawner.py +++ b/jupyterhub/tests/test_spawner.py @@ -529,3 +529,10 @@ def test_spawner_server(db): spawner.server = None db.commit() assert spawner.orm_spawner.server is None + + # test with no underlying orm.Spawner + # (only relevant for mocking, never true for actual Spawners) + spawner = Spawner() + spawner.server = Server.from_url("http://1.2.3.4") + assert spawner.server is not None + assert spawner.server.ip == "1.2.3.4" From e0a17db5f18863b183ac5edfe9a6ef52077bf54f Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Thu, 10 Mar 2022 12:45:09 -0800 Subject: [PATCH 17/51] Add some docs on common log messages When debugging errors and outages, looking at the logs emitted by JupyterHub is very helpful. This document tries to document some common log messages, and what they mean. I currently added just one log message, but we can add more over time. Ref https://github.com/2i2c-org/infrastructure/issues/1081 where this would've been useful troubleshooting --- docs/source/admin/log-messages.md | 32 +++++++++++++++++++++++++++++++ docs/source/index-admin.rst | 1 + 2 files changed, 33 insertions(+) create mode 100644 docs/source/admin/log-messages.md diff --git a/docs/source/admin/log-messages.md b/docs/source/admin/log-messages.md new file mode 100644 index 00000000..d502da14 --- /dev/null +++ b/docs/source/admin/log-messages.md @@ -0,0 +1,32 @@ +# Common log messages emitted by JupyterHub + +When debugging errors and outages, looking at the logs emitted by +JupyterHub is very helpful. This document tries to document some common +log messages, and what they mean. + +## Failing suspected API request to not-running server + +Your logs might be littered with lines that might look slightly scary + +``` +[W 2022-03-10 17:25:19.774 JupyterHub base:1349] Failing suspected API request to not-running server: /hub/user//api/metrics/v1 +``` + +However, what this most likely means is that the user's server has +stopped running but they still have a browser tab open. This is +extremely common - for example, you might have 3 tabs open, and shut +your server down via one. Or you closed your laptop, your server was +culled for inactivity, and then you reopen your laptop again! The +client side code (JupyterLab, Classic Notebook, etc) does not know +yet that the server is dead, and continues to make some API requests. +JupyterHub's architecture means that the proxy routes all requests that +don't go to a running user server to the hub process itself. The hub +process then explicitly returns a failure response, so the client knows +that the server is not running anymore. This is used by JupyterLab to +tell you your server is not running anymore, and offer you the option +to let you restart it. + +Most commonly, you'll see this in reference to the `/api/metrics/v1` +URL, used by [jupyter-resource-usage](https://github.com/jupyter-server/jupyter-resource-usage). + +So for the most part, this log line is pretty benign! diff --git a/docs/source/index-admin.rst b/docs/source/index-admin.rst index f780d0ab..fe39b5a8 100644 --- a/docs/source/index-admin.rst +++ b/docs/source/index-admin.rst @@ -10,4 +10,5 @@ well as other information relevant to running your own JupyterHub over time. troubleshooting admin/upgrading + admin/log-messages changelog From 6eb526d08a6ef0d59d7d4be162260cec81b4aec7 Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Thu, 10 Mar 2022 13:45:28 -0800 Subject: [PATCH 18/51] Add a little more structure --- docs/source/admin/log-messages.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/source/admin/log-messages.md b/docs/source/admin/log-messages.md index d502da14..24074df7 100644 --- a/docs/source/admin/log-messages.md +++ b/docs/source/admin/log-messages.md @@ -6,12 +6,16 @@ log messages, and what they mean. ## Failing suspected API request to not-running server +### Example + Your logs might be littered with lines that might look slightly scary ``` [W 2022-03-10 17:25:19.774 JupyterHub base:1349] Failing suspected API request to not-running server: /hub/user//api/metrics/v1 ``` +### Most likely cause + However, what this most likely means is that the user's server has stopped running but they still have a browser tab open. This is extremely common - for example, you might have 3 tabs open, and shut @@ -29,4 +33,6 @@ to let you restart it. Most commonly, you'll see this in reference to the `/api/metrics/v1` URL, used by [jupyter-resource-usage](https://github.com/jupyter-server/jupyter-resource-usage). -So for the most part, this log line is pretty benign! +### Actions you can take + +This log message is benign, and there is usually no action for you to take. From ab79251fe29aaeab2620ce6b58d04c7aa1fae055 Mon Sep 17 00:00:00 2001 From: Yuvi Panda Date: Thu, 10 Mar 2022 15:54:42 -0800 Subject: [PATCH 19/51] Reword for clarity Co-authored-by: Chris Holdgraf --- docs/source/admin/log-messages.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/source/admin/log-messages.md b/docs/source/admin/log-messages.md index 24074df7..f8e37422 100644 --- a/docs/source/admin/log-messages.md +++ b/docs/source/admin/log-messages.md @@ -16,9 +16,8 @@ Your logs might be littered with lines that might look slightly scary ### Most likely cause -However, what this most likely means is that the user's server has -stopped running but they still have a browser tab open. This is -extremely common - for example, you might have 3 tabs open, and shut +This likely means is that the user's server has stopped running but they +still have a browser tab open. For example, you might have 3 tabs open, and shut your server down via one. Or you closed your laptop, your server was culled for inactivity, and then you reopen your laptop again! The client side code (JupyterLab, Classic Notebook, etc) does not know From 8afc2c9ae990bb239d0bf4f3a8bcbb24f75d12f7 Mon Sep 17 00:00:00 2001 From: Narek Amirbekian Date: Thu, 10 Mar 2022 22:20:01 -0800 Subject: [PATCH 20/51] Fix admin table sorting --- .../ServerDashboard/ServerDashboard.jsx | 149 ++++++++---------- share/jupyterhub/static/js/admin-react.js | 2 +- 2 files changed, 68 insertions(+), 83 deletions(-) diff --git a/jsx/src/components/ServerDashboard/ServerDashboard.jsx b/jsx/src/components/ServerDashboard/ServerDashboard.jsx index 9b08f0fa..18c6c358 100644 --- a/jsx/src/components/ServerDashboard/ServerDashboard.jsx +++ b/jsx/src/components/ServerDashboard/ServerDashboard.jsx @@ -153,10 +153,9 @@ const ServerDashboard = (props) => { ); }; - const EditUserCell = ({ user, numServers, serverName }) => { - if (serverName) return null; + const EditUserCell = ({ user }) => { return ( - + - {user_data.flatMap((e, i) => { - let userServers = Object.values({ - "": e.server, - ...(e.servers || {}), - }); - return userServers.map((server) => { - server = { name: "", ...server }; - return ( - - {!server.name && ( - - {e.name} - - )} - {!server.name && ( - - {e.admin ? "admin" : ""} - - )} + {servers.map(([user, server], i) => { + server.name = server.name || "" + return ( + + {user.name} + + {user.admin ? "admin" : ""} + - - {server.name ? ( -

{server.name}

- ) : ( -

[MAIN]

- )} - - - {server.last_activity - ? timeSince(server.last_activity) - : "Never"} - - - {server.started ? ( - // Stop Single-user server - <> - - - - ) : ( - // Start Single-user server - <> - - + {server.name ? ( +

{server.name}

+ ) : ( +

[MAIN]

+ )} + + + {server.last_activity + ? timeSince(server.last_activity) + : "Never"} + + + {server.started ? ( + // Stop Single-user server + <> + + + + ) : ( + // Start Single-user server + <> + +
+ - - - )} - - - - ); - }); + Spawn Page + + + + )} + + + + ); })} diff --git a/share/jupyterhub/static/js/admin-react.js b/share/jupyterhub/static/js/admin-react.js index 68c3f22c..2f9b1ad6 100644 --- a/share/jupyterhub/static/js/admin-react.js +++ b/share/jupyterhub/static/js/admin-react.js @@ -1,2 +1,2 @@ /*! For license information please see admin-react.js.LICENSE.txt */ -(()=>{var e,t,n={733:(e,t,n)=>{"use strict";var r=n(294),a=n(935),o=n(697),l=n.n(o),i=r.createContext(null),u=function(e){e()},c=function(){return u},s={notify:function(){}},f=function(){function e(e,t){this.store=e,this.parentSub=t,this.unsubscribe=null,this.listeners=s,this.handleChangeWrapper=this.handleChangeWrapper.bind(this)}var t=e.prototype;return t.addNestedSub=function(e){return this.trySubscribe(),this.listeners.subscribe(e)},t.notifyNestedSubs=function(){this.listeners.notify()},t.handleChangeWrapper=function(){this.onStateChange&&this.onStateChange()},t.isSubscribed=function(){return Boolean(this.unsubscribe)},t.trySubscribe=function(){this.unsubscribe||(this.unsubscribe=this.parentSub?this.parentSub.addNestedSub(this.handleChangeWrapper):this.store.subscribe(this.handleChangeWrapper),this.listeners=function(){var e=c(),t=null,n=null;return{clear:function(){t=null,n=null},notify:function(){e((function(){for(var e=t;e;)e.callback(),e=e.next}))},get:function(){for(var e=[],n=t;n;)e.push(n),n=n.next;return e},subscribe:function(e){var r=!0,a=n={callback:e,next:null,prev:n};return a.prev?a.prev.next=a:t=a,function(){r&&null!==t&&(r=!1,a.next?a.next.prev=a.prev:n=a.prev,a.prev?a.prev.next=a.next:t=a.next)}}}}())},t.tryUnsubscribe=function(){this.unsubscribe&&(this.unsubscribe(),this.unsubscribe=null,this.listeners.clear(),this.listeners=s)},e}();const d=function(e){var t=e.store,n=e.context,a=e.children,o=(0,r.useMemo)((function(){var e=new f(t);return e.onStateChange=e.notifyNestedSubs,{store:t,subscription:e}}),[t]),l=(0,r.useMemo)((function(){return t.getState()}),[t]);(0,r.useEffect)((function(){var e=o.subscription;return e.trySubscribe(),l!==t.getState()&&e.notifyNestedSubs(),function(){e.tryUnsubscribe(),e.onStateChange=null}}),[o,l]);var u=n||i;return r.createElement(u.Provider,{value:o},a)};n(679),n(864);var p="undefined"!=typeof window&&void 0!==window.document&&void 0!==window.document.createElement?r.useLayoutEffect:r.useEffect,m=n(121),h=function(){return Math.random().toString(36).substring(7).split("").join(".")},v={INIT:"@@redux/INIT"+h(),REPLACE:"@@redux/REPLACE"+h(),PROBE_UNKNOWN_ACTION:function(){return"@@redux/PROBE_UNKNOWN_ACTION"+h()}};function y(e){if("object"!=typeof e||null===e)return!1;for(var t=e;null!==Object.getPrototypeOf(t);)t=Object.getPrototypeOf(t);return Object.getPrototypeOf(e)===t}function g(){return(g=Object.assign||function(e){for(var t=1;t1&&void 0!==arguments[1]?arguments[1]:"";return R("/users/"+e+"/servers/"+(t||""),"POST")},stopServer:function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"";return R("/users/"+e+"/servers/"+(t||""),"DELETE")},startAll:function(e){return e.map((function(e){return R("/users/"+e+"/server","POST")}))},stopAll:function(e){return e.map((function(e){return R("/users/"+e+"/server","DELETE")}))},addToGroup:function(e,t){return R("/groups/"+t+"/users","POST",{users:e})},removeFromGroup:function(e,t){return R("/groups/"+t+"/users","DELETE",{users:e})},createGroup:function(e){return R("/groups/"+e,"POST")},deleteGroup:function(e){return R("/groups/"+e,"DELETE")},addUsers:function(e,t){return R("/users","POST",{usernames:e,admin:t})},editUser:function(e,t,n){return R("/users/"+e,"PATCH",{name:t,admin:n})},deleteUser:function(e){return R("/users/"+e,"DELETE")},findUser:function(e){return R("/users/"+e,"GET")},validateUser:function(e){return R("/users/"+e,"GET").then((function(e){return e.status})).then((function(e){return!(e>200)}))},failRegexEvent:function(){return null},noChangeEvent:function(){return null},refreshGroupsData:function(){return R("/groups","GET").then((function(e){return e.json()}))},refreshUserData:function(){return R("/users","GET").then((function(e){return e.json()}))}}},M=function(e){return g({},e,O())},function(e){var t=(0,r.createFactory)(e);return function(e){return t(M(e))}});var M;function z(e){return"/"===e.charAt(0)}function I(e,t){for(var n=t,r=n+1,a=e.length;r=0;s--){var f=a[s];"."===f?I(a,s):".."===f?(I(a,s),c++):c&&(I(a,s),c--)}if(!i)for(;c--;c)a.unshift("..");!i||""===a[0]||a[0]&&z(a[0])||a.unshift("");var d=a.join("/");return n&&"/"!==d.substr(-1)&&(d+="/"),d};"undefined"==typeof window||!window.document||window.document.createElement;var F=1073741823,U="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof window?window:void 0!==n.g?n.g:{};function $(e){var t=[];return{on:function(e){t.push(e)},off:function(e){t=t.filter((function(t){return t!==e}))},get:function(){return e},set:function(n,r){e=n,t.forEach((function(t){return t(e,r)}))}}}const B=r.createContext||function(e,t){var n,a,o,i="__create-react-context-"+((U[o="__global_unique_id__"]=(U[o]||0)+1)+"__"),u=function(e){function n(){var t;return(t=e.apply(this,arguments)||this).emitter=$(t.props.value),t}P(n,e);var r=n.prototype;return r.getChildContext=function(){var e;return(e={})[i]=this.emitter,e},r.componentWillReceiveProps=function(e){if(this.props.value!==e.value){var n,r=this.props.value,a=e.value;((o=r)===(l=a)?0!==o||1/o==1/l:o!=o&&l!=l)?n=0:(n="function"==typeof t?t(r,a):F,0!=(n|=0)&&this.emitter.set(e.value,n))}var o,l},r.render=function(){return this.props.children},n}(r.Component);u.childContextTypes=((n={})[i]=l().object.isRequired,n);var c=function(t){function n(){var e;return(e=t.apply(this,arguments)||this).state={value:e.getValue()},e.onUpdate=function(t,n){0!=((0|e.observedBits)&n)&&e.setState({value:e.getValue()})},e}P(n,t);var r=n.prototype;return r.componentWillReceiveProps=function(e){var t=e.observedBits;this.observedBits=null==t?F:t},r.componentDidMount=function(){this.context[i]&&this.context[i].on(this.onUpdate);var e=this.props.observedBits;this.observedBits=null==e?F:e},r.componentWillUnmount=function(){this.context[i]&&this.context[i].off(this.onUpdate)},r.getValue=function(){return this.context[i]?this.context[i].get():e},r.render=function(){return(e=this.props.children,Array.isArray(e)?e[0]:e)(this.state.value);var e},n}(r.Component);return c.contextTypes=((a={})[i]=l().object,a),{Provider:u,Consumer:c}};const H=function(e,t){if(!e)throw new Error("Invariant failed")};var V=n(779),W=n.n(V);function G(e,t){if(null==e)return{};var n,r,a={},o=Object.keys(e);for(r=0;r=0||(a[n]=e[n]);return a}var Q=function(e){var t=B();return t.displayName="Router-History",t}(),q=function(e){var t=B();return t.displayName="Router",t}(),K=function(e){function t(t){var n;return(n=e.call(this,t)||this).state={location:t.history.location},n._isMounted=!1,n._pendingLocation=null,t.staticContext||(n.unlisten=t.history.listen((function(e){n._isMounted?n.setState({location:e}):n._pendingLocation=e}))),n}P(t,e),t.computeRootMatch=function(e){return{path:"/",url:"/",params:{},isExact:"/"===e}};var n=t.prototype;return n.componentDidMount=function(){this._isMounted=!0,this._pendingLocation&&this.setState({location:this._pendingLocation})},n.componentWillUnmount=function(){this.unlisten&&this.unlisten()},n.render=function(){return r.createElement(q.Provider,{value:{history:this.props.history,location:this.state.location,match:t.computeRootMatch(this.state.location.pathname),staticContext:this.props.staticContext}},r.createElement(Q.Provider,{children:this.props.children||null,value:this.props.history}))},t}(r.Component);r.Component,r.Component;var Y={},X=0;function J(e,t){void 0===t&&(t={}),("string"==typeof t||Array.isArray(t))&&(t={path:t});var n=t,r=n.path,a=n.exact,o=void 0!==a&&a,l=n.strict,i=void 0!==l&&l,u=n.sensitive,c=void 0!==u&&u;return[].concat(r).reduce((function(t,n){if(!n&&""!==n)return null;if(t)return t;var r=function(e,t){var n=""+t.end+t.strict+t.sensitive,r=Y[n]||(Y[n]={});if(r[e])return r[e];var a=[],o={regexp:W()(e,a,t),keys:a};return X<1e4&&(r[e]=o,X++),o}(n,{end:o,strict:i,sensitive:c}),a=r.regexp,l=r.keys,u=a.exec(e);if(!u)return null;var s=u[0],f=u.slice(1),d=e===s;return o&&!d?null:{path:n,url:"/"===n&&""===s?"/":s,isExact:d,params:l.reduce((function(e,t,n){return e[t.name]=f[n],e}),{})}}),null)}var Z=function(e){function t(){return e.apply(this,arguments)||this}return P(t,e),t.prototype.render=function(){var e=this;return r.createElement(q.Consumer,null,(function(t){t||H(!1);var n=e.props.location||t.location,a=g({},t,{location:n,match:e.props.computedMatch?e.props.computedMatch:e.props.path?J(n.pathname,e.props):t.match}),o=e.props,l=o.children,i=o.component,u=o.render;return Array.isArray(l)&&0===l.length&&(l=null),r.createElement(q.Provider,{value:a},a.match?l?"function"==typeof l?l(a):l:i?r.createElement(i,a):u?u(a):null:"function"==typeof l?l(a):null)}))},t}(r.Component);r.Component;var ee=function(e){function t(){return e.apply(this,arguments)||this}return P(t,e),t.prototype.render=function(){var e=this;return r.createElement(q.Consumer,null,(function(t){t||H(!1);var n,a,o=e.props.location||t.location;return r.Children.forEach(e.props.children,(function(e){if(null==a&&r.isValidElement(e)){n=e;var l=e.props.path||e.props.from;a=l?J(o.pathname,g({},e.props,{path:l})):t.match}})),a?r.cloneElement(n,{location:o,computedMatch:a}):null}))},t}(r.Component);function te(e){return"/"===e.charAt(0)?e:"/"+e}function ne(e){return"/"===e.charAt(0)?e.substr(1):e}function re(e,t){return function(e,t){return 0===e.toLowerCase().indexOf(t.toLowerCase())&&-1!=="/?#".indexOf(e.charAt(t.length))}(e,t)?e.substr(t.length):e}function ae(e){return"/"===e.charAt(e.length-1)?e.slice(0,-1):e}function oe(e){var t=e.pathname,n=e.search,r=e.hash,a=t||"/";return n&&"?"!==n&&(a+="?"===n.charAt(0)?n:"?"+n),r&&"#"!==r&&(a+="#"===r.charAt(0)?r:"#"+r),a}function le(e,t,n,r){var a;"string"==typeof e?(a=function(e){var t=e||"/",n="",r="",a=t.indexOf("#");-1!==a&&(r=t.substr(a),t=t.substr(0,a));var o=t.indexOf("?");return-1!==o&&(n=t.substr(o),t=t.substr(0,o)),{pathname:t,search:"?"===n?"":n,hash:"#"===r?"":r}}(e)).state=t:(void 0===(a=g({},e)).pathname&&(a.pathname=""),a.search?"?"!==a.search.charAt(0)&&(a.search="?"+a.search):a.search="",a.hash?"#"!==a.hash.charAt(0)&&(a.hash="#"+a.hash):a.hash="",void 0!==t&&void 0===a.state&&(a.state=t));try{a.pathname=decodeURI(a.pathname)}catch(e){throw e instanceof URIError?new URIError('Pathname "'+a.pathname+'" could not be decoded. This is likely caused by an invalid percent-encoding.'):e}return n&&(a.key=n),r?a.pathname?"/"!==a.pathname.charAt(0)&&(a.pathname=D(a.pathname,r.pathname)):a.pathname=r.pathname:a.pathname||(a.pathname="/"),a}function ie(){var e=null,t=[];return{setPrompt:function(t){return e=t,function(){e===t&&(e=null)}},confirmTransitionTo:function(t,n,r,a){if(null!=e){var o="function"==typeof e?e(t,n):e;"string"==typeof o?"function"==typeof r?r(o,a):a(!0):a(!1!==o)}else a(!0)},appendListener:function(e){var n=!0;function r(){n&&e.apply(void 0,arguments)}return t.push(r),function(){n=!1,t=t.filter((function(e){return e!==r}))}},notifyListeners:function(){for(var e=arguments.length,n=new Array(e),r=0;r=1?r.createElement("button",{className:"btn btn-sm btn-light spaced"},r.createElement(ke,{to:"".concat(t,"?page=").concat(n-1)},r.createElement("span",{className:"active-pagination"},"Previous"))):r.createElement("button",{className:"btn btn-sm btn-light spaced"},r.createElement("span",{className:"inactive-pagination"},"Previous")),l>=a?r.createElement("button",{className:"btn btn-sm btn-light spaced"},r.createElement(ke,{to:"".concat(t,"?page=").concat(n+1)},r.createElement("span",{className:"active-pagination"},"Next"))):r.createElement("button",{className:"btn btn-sm btn-light spaced"},r.createElement("span",{className:"inactive-pagination"},"Next"))))};He.propTypes={endpoint:l().string,page:l().number,limit:l().number,numOffset:l().number,numElements:l().number};const Ve=He;function We(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function Ge(e){for(var t=1;te.length)&&(t=e.length);for(var n=0,r=new Array(t);nt.name?1:-1}))},n=function(e){return e.sort((function(e,t){return e.name Manage Groups")),r.createElement("div",{className:"server-dashboard-container"},r.createElement("table",{className:"table table-striped table-bordered table-hover"},r.createElement("thead",{className:"admin-table-head"},r.createElement("tr",null,r.createElement("th",{id:"user-header"},"User"," ",r.createElement(Je,{sorts:{asc:n,desc:t},callback:function(e){return c((function(){return e}))},testid:"user-sort"})),r.createElement("th",{id:"admin-header"},"Admin"," ",r.createElement(Je,{sorts:{asc:function(e){return e.sort((function(e){return e.admin?1:-1}))},desc:function(e){return e.sort((function(e){return e.admin?-1:1}))}},callback:function(e){return c((function(){return e}))},testid:"admin-sort"})),r.createElement("th",{id:"server-header"},"Server"," ",r.createElement(Je,{sorts:{asc:n,desc:t},callback:function(e){return c((function(){return e}))},testid:"server-sort"})),r.createElement("th",{id:"last-activity-header"},"Last Activity"," ",r.createElement(Je,{sorts:{asc:function(e){return e.sort((function(e,t){return new Date(e.last_activity)-new Date(t.last_activity)>0?1:-1}))},desc:function(e){return e.sort((function(e,t){return new Date(e.last_activity)-new Date(t.last_activity)>0?-1:1}))}},callback:function(e){return c((function(){return e}))},testid:"last-activity-sort"})),r.createElement("th",{id:"running-status-header"},"Running"," ",r.createElement(Je,{sorts:{asc:function(e){return e.sort((function(e){return null==e.server?-1:1}))},desc:function(e){return e.sort((function(e){return null==e.server?1:-1}))}},callback:function(e){return c((function(){return e}))},testid:"running-status-sort"})),r.createElement("th",{id:"actions-header"},"Actions"))),r.createElement("tbody",null,r.createElement("tr",{className:"noborder"},r.createElement("td",null,r.createElement(Re,{variant:"light",className:"add-users-button"},r.createElement(ke,{to:"/add-users"},"Add Users"))),r.createElement("td",null),r.createElement("td",null),r.createElement("td",null,r.createElement(Re,{variant:"primary",className:"start-all","data-testid":"start-all",onClick:function(){Promise.all(E(f.map((function(e){return e.name})))).then((function(e){var t=e.filter((function(e){return!e.ok}));return t.length>0&&l("Failed to start ".concat(t.length," ").concat(t.length>1?"servers":"server",". ").concat(t.length>1?"Are they ":"Is it "," already running?")),e})).then((function(e){return y.apply(void 0,h).then((function(e){C(e,m)})).catch((function(){return l("Failed to update users list.")})),e})).catch((function(){return l("Failed to start servers.")}))}},"Start All"),r.createElement("span",null," "),r.createElement(Re,{variant:"danger",className:"stop-all","data-testid":"stop-all",onClick:function(){Promise.all(k(f.map((function(e){return e.name})))).then((function(e){var t=e.filter((function(e){return!e.ok}));return t.length>0&&l("Failed to stop ".concat(t.length," ").concat(t.length>1?"servers":"server",". ").concat(t.length>1?"Are they ":"Is it "," already stopped?")),e})).then((function(e){return y.apply(void 0,h).then((function(e){C(e,m)})).catch((function(){return l("Failed to update users list.")})),e})).catch((function(){return l("Failed to stop servers.")}))}},"Stop All")),r.createElement("td",null,r.createElement(Re,{variant:"danger",id:"shutdown-button",onClick:g},"Shutdown Hub"))),f.flatMap((function(e,t){var n=Object.values(Ge({"":e.server},e.servers||{}));return n.map((function(a){return a=Ge({name:""},a),r.createElement("tr",{key:t+"row",className:"user-row"},!a.name&&r.createElement("td",{"data-testid":"user-row-name",rowspan:n.length},e.name),!a.name&&r.createElement("td",{"data-testid":"user-row-admin",rowspan:n.length},e.admin?"admin":""),r.createElement("td",{"data-testid":"user-row-server"},a.name?r.createElement("p",{class:"text-secondary"},a.name):r.createElement("p",{style:{color:"lightgrey"}},"[MAIN]")),r.createElement("td",{"data-testid":"user-row-last-activity"},a.last_activity?(o=a.last_activity,6e4,l=36e5,i=864e5,u=2592e6,c=31536e6,(s=Date.now()-Date.parse(o))<6e4?Math.round(s/1e3)+" seconds ago":s0?n.map((function(e,n){return r.createElement("li",{className:"list-group-item",key:"group-item"+n},r.createElement("span",{className:"badge badge-pill badge-success"},e.users.length+" users"),r.createElement(ke,{to:{pathname:"/group-edit",state:{group_data:e,user_data:t}}},e.name))})):r.createElement("div",null,r.createElement("h4",null,"no groups created..."))),r.createElement(Ve,{endpoint:"/groups",page:i,limit:o,numOffset:u[0],numElements:n.length})),r.createElement("div",{className:"panel-footer"},r.createElement("button",{className:"btn btn-light adjacent-span-spacing"},r.createElement(ke,{to:"/"},"Back")),r.createElement("button",{className:"btn btn-primary adjacent-span-spacing",onClick:function(){s.push("/create-group")}},"New Group"))))))):r.createElement("div",{"data-testid":"no-show"})};et.propTypes={user_data:l().array,groups_data:l().array,updateUsers:l().func,updateGroups:l().func,history:l().shape({push:l().func}),location:l().shape({search:l().string})};const tt=et;function nt(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){if("undefined"!=typeof Symbol&&Symbol.iterator in Object(e)){var n=[],r=!0,a=!1,o=void 0;try{for(var l,i=e[Symbol.iterator]();!(r=(l=i.next()).done)&&(n.push(l.value),!t||n.length!==t);r=!0);}catch(e){a=!0,o=e}finally{try{r||null==i.return||i.return()}finally{if(a)throw o}}return n}}(e,t)||function(e,t){if(e){if("string"==typeof e)return rt(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?rt(e,t):void 0}}(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function rt(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);ne.length)&&(t=e.length);for(var n=0,r=new Array(t);n0&&r.push(m(e,E.name)),t.length>0&&r.push(h(t,E.name)),Promise.all(r).then((function(e){0==e.map((function(e){return e.status})).filter((function(e){return e>=300})).length?y(0,f).then((function(e){return p(e,0)})).then((function(){return b.push("/groups")})):s("Failed to edit group.")})).catch((function(){console.log("outer"),s("Failed to edit group.")}))}else b.push("/groups")}},"Apply"),r.createElement("button",{id:"delete-group","data-testid":"delete-group",className:"btn btn-danger",style:{float:"right"},onClick:function(){var e=E.name;v(e).then((function(e){e.status<300?y(0,f).then((function(e){return p(e,0)})).then((function(){return b.push("/groups")})):s("Failed to delete group.")})).catch((function(){return s("Failed to delete group.")}))}},"Delete Group"),r.createElement("br",null),r.createElement("br",null)))):r.createElement("div",null)};ut.propTypes={location:l().shape({state:l().shape({group_data:l().object,callback:l().func})}),history:l().shape({push:l().func}),addToGroup:l().func,removeFromGroup:l().func,deleteGroup:l().func,updateGroups:l().func,validateUser:l().func};const ct=ut;function st(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){if("undefined"!=typeof Symbol&&Symbol.iterator in Object(e)){var n=[],r=!0,a=!1,o=void 0;try{for(var l,i=e[Symbol.iterator]();!(r=(l=i.next()).done)&&(n.push(l.value),!t||n.length!==t);r=!0);}catch(e){a=!0,o=e}finally{try{r||null==i.return||i.return()}finally{if(a)throw o}}return n}}(e,t)||function(e,t){if(e){if("string"==typeof e)return ft(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?ft(e,t):void 0}}(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function ft(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);ne.length)&&(t=e.length);for(var n=0,r=new Array(t);n2&&0==/[!@#$%^&*(),.?":{}|<>]/g.test(e)}));e.lengthe.length)&&(t=e.length);for(var n=0,r=new Array(t);n2&&0==/[!@#$%^&*(),.?":{}|<>]/g.test(y)?u(m,""!=y?y:m,w).then((function(e){e.status<300?f(0,t).then((function(e){return i(e,0)})).then((function(){return d.push("/")})).catch((function(){return o("Could not update users list.")})):o("Failed to edit user.")})).catch((function(){o("Failed to edit user.")})):o("Failed to edit user. Make sure the username does not contain special characters."):u(m,m,w).then((function(e){e.status<300?f(0,t).then((function(e){return i(e,0)})).then((function(){return d.push("/")})).catch((function(){return o("Could not update users list.")})):o("Failed to edit user.")})).catch((function(){o("Failed to edit user.")})):s()}},"Apply")))))))};wt.propTypes={location:l().shape({state:l().shape({username:l().string,has_admin:l().bool})}),history:l().shape({push:l().func}),editUser:l().func,deleteUser:l().func,failRegexEvent:l().func,noChangeEvent:l().func,updateUsers:l().func};const Et=wt;n(137);var kt=function e(t,n,r){var a;if("function"==typeof n&&"function"==typeof r||"function"==typeof r&&"function"==typeof arguments[3])throw new Error("It looks like you are passing several store enhancers to createStore(). This is not supported. Instead, compose them together to a single function.");if("function"==typeof n&&void 0===r&&(r=n,n=void 0),void 0!==r){if("function"!=typeof r)throw new Error("Expected the enhancer to be a function.");return r(e)(t,n)}if("function"!=typeof t)throw new Error("Expected the reducer to be a function.");var o=t,l=n,i=[],u=i,c=!1;function s(){u===i&&(u=i.slice())}function f(){if(c)throw new Error("You may not call store.getState() while the reducer is executing. The reducer has already received the state as an argument. Pass it down from the top reducer instead of reading it from the store.");return l}function d(e){if("function"!=typeof e)throw new Error("Expected the listener to be a function.");if(c)throw new Error("You may not call store.subscribe() while the reducer is executing. If you would like to be notified after the store has been updated, subscribe from a component and invoke store.getState() in the callback to access the latest state. See https://redux.js.org/api-reference/store#subscribelistener for more details.");var t=!0;return s(),u.push(e),function(){if(t){if(c)throw new Error("You may not unsubscribe from a store listener while the reducer is executing. See https://redux.js.org/api-reference/store#subscribelistener for more details.");t=!1,s();var n=u.indexOf(e);u.splice(n,1),i=null}}}function p(e){if(!y(e))throw new Error("Actions must be plain objects. Use custom middleware for async actions.");if(void 0===e.type)throw new Error('Actions may not have an undefined "type" property. Have you misspelled a constant?');if(c)throw new Error("Reducers may not dispatch actions.");try{c=!0,l=o(l,e)}finally{c=!1}for(var t=i=u,n=0;n0&&void 0!==arguments[0]?arguments[0]:A,t=arguments.length>1?arguments[1]:void 0;switch(t.type){case"USER_PAGE":return Object.assign({},e,{user_page:t.value.page,user_data:t.value.data});case"GROUPS_PAGE":return Object.assign({},e,{groups_page:t.value.page,groups_data:t.value.data});default:return e}}),A),St=function(){return(0,r.useEffect)((function(){var e=A.limit,t=A.groups_page;R("/users?offset=".concat(A.user_page*e,"&limit=").concat(e),"GET").then((function(e){return e.json()})).then((function(e){return kt.dispatch({type:"USER_PAGE",value:{data:e,page:0}})})).catch((function(e){return console.log(e)})),R("/groups?offset=".concat(t*e,"&limit=").concat(e),"GET").then((function(e){return e.json()})).then((function(e){return kt.dispatch({type:"GROUPS_PAGE",value:{data:e,page:0}})})).catch((function(e){return console.log(e)}))})),r.createElement("div",{className:"resets"},r.createElement(d,{store:kt},r.createElement(ve,null,r.createElement(ee,null,r.createElement(Z,{exact:!0,path:"/",component:T(j)(Ze)}),r.createElement(Z,{exact:!0,path:"/groups",component:T(j)(tt)}),r.createElement(Z,{exact:!0,path:"/group-edit",component:T(j)(ct)}),r.createElement(Z,{exact:!0,path:"/create-group",component:T(j)(pt)}),r.createElement(Z,{exact:!0,path:"/add-users",component:T(j)(yt)}),r.createElement(Z,{exact:!0,path:"/edit-user",component:T(j)(Et)})))))};a.render(r.createElement(St,null),document.getElementById("react-admin-hook"))},790:(e,t)=>{"use strict";t.E=function(){var e=[],t=e;function n(){t===e&&(t=e.slice())}return{listen:function(e){if("function"!=typeof e)throw new Error("Expected listener to be a function.");var r=!0;return n(),t.push(e),function(){if(r){r=!1,n();var a=t.indexOf(e);t.splice(a,1)}}},emit:function(){for(var n=e=t,r=0;r{var n;!function(){"use strict";var r={}.hasOwnProperty;function a(){for(var e=[],t=0;t{"use strict";n.r(t),n.d(t,{default:()=>i});var r=n(645),a=n.n(r),o=n(223),l=a()((function(e){return e[1]}));l.i(o.default),l.push([e.id,".users-container {\n width: 100%;\n position: relative;\n padding: 5px;\n overflow-x: scroll;\n}\n\n.users-container div {\n display: inline-block;\n}\n\n.users-container .item {\n padding: 3px;\n padding-left: 6px;\n padding-right: 6px;\n border-radius: 3px;\n font-size: 14px;\n margin-left: 4px;\n margin-right: 4px;\n transition: 30ms ease-in all;\n cursor: pointer;\n user-select: none;\n border: solid 1px #dfdfdf;\n}\n\n.users-container .item.unselected {\n background-color: #f7f7f7;\n color: #777;\n}\n\n.users-container .item.selected {\n background-color: orange;\n color: white;\n}\n\n.users-container .item:hover {\n opacity: 0.7;\n}\n",""]);const i=l},457:(e,t,n)=>{"use strict";n.r(t),n.d(t,{default:()=>i});var r=n(645),a=n.n(r),o=n(223),l=a()((function(e){return e[1]}));l.i(o.default),l.push([e.id,".pagination-footer * button {\n margin-right: 10px;\n}\n\n.pagination-footer * .inactive-pagination {\n color: gray;\n cursor: not-allowed;\n}\n\n.pagination-footer * button.spaced {\n color: var(--blue);\n}\n",""]);const i=l},642:(e,t,n)=>{"use strict";n.r(t),n.d(t,{default:()=>i});var r=n(645),a=n.n(r),o=n(223),l=a()((function(e){return e[1]}));l.i(o.default),l.push([e.id,".server-dashboard-container {\n padding-right: 15px;\n padding-left: 15px;\n margin-right: auto;\n margin-left: auto;\n}\n\n.server-dashboard-container .add-users-button {\n border: 1px solid #ddd;\n}\n\n.server-dashboard-container tbody {\n color: #626262;\n}\n\n.admin-table-head {\n user-select: none;\n}\n\n.sort-icon {\n display: inline-block;\n top: 0.125em;\n position: relative;\n user-select: none;\n cursor: pointer;\n}\n\ntr.noborder > td {\n border: none !important;\n}\n",""]);const i=l},223:(e,t,n)=>{"use strict";n.r(t),n.d(t,{default:()=>o});var r=n(645),a=n.n(r)()((function(e){return e[1]}));a.push([e.id,":root {\n --red: #d7191e;\n --orange: #f1ad4e;\n --blue: #2e7ab6;\n --white: #ffffff;\n --gray: #f7f7f7;\n}\n\n/* Color Classes */\n.red {\n background-color: var(--red);\n}\n.orange {\n background-color: var(--orange);\n}\n.blue {\n background-color: var(--blue);\n}\n.white {\n background-color: var(--white);\n}\n\n/* Resets */\n\n.resets .modal {\n display: block;\n visibility: visible;\n z-index: 2000;\n}\n\n/* Global Util Classes */\n.adjacent-span-spacing {\n margin-right: 5px;\n margin-left: 5px;\n}\n",""]);const o=a},645:e=>{"use strict";e.exports=function(e){var t=[];return t.toString=function(){return this.map((function(t){var n=e(t);return t[2]?"@media ".concat(t[2]," {").concat(n,"}"):n})).join("")},t.i=function(e,n,r){"string"==typeof e&&(e=[[null,e,""]]);var a={};if(r)for(var o=0;o{"use strict";var t=Object.prototype.hasOwnProperty;function n(e,t){return e===t?0!==e||0!==t||1/e==1/t:e!=e&&t!=t}e.exports=function(e,r){if(n(e,r))return!0;if("object"!=typeof e||null===e||"object"!=typeof r||null===r)return!1;var a=Object.keys(e),o=Object.keys(r);if(a.length!==o.length)return!1;for(var l=0;l{"use strict";var r=n(864),a={childContextTypes:!0,contextType:!0,contextTypes:!0,defaultProps:!0,displayName:!0,getDefaultProps:!0,getDerivedStateFromError:!0,getDerivedStateFromProps:!0,mixins:!0,propTypes:!0,type:!0},o={name:!0,length:!0,prototype:!0,caller:!0,callee:!0,arguments:!0,arity:!0},l={$$typeof:!0,compare:!0,defaultProps:!0,displayName:!0,propTypes:!0,type:!0},i={};function u(e){return r.isMemo(e)?l:i[e.$$typeof]||a}i[r.ForwardRef]={$$typeof:!0,render:!0,defaultProps:!0,displayName:!0,propTypes:!0},i[r.Memo]=l;var c=Object.defineProperty,s=Object.getOwnPropertyNames,f=Object.getOwnPropertySymbols,d=Object.getOwnPropertyDescriptor,p=Object.getPrototypeOf,m=Object.prototype;e.exports=function e(t,n,r){if("string"!=typeof n){if(m){var a=p(n);a&&a!==m&&e(t,a,r)}var l=s(n);f&&(l=l.concat(f(n)));for(var i=u(t),h=u(n),v=0;v{"use strict";var t=Object.getOwnPropertySymbols,n=Object.prototype.hasOwnProperty,r=Object.prototype.propertyIsEnumerable;function a(e){if(null==e)throw new TypeError("Object.assign cannot be called with null or undefined");return Object(e)}e.exports=function(){try{if(!Object.assign)return!1;var e=new String("abc");if(e[5]="de","5"===Object.getOwnPropertyNames(e)[0])return!1;for(var t={},n=0;n<10;n++)t["_"+String.fromCharCode(n)]=n;if("0123456789"!==Object.getOwnPropertyNames(t).map((function(e){return t[e]})).join(""))return!1;var r={};return"abcdefghijklmnopqrst".split("").forEach((function(e){r[e]=e})),"abcdefghijklmnopqrst"===Object.keys(Object.assign({},r)).join("")}catch(e){return!1}}()?Object.assign:function(e,o){for(var l,i,u=a(e),c=1;c{var r=n(173);e.exports=function e(t,n,a){return r(n)||(a=n||a,n=[]),a=a||{},t instanceof RegExp?function(e,t){var n=e.source.match(/\((?!\?)/g);if(n)for(var r=0;r{e.exports=Array.isArray||function(e){return"[object Array]"==Object.prototype.toString.call(e)}},703:(e,t,n)=>{"use strict";var r=n(414);function a(){}function o(){}o.resetWarningCache=a,e.exports=function(){function e(e,t,n,a,o,l){if(l!==r){var i=new Error("Calling PropTypes validators directly is not supported by the `prop-types` package. Use PropTypes.checkPropTypes() to call them. Read more at http://fb.me/use-check-prop-types");throw i.name="Invariant Violation",i}}function t(){return e}e.isRequired=e;var n={array:e,bool:e,func:e,number:e,object:e,string:e,symbol:e,any:e,arrayOf:t,element:e,elementType:e,instanceOf:t,node:e,objectOf:t,oneOf:t,oneOfType:t,shape:t,exact:t,checkPropTypes:o,resetWarningCache:a};return n.PropTypes=n,n}},697:(e,t,n)=>{e.exports=n(703)()},414:e=>{"use strict";e.exports="SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED"},448:(e,t,n)=>{"use strict";var r=n(294),a=n(418),o=n(840);function l(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n