diff --git a/docs/source/_static/rest-api.yml b/docs/source/_static/rest-api.yml index ed71a909..4f9e5d84 100644 --- a/docs/source/_static/rest-api.yml +++ b/docs/source/_static/rest-api.yml @@ -139,6 +139,16 @@ paths: If unspecified, use api_page_default_limit. schema: type: number + - name: include_stopped_servers + in: query + description: | + Include stopped servers in user model(s). + Added in JupyterHub 3.0. + Allows retrieval of information about stopped servers, + such as activity and state fields. + schema: + type: boolean + allowEmptyValue: true responses: 200: description: The Hub's user list @@ -1160,7 +1170,11 @@ components: format: date-time servers: type: array - description: The active servers for this user. + description: | + The servers for this user. + By default: only includes _active_ servers. + Changed in 3.0: if `?include_stopped_servers` parameter is specified, + stopped servers will be included as well. items: $ref: "#/components/schemas/Server" auth_state: @@ -1182,6 +1196,15 @@ components: description: | Whether the server is ready for traffic. Will always be false when any transition is pending. + stopped: + type: boolean + description: | + Whether the server is stopped. Added in JupyterHub 3.0, + and only useful when using the `?include_stopped_servers` + request parameter. + Now that stopped servers may be included (since JupyterHub 3.0), + this is the simplest way to select stopped servers. + Always equivalent to `not (ready or pending)`. pending: type: string description: | diff --git a/jsx/src/App.jsx b/jsx/src/App.jsx index ab592e1d..49058604 100644 --- a/jsx/src/App.jsx +++ b/jsx/src/App.jsx @@ -22,15 +22,16 @@ const store = createStore(reducers, initialState); const App = () => { useEffect(() => { let { limit, user_page, groups_page } = initialState; - jhapiRequest(`/users?offset=${user_page * limit}&limit=${limit}`, "GET") - .then((data) => data.json()) + let api = withAPI()().props; + api + .updateUsers(user_page * limit, limit) .then((data) => store.dispatch({ type: "USER_PAGE", value: { data: data, page: 0 } }) ) .catch((err) => console.log(err)); - jhapiRequest(`/groups?offset=${groups_page * limit}&limit=${limit}`, "GET") - .then((data) => data.json()) + api + .updateGroups(groups_page * limit, limit) .then((data) => store.dispatch({ type: "GROUPS_PAGE", value: { data: data, page: 0 } }) ) diff --git a/jsx/src/util/withAPI.js b/jsx/src/util/withAPI.js index 1e6f680e..198d3387 100644 --- a/jsx/src/util/withAPI.js +++ b/jsx/src/util/withAPI.js @@ -4,7 +4,9 @@ import { jhapiRequest } from "./jhapiUtil"; const withAPI = withProps(() => ({ updateUsers: (offset, limit, name_filter) => jhapiRequest( - `/users?offset=${offset}&limit=${limit}&name_filter=${name_filter || ""}`, + `/users?include_stopped_servers&offset=${offset}&limit=${limit}&name_filter=${ + name_filter || "" + }`, "GET" ).then((data) => data.json()), updateGroups: (offset, limit) => diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py index b6ccb4e8..88844cb5 100644 --- a/jupyterhub/apihandlers/base.py +++ b/jupyterhub/apihandlers/base.py @@ -187,22 +187,44 @@ class APIHandler(BaseHandler): json.dumps({'status': status_code, 'message': message or status_message}) ) - def server_model(self, spawner): + def server_model(self, spawner, *, user=None): """Get the JSON model for a Spawner - Assume server permission already granted""" + Assume server permission already granted + """ + if isinstance(spawner, orm.Spawner): + # if an orm.Spawner is passed, + # create a model for a stopped Spawner + # not all info is available without the higher-level Spawner wrapper + orm_spawner = spawner + pending = None + ready = False + stopped = True + user = user + if user is None: + raise RuntimeError("Must specify User with orm.Spawner") + state = orm_spawner.state + else: + orm_spawner = spawner.orm_spawner + pending = spawner.pending + ready = spawner.ready + user = spawner.user + stopped = not spawner.active + state = spawner.get_state() + model = { - 'name': spawner.name, - 'last_activity': isoformat(spawner.orm_spawner.last_activity), - 'started': isoformat(spawner.orm_spawner.started), - 'pending': spawner.pending, - 'ready': spawner.ready, - 'url': url_path_join(spawner.user.url, url_escape_path(spawner.name), '/'), + 'name': orm_spawner.name, + 'last_activity': isoformat(orm_spawner.last_activity), + 'started': isoformat(orm_spawner.started), + 'pending': pending, + 'ready': ready, + 'stopped': stopped, + 'url': url_path_join(user.url, url_escape_path(spawner.name), '/'), 'user_options': spawner.user_options, - 'progress_url': spawner._progress_url, + 'progress_url': user.progress_url(spawner.name), } scope_filter = self.get_scope_filter('admin:server_state') if scope_filter(spawner, kind='server'): - model['state'] = spawner.get_state() + model['state'] = state return model def token_model(self, token): @@ -248,10 +270,22 @@ class APIHandler(BaseHandler): keys.update(allowed_keys) return model + _include_stopped_servers = None + + @property + def include_stopped_servers(self): + """Whether stopped servers should be included in user models""" + if self._include_stopped_servers is None: + self._include_stopped_servers = self.get_argument( + "include_stopped_servers", "0" + ).lower() not in {"0", "false"} + return self._include_stopped_servers + def user_model(self, user): """Get the JSON model for a User object""" if isinstance(user, orm.User): user = self.users[user.id] + include_stopped_servers = self.include_stopped_servers model = { 'kind': 'user', 'name': user.name, @@ -291,18 +325,29 @@ class APIHandler(BaseHandler): if '' in user.spawners and 'pending' in allowed_keys: model['pending'] = user.spawners[''].pending - servers = model['servers'] = {} + servers = {} scope_filter = self.get_scope_filter('read:servers') for name, spawner in user.spawners.items(): # include 'active' servers, not just ready # (this includes pending events) - if spawner.active and scope_filter(spawner, kind='server'): + if (spawner.active or include_stopped_servers) and scope_filter( + spawner, kind='server' + ): servers[name] = self.server_model(spawner) - if not servers and 'servers' not in allowed_keys: + + if include_stopped_servers: + # add any stopped servers in the db + seen = set(servers.keys()) + for name, orm_spawner in user.orm_spawners.items(): + if name not in seen and scope_filter(orm_spawner, kind='server'): + servers[name] = self.server_model(orm_spawner, user=user) + + if "servers" in allowed_keys or servers: # omit servers if no access # leave present and empty # if request has access to read servers in general - model.pop('servers') + model["servers"] = servers + return model def group_model(self, group): diff --git a/jupyterhub/tests/test_named_servers.py b/jupyterhub/tests/test_named_servers.py index 5751d7ff..8af29edf 100644 --- a/jupyterhub/tests/test_named_servers.py +++ b/jupyterhub/tests/test_named_servers.py @@ -1,6 +1,7 @@ """Tests for named servers""" import asyncio import json +import time from unittest import mock from urllib.parse import unquote, urlencode, urlparse @@ -61,6 +62,7 @@ async def test_default_server(app, named_servers): 'url': user.url, 'pending': None, 'ready': True, + 'stopped': False, 'progress_url': 'PREFIX/hub/api/users/{}/server/progress'.format( username ), @@ -148,6 +150,7 @@ async def test_create_named_server( 'url': url_path_join(user.url, escapedname, '/'), 'pending': None, 'ready': True, + 'stopped': False, 'progress_url': 'PREFIX/hub/api/users/{}/servers/{}/progress'.format( username, escapedname ), @@ -433,3 +436,61 @@ async def test_named_server_stop_server(app, username, named_servers): assert user.spawners[server_name].server is None assert user.spawners[''].server assert user.running + + +@pytest.mark.parametrize( + "include_stopped_servers", + [True, False], +) +async def test_stopped_servers(app, user, named_servers, include_stopped_servers): + r = await api_request(app, 'users', user.name, 'server', method='post') + r.raise_for_status() + r = await api_request(app, 'users', user.name, 'servers', "named", method='post') + r.raise_for_status() + + # wait for starts + for i in range(60): + r = await api_request(app, 'users', user.name) + r.raise_for_status() + user_model = r.json() + if not all(s["ready"] for s in user_model["servers"].values()): + time.sleep(1) + else: + break + else: + raise TimeoutError(f"User never stopped: {user_model}") + + r = await api_request(app, 'users', user.name, 'server', method='delete') + r.raise_for_status() + r = await api_request(app, 'users', user.name, 'servers', "named", method='delete') + r.raise_for_status() + + # wait for stops + for i in range(60): + r = await api_request(app, 'users', user.name) + r.raise_for_status() + user_model = r.json() + if not all(s["stopped"] for s in user_model["servers"].values()): + time.sleep(1) + else: + break + else: + raise TimeoutError(f"User never stopped: {user_model}") + + # we have two stopped servers + path = f"users/{user.name}" + if include_stopped_servers: + path = f"{path}?include_stopped_servers" + r = await api_request(app, path) + r.raise_for_status() + user_model = r.json() + servers = list(user_model["servers"].values()) + if include_stopped_servers: + assert len(servers) == 2 + assert all(s["last_activity"] for s in servers) + assert all(s["started"] is None for s in servers) + assert all(s["stopped"] for s in servers) + assert not any(s["ready"] for s in servers) + assert not any(s["pending"] for s in servers) + else: + assert user_model["servers"] == {}