Compare commits

..

21 Commits

Author SHA1 Message Date
Min RK
c616ab284d Bump to 5.0.0 2024-05-24 12:45:26 +02:00
Min RK
41090ceb55 Merge pull request #4820 from minrk/rel5
final changelog for 5.0.0
2024-05-24 12:31:02 +02:00
Min RK
d7939c1721 one last patch 2024-05-24 11:00:46 +02:00
Min RK
d93ca55b11 update nginx ssl url 2024-05-24 10:57:36 +02:00
Min RK
9ff11e6fa4 Merge pull request #4821 from yuvipanda/fix-bootstrap
Fix missing `form-control` classes & some padding
2024-05-24 10:54:16 +02:00
pre-commit-ci[bot]
66ddaebf26 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2024-05-24 01:55:12 +00:00
YuviPanda
2598ac2c1a Fix missing form-control classes & some padding
- Missing `form-control` on a textbox gave it weird padding,
  this fixes it.
- Add new server is set up as a [button addon](https://getbootstrap.com/docs/5.3/forms/input-group/#button-addons)
- Add a little right margin to the username in the navbar,
  just before the logout button. Otherwise they were 'stuck'
  to each other
2024-05-23 18:53:32 -07:00
Min RK
4ab36e3da6 final changelog for 5.0.0 2024-05-23 13:10:58 +02:00
Min RK
282cc020b6 Merge pull request #4815 from minrk/admin-test
admin: don't use state change to update offset
2024-05-16 08:48:22 +02:00
Min RK
6912a5a752 Merge pull request #4817 from minrk/share-code-full-url
add full URLs to share modes
2024-05-16 08:45:08 +02:00
Min RK
cedf237852 avoid offset race cycle in groups as well 2024-05-15 10:42:58 +02:00
Min RK
9ff8f3e6ec update server model docstring 2024-05-15 10:29:09 +02:00
Erik Sundell
abc9581a75 Merge pull request #4816 from minrk/share-codes
DOC: /share-codes/ url typo
2024-05-15 10:01:53 +02:00
Min RK
02df033227 add full URLs to share modes
- full_url for SharedServer
- full_accept_url for ShareCode
2024-05-15 00:02:47 +02:00
Min RK
f82097bf2e /share-codes/ typo 2024-05-14 23:47:01 +02:00
Min RK
2af252c4c3 admin: don't use state change to update offset
set offset -> request page -> response sets offset is a recipe for races

instead, send request with new offset and only update offset state

made easier by consolidating page update requests into single loadPageData
2024-05-14 15:23:46 +02:00
Min RK
06c8d22087 Merge pull request #4814 from minrk/activity-warning
quieter logging in activity-reporting when hub is temporarily unavailable
2024-05-13 10:32:48 +02:00
Min RK
95d479af88 Merge pull request #4812 from minrk/setup-python-cache
ci: enable pip cache
2024-05-13 10:31:58 +02:00
Min RK
aee92985ac set cache-dependency-path 2024-05-13 09:49:18 +02:00
Min RK
ea73931ad0 quieter logging in activity-reporting when hub is temporarily unavailable 2024-05-13 09:36:19 +02:00
Min RK
b0494c203f ci: enable pip cache 2024-05-09 09:03:05 +02:00
18 changed files with 143 additions and 92 deletions

View File

@@ -36,6 +36,7 @@ jobs:
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: with:
python-version: "3.11" python-version: "3.11"
cache: pip
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:

View File

@@ -61,6 +61,10 @@ jobs:
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: with:
python-version: "3.11" python-version: "3.11"
cache: pip
cache-dependency-path: |
requirements.txt
docs/requirements.txt
- name: Install requirements - name: Install requirements
run: | run: |

View File

@@ -158,6 +158,11 @@ jobs:
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: "${{ matrix.python }}" python-version: "${{ matrix.python }}"
cache: pip
cache-dependency-path: |
pyproject.toml
requirements.txt
ci/oldest-dependencies/requirements.old
- name: Install Python dependencies - name: Install Python dependencies
run: | run: |

View File

@@ -7,7 +7,7 @@ info:
license: license:
name: BSD-3-Clause name: BSD-3-Clause
identifier: BSD-3-Clause identifier: BSD-3-Clause
version: 5.0.0b2 version: 5.0.0
servers: servers:
- url: /hub/api - url: /hub/api
security: security:
@@ -1176,8 +1176,16 @@ paths:
example: abc123 example: abc123
accept_url: accept_url:
type: string type: string
description: The URL for accepting the code description: The URL path for accepting the code
example: /hub/accept-share?code=abc123 example: /hub/accept-share?code=abc123
full_accept_url:
type:
- string
- "null"
description: |
The full URL for accepting the code,
if JupyterHub.public_url configuration is defined.
example: https://hub.example.org/hub/accept-share?code=abc123
security: security:
- oauth2: - oauth2:
- shares - shares
@@ -1877,7 +1885,14 @@ components:
description: the server name. '' for the default server. description: the server name. '' for the default server.
url: url:
type: string type: string
description: the server's URL description: the server's URL (path only when not using subdomains)
full_url:
type:
- string
- "null"
description: |
The full URL of the server (`https://hub.example.org/user/:name/:servername`).
`null` unless JupyterHub.public_url or subdomains are configured.
ready: ready:
type: boolean type: boolean
description: whether the server is ready description: whether the server is ready

File diff suppressed because one or more lines are too long

View File

@@ -264,7 +264,7 @@ Share codes are much like shares, except:
To create a share code: To create a share code:
```{parsed-literal} ```{parsed-literal}
[POST /api/share-code/:username/:servername](rest-api-post-share-code) [POST /api/share-codes/:username/:servername](rest-api-post-share-code)
``` ```
where the body should include the scopes to be granted and expiration. where the body should include the scopes to be granted and expiration.
@@ -286,6 +286,7 @@ The response contains the code itself:
{ {
"code": "abc1234....", "code": "abc1234....",
"accept_url": "/hub/accept-share?code=abc1234", "accept_url": "/hub/accept-share?code=abc1234",
"full_accept_url": "https://hub.example.org/hub/accept-share?code=abc1234",
"id": "sc_1234", "id": "sc_1234",
"scopes": [...], "scopes": [...],
... ...

View File

@@ -68,7 +68,7 @@ c.JupyterHub.ssl_cert = '/etc/letsencrypt/live/example.com/fullchain.pem'
### If SSL termination happens outside of the Hub ### If SSL termination happens outside of the Hub
In certain cases, for example, if the hub is running behind a reverse proxy, and In certain cases, for example, if the hub is running behind a reverse proxy, and
[SSL termination is being provided by NGINX](https://www.nginx.com/resources/admin-guide/nginx-ssl-termination/), [SSL termination is being provided by NGINX](https://docs.nginx.com/nginx/admin-guide/security-controls/terminating-ssl-http/),
it is reasonable to run the hub without SSL. it is reasonable to run the hub without SSL.
To achieve this, remove `c.JupyterHub.ssl_key` and `c.JupyterHub.ssl_cert` To achieve this, remove `c.JupyterHub.ssl_key` and `c.JupyterHub.ssl_cert`

View File

@@ -159,11 +159,14 @@ which will have a JSON response:
'last_exchanged_at': None, 'last_exchanged_at': None,
'code': 'U-eYLFT1lGstEqfMHpAIvTZ1MRjZ1Y1a-loGQ0K86to', 'code': 'U-eYLFT1lGstEqfMHpAIvTZ1MRjZ1Y1a-loGQ0K86to',
'accept_url': '/hub/accept-share?code=U-eYLFT1lGstEqfMHpAIvTZ1MRjZ1Y1a-loGQ0K86to', 'accept_url': '/hub/accept-share?code=U-eYLFT1lGstEqfMHpAIvTZ1MRjZ1Y1a-loGQ0K86to',
'full_accept_url': 'https://hub.example.org/accept-share?code=U-eYLFT1lGstEqfMHpAIvTZ1MRjZ1Y1a-loGQ0K86to',
} }
``` ```
The most relevant fields here are `code`, which contains the code itself, and `accept_url`, which is the URL path for the page another user. The most relevant fields here are `code`, which contains the code itself, and `accept_url`, which is the URL path for the page another user.
Note: it does not contain the _hostname_ of the hub, which JupyterHub often does not know. Note: it does not contain the _hostname_ of the hub, which JupyterHub often does not know.
If `public_url` configuration is defined, `full_accept_url` will be the full URL including the host.
Otherwise, it will be null.
Share codes are guaranteed to be url-safe, so no encoding is required. Share codes are guaranteed to be url-safe, so no encoding is required.

View File

@@ -14,8 +14,7 @@ const Groups = (props) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const navigate = useNavigate(); const navigate = useNavigate();
const { setOffset, offset, handleLimit, limit, setPagination } = const { offset, handleLimit, limit, setPagination } = usePaginationParams();
usePaginationParams();
const total = groups_page ? groups_page.total : undefined; const total = groups_page ? groups_page.total : undefined;
@@ -32,11 +31,22 @@ const Groups = (props) => {
}); });
}; };
// single callback to reload the page
// uses current state, or params can be specified if state
// should be updated _after_ load, e.g. offset
const loadPageData = (params) => {
params = params || {};
return updateGroups(
params.offset === undefined ? offset : params.offset,
params.limit === undefined ? limit : params.limit,
)
.then((data) => dispatchPageUpdate(data.items, data._pagination))
.catch((err) => setErrorAlert("Failed to update group list."));
};
useEffect(() => { useEffect(() => {
updateGroups(offset, limit).then((data) => loadPageData();
dispatchPageUpdate(data.items, data._pagination), }, [limit]);
);
}, [offset, limit]);
if (!groups_data || !groups_page) { if (!groups_data || !groups_page) {
return <div data-testid="no-show"></div>; return <div data-testid="no-show"></div>;
@@ -72,8 +82,10 @@ const Groups = (props) => {
limit={limit} limit={limit}
visible={groups_data.length} visible={groups_data.length}
total={total} total={total}
next={() => setOffset(offset + limit)} next={() => loadPageData({ offset: offset + limit })}
prev={() => setOffset(offset - limit)} prev={() =>
loadPageData({ offset: limit > offset ? 0 : offset - limit })
}
handleLimit={handleLimit} handleLimit={handleLimit}
/> />
</Card.Body> </Card.Body>

View File

@@ -112,8 +112,8 @@ test("Renders nothing if required data is not available", async () => {
expect(noShow).toBeVisible(); expect(noShow).toBeVisible();
}); });
test("Interacting with PaginationFooter causes state update and refresh via useEffect call", async () => { test("Interacting with PaginationFooter causes page refresh", async () => {
let upgradeGroupsSpy = mockAsync(); let updateGroupsSpy = mockAsync();
let setSearchParamsSpy = mockAsync(); let setSearchParamsSpy = mockAsync();
let searchParams = new URLSearchParams({ limit: "2" }); let searchParams = new URLSearchParams({ limit: "2" });
useSearchParams.mockImplementation(() => [ useSearchParams.mockImplementation(() => [
@@ -125,11 +125,11 @@ test("Interacting with PaginationFooter causes state update and refresh via useE
]); ]);
let _, setSearchParams; let _, setSearchParams;
await act(async () => { await act(async () => {
render(groupsJsx(upgradeGroupsSpy)); render(groupsJsx(updateGroupsSpy));
[_, setSearchParams] = useSearchParams(); [_, setSearchParams] = useSearchParams();
}); });
expect(upgradeGroupsSpy).toBeCalledWith(0, 2); expect(updateGroupsSpy).toBeCalledWith(0, 2);
var lastState = var lastState =
mockReducers.mock.results[mockReducers.mock.results.length - 1].value; mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
@@ -140,9 +140,7 @@ test("Interacting with PaginationFooter causes state update and refresh via useE
await act(async () => { await act(async () => {
fireEvent.click(next); fireEvent.click(next);
}); });
expect(setSearchParamsSpy).toBeCalledWith("limit=2&offset=2"); expect(updateGroupsSpy).toBeCalledWith(2, 2);
// mocked updateGroups means callback after load doesn't fire
// FIXME: mocked useSelector, state seem to prevent updateGroups from being called // expect(setSearchParamsSpy).toBeCalledWith("limit=2&offset=2");
// making the test environment not representative
// expect(callbackSpy).toHaveBeenCalledWith(2, 2);
}); });

View File

@@ -41,7 +41,7 @@ const ServerDashboard = (props) => {
let user_data = useSelector((state) => state.user_data); let user_data = useSelector((state) => state.user_data);
const user_page = useSelector((state) => state.user_page); const user_page = useSelector((state) => state.user_page);
const { setOffset, offset, setLimit, handleLimit, limit, setPagination } = const { offset, setLimit, handleLimit, limit, setPagination } =
usePaginationParams(); usePaginationParams();
const name_filter = searchParams.get("name_filter") || ""; const name_filter = searchParams.get("name_filter") || "";
@@ -123,26 +123,39 @@ const ServerDashboard = (props) => {
} else { } else {
params.set("state", new_state_filter); params.set("state", new_state_filter);
} }
console.log("setting search params", params.toString());
return params; return params;
}); });
}; };
// the callback to update the displayed user list // the callback to update the displayed user list
const updateUsersWithParams = () => const updateUsersWithParams = (params) => {
updateUsers({ if (params) {
offset, if (params.offset !== undefined && params.offset < 0) {
params.offset = 0;
}
}
return updateUsers({
offset: offset,
limit, limit,
name_filter, name_filter,
sort, sort,
state: state_filter, state: state_filter,
...params,
}); });
};
useEffect(() => { // single callback to reload the page
updateUsersWithParams() // uses current state, or params can be specified if state
// should be updated _after_ load, e.g. offset
const loadPageData = (params) => {
return updateUsersWithParams(params)
.then((data) => dispatchPageUpdate(data.items, data._pagination)) .then((data) => dispatchPageUpdate(data.items, data._pagination))
.catch((err) => setErrorAlert("Failed to update user list.")); .catch((err) => setErrorAlert("Failed to update user list."));
}, [offset, limit, name_filter, sort, state_filter]); };
useEffect(() => {
loadPageData();
}, [limit, name_filter, sort, state_filter]);
if (!user_data || !user_page) { if (!user_data || !user_page) {
return <div data-testid="no-show"></div>; return <div data-testid="no-show"></div>;
@@ -172,14 +185,7 @@ const ServerDashboard = (props) => {
action(user.name, server.name) action(user.name, server.name)
.then((res) => { .then((res) => {
if (res.status < 300) { if (res.status < 300) {
updateUsersWithParams() loadPageData();
.then((data) => {
dispatchPageUpdate(data.items, data._pagination);
})
.catch(() => {
setIsDisabled(false);
setErrorAlert(`Failed to update users list.`);
});
} else { } else {
setErrorAlert(`Failed to ${name.toLowerCase()}.`); setErrorAlert(`Failed to ${name.toLowerCase()}.`);
setIsDisabled(false); setIsDisabled(false);
@@ -519,13 +525,7 @@ const ServerDashboard = (props) => {
return res; return res;
}) })
.then((res) => { .then((res) => {
updateUsersWithParams() loadPageData();
.then((data) => {
dispatchPageUpdate(data.items, data._pagination);
})
.catch(() =>
setErrorAlert(`Failed to update users list.`),
);
return res; return res;
}) })
.catch(() => setErrorAlert(`Failed to start servers.`)); .catch(() => setErrorAlert(`Failed to start servers.`));
@@ -556,13 +556,7 @@ const ServerDashboard = (props) => {
return res; return res;
}) })
.then((res) => { .then((res) => {
updateUsersWithParams() loadPageData();
.then((data) => {
dispatchPageUpdate(data.items, data._pagination);
})
.catch(() =>
setErrorAlert(`Failed to update users list.`),
);
return res; return res;
}) })
.catch(() => setErrorAlert(`Failed to stop servers.`)); .catch(() => setErrorAlert(`Failed to stop servers.`));
@@ -590,8 +584,13 @@ const ServerDashboard = (props) => {
limit={limit} limit={limit}
visible={user_data.length} visible={user_data.length}
total={total} total={total}
next={() => setOffset(offset + limit)} // don't trigger via setOffset state change,
prev={() => setOffset(offset - limit)} // which can cause infinite cycles.
// offset state will be set upon reply via setPagination
next={() => loadPageData({ offset: offset + limit })}
prev={() =>
loadPageData({ offset: limit > offset ? 0 : offset - limit })
}
handleLimit={handleLimit} handleLimit={handleLimit}
/> />
<br></br> <br></br>

View File

@@ -608,7 +608,7 @@ test("Search for user calls updateUsers with name filter", async () => {
// expect(mockUpdateUsers).toBeCalledWith(0, 100, "ab"); // expect(mockUpdateUsers).toBeCalledWith(0, 100, "ab");
}); });
test("Interacting with PaginationFooter causes state update and refresh via useEffect call", async () => { test("Interacting with PaginationFooter requests page update", async () => {
await act(async () => { await act(async () => {
render(serverDashboardJsx()); render(serverDashboardJsx());
}); });
@@ -625,14 +625,10 @@ test("Interacting with PaginationFooter causes state update and refresh via useE
jest.runAllTimers(); jest.runAllTimers();
}); });
expect(searchParams.get("offset")).toEqual("2"); expect(mockUpdateUsers).toBeCalledWith({
expect(searchParams.get("limit")).toEqual("2"); ...defaultUpdateUsersParams,
offset: 2,
// FIXME: should call updateUsers, does in reality. });
// tests don't reflect reality due to mocked state/useSelector
// unclear how to fix this.
// expect(callbackSpy.mock.calls).toHaveLength(2);
// expect(callbackSpy).toHaveBeenCalledWith(2, 2, "");
}); });
test("Server delete button exists for named servers", async () => { test("Server delete button exists for named servers", async () => {

View File

@@ -3,7 +3,7 @@
# Copyright (c) Jupyter Development Team. # Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License. # Distributed under the terms of the Modified BSD License.
# version_info updated by running `tbump` # version_info updated by running `tbump`
version_info = (5, 0, 0, "b2", "") version_info = (5, 0, 0, "", "")
# pep 440 version: no dot before beta/rc, but before .dev # pep 440 version: no dot before beta/rc, but before .dev
# 0.1.0rc1 # 0.1.0rc1

View File

@@ -5,6 +5,7 @@
import json import json
import re import re
from typing import List, Optional from typing import List, Optional
from urllib.parse import urlunparse
from pydantic import ( from pydantic import (
BaseModel, BaseModel,
@@ -80,7 +81,7 @@ class _ShareAPIHandler(APIHandler):
"""Truncated server model for use in shares """Truncated server model for use in shares
- Adds "user" field (just name for now) - Adds "user" field (just name for now)
- Limits fields to "name", "url", "ready" - Limits fields to "name", "url", "full_url", "ready"
from standard server model from standard server model
""" """
user = self.users[spawner.user.id] user = self.users[spawner.user.id]
@@ -95,7 +96,7 @@ class _ShareAPIHandler(APIHandler):
} }
} }
# subset keys for sharing # subset keys for sharing
for key in ["name", "url", "ready"]: for key in ["name", "url", "full_url", "ready"]:
if key in full_model: if key in full_model:
server_model[key] = full_model[key] server_model[key] = full_model[key]
@@ -128,6 +129,12 @@ class _ShareAPIHandler(APIHandler):
model["accept_url"] = url_concat( model["accept_url"] = url_concat(
self.hub.base_url + "accept-share", {"code": code} self.hub.base_url + "accept-share", {"code": code}
) )
model["full_accept_url"] = None
public_url = self.settings.get("public_url")
if public_url:
model["full_accept_url"] = urlunparse(
public_url._replace(path=model["accept_url"])
)
return model return model
def _init_share_query(self, kind="share"): def _init_share_query(self, kind="share"):

View File

@@ -412,9 +412,12 @@ class JupyterHubSingleUser(ExtensionApp):
return return
last_activity_timestamp = isoformat(last_activity) last_activity_timestamp = isoformat(last_activity)
failure_count = 0
async def notify(): async def notify():
nonlocal failure_count
self.log.debug("Notifying Hub of activity %s", last_activity_timestamp) self.log.debug("Notifying Hub of activity %s", last_activity_timestamp)
req = HTTPRequest( req = HTTPRequest(
url=self.hub_activity_url, url=self.hub_activity_url,
method='POST', method='POST',
@@ -433,8 +436,12 @@ class JupyterHubSingleUser(ExtensionApp):
) )
try: try:
await client.fetch(req) await client.fetch(req)
except Exception: except Exception as e:
self.log.exception("Error notifying Hub of activity") failure_count += 1
# log traceback at debug-level
self.log.debug("Error notifying Hub of activity", exc_info=True)
# only one-line error visible by default
self.log.error("Error notifying Hub of activity: %s", e)
return False return False
else: else:
return True return True
@@ -446,6 +453,8 @@ class JupyterHubSingleUser(ExtensionApp):
max_wait=15, max_wait=15,
timeout=60, timeout=60,
) )
if failure_count:
self.log.info("Sent hub activity after %s retries", failure_count)
self._last_activity_sent = last_activity self._last_activity_sent = last_activity
async def keep_activity_updated(self): async def keep_activity_updated(self):

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
# ref: https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html # ref: https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html
[project] [project]
name = "jupyterhub" name = "jupyterhub"
version = "5.0.0b2" version = "5.0.0"
dynamic = ["readme", "dependencies"] dynamic = ["readme", "dependencies"]
description = "JupyterHub: A multi-user server for Jupyter notebooks" description = "JupyterHub: A multi-user server for Jupyter notebooks"
authors = [ authors = [
@@ -147,7 +147,7 @@ indent_size = 2
github_url = "https://github.com/jupyterhub/jupyterhub" github_url = "https://github.com/jupyterhub/jupyterhub"
[tool.tbump.version] [tool.tbump.version]
current = "5.0.0b2" current = "5.0.0"
# Example of a semver regexp. # Example of a semver regexp.
# Make sure this matches current_version before # Make sure this matches current_version before

View File

@@ -39,12 +39,14 @@
<tbody> <tbody>
<tr class="home-server-row add-server-row"> <tr class="home-server-row add-server-row">
<td colspan="4"> <td colspan="4">
<input class="new-server-name" <div class="input-group">
aria-label="server name" <input class="new-server-name form-control"
placeholder="name-your-server"> aria-label="server name"
<button role="button" placeholder="name-your-server">
type="button" <button role="button"
class="new-server-btn btn btn-xs btn-primary">Add New Server</button> type="button"
class="new-server-btn btn btn-xs btn-primary">Add New Server</button>
</div>
</td> </td>
</tr> </tr>
{% for spawner in named_spawners %} {% for spawner in named_spawners %}

View File

@@ -167,7 +167,7 @@
{% block login_widget %} {% block login_widget %}
<span id="login_widget"> <span id="login_widget">
{% if user %} {% if user %}
<span class="navbar-text">{{ user.name }}</span> <span class="navbar-text me-1">{{ user.name }}</span>
<a id="logout" <a id="logout"
role="button" role="button"
class="btn btn-sm btn-outline-dark" class="btn btn-sm btn-outline-dark"