mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-08 10:34:10 +00:00
Compare commits
21 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
c616ab284d | ||
![]() |
41090ceb55 | ||
![]() |
d7939c1721 | ||
![]() |
d93ca55b11 | ||
![]() |
9ff11e6fa4 | ||
![]() |
66ddaebf26 | ||
![]() |
2598ac2c1a | ||
![]() |
4ab36e3da6 | ||
![]() |
282cc020b6 | ||
![]() |
6912a5a752 | ||
![]() |
cedf237852 | ||
![]() |
9ff8f3e6ec | ||
![]() |
abc9581a75 | ||
![]() |
02df033227 | ||
![]() |
f82097bf2e | ||
![]() |
2af252c4c3 | ||
![]() |
06c8d22087 | ||
![]() |
95d479af88 | ||
![]() |
aee92985ac | ||
![]() |
ea73931ad0 | ||
![]() |
b0494c203f |
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@@ -36,6 +36,7 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
|
4
.github/workflows/test-docs.yml
vendored
4
.github/workflows/test-docs.yml
vendored
@@ -61,6 +61,10 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
cache-dependency-path: |
|
||||
requirements.txt
|
||||
docs/requirements.txt
|
||||
|
||||
- name: Install requirements
|
||||
run: |
|
||||
|
5
.github/workflows/test.yml
vendored
5
.github/workflows/test.yml
vendored
@@ -158,6 +158,11 @@ jobs:
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "${{ matrix.python }}"
|
||||
cache: pip
|
||||
cache-dependency-path: |
|
||||
pyproject.toml
|
||||
requirements.txt
|
||||
ci/oldest-dependencies/requirements.old
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
|
@@ -7,7 +7,7 @@ info:
|
||||
license:
|
||||
name: BSD-3-Clause
|
||||
identifier: BSD-3-Clause
|
||||
version: 5.0.0b2
|
||||
version: 5.0.0
|
||||
servers:
|
||||
- url: /hub/api
|
||||
security:
|
||||
@@ -1176,8 +1176,16 @@ paths:
|
||||
example: abc123
|
||||
accept_url:
|
||||
type: string
|
||||
description: The URL for accepting the code
|
||||
description: The URL path for accepting the code
|
||||
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:
|
||||
- oauth2:
|
||||
- shares
|
||||
@@ -1877,7 +1885,14 @@ components:
|
||||
description: the server name. '' for the default server.
|
||||
url:
|
||||
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:
|
||||
type: boolean
|
||||
description: whether the server is ready
|
||||
|
File diff suppressed because one or more lines are too long
@@ -264,7 +264,7 @@ Share codes are much like shares, except:
|
||||
To create a share code:
|
||||
|
||||
```{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.
|
||||
@@ -286,6 +286,7 @@ The response contains the code itself:
|
||||
{
|
||||
"code": "abc1234....",
|
||||
"accept_url": "/hub/accept-share?code=abc1234",
|
||||
"full_accept_url": "https://hub.example.org/hub/accept-share?code=abc1234",
|
||||
"id": "sc_1234",
|
||||
"scopes": [...],
|
||||
...
|
||||
|
@@ -68,7 +68,7 @@ c.JupyterHub.ssl_cert = '/etc/letsencrypt/live/example.com/fullchain.pem'
|
||||
### If SSL termination happens outside of the Hub
|
||||
|
||||
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.
|
||||
|
||||
To achieve this, remove `c.JupyterHub.ssl_key` and `c.JupyterHub.ssl_cert`
|
||||
|
@@ -159,11 +159,14 @@ which will have a JSON response:
|
||||
'last_exchanged_at': None,
|
||||
'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.
|
||||
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.
|
||||
|
||||
|
@@ -14,8 +14,7 @@ const Groups = (props) => {
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { setOffset, offset, handleLimit, limit, setPagination } =
|
||||
usePaginationParams();
|
||||
const { offset, handleLimit, limit, setPagination } = usePaginationParams();
|
||||
|
||||
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(() => {
|
||||
updateGroups(offset, limit).then((data) =>
|
||||
dispatchPageUpdate(data.items, data._pagination),
|
||||
);
|
||||
}, [offset, limit]);
|
||||
loadPageData();
|
||||
}, [limit]);
|
||||
|
||||
if (!groups_data || !groups_page) {
|
||||
return <div data-testid="no-show"></div>;
|
||||
@@ -72,8 +82,10 @@ const Groups = (props) => {
|
||||
limit={limit}
|
||||
visible={groups_data.length}
|
||||
total={total}
|
||||
next={() => setOffset(offset + limit)}
|
||||
prev={() => setOffset(offset - limit)}
|
||||
next={() => loadPageData({ offset: offset + limit })}
|
||||
prev={() =>
|
||||
loadPageData({ offset: limit > offset ? 0 : offset - limit })
|
||||
}
|
||||
handleLimit={handleLimit}
|
||||
/>
|
||||
</Card.Body>
|
||||
|
@@ -112,8 +112,8 @@ test("Renders nothing if required data is not available", async () => {
|
||||
expect(noShow).toBeVisible();
|
||||
});
|
||||
|
||||
test("Interacting with PaginationFooter causes state update and refresh via useEffect call", async () => {
|
||||
let upgradeGroupsSpy = mockAsync();
|
||||
test("Interacting with PaginationFooter causes page refresh", async () => {
|
||||
let updateGroupsSpy = mockAsync();
|
||||
let setSearchParamsSpy = mockAsync();
|
||||
let searchParams = new URLSearchParams({ limit: "2" });
|
||||
useSearchParams.mockImplementation(() => [
|
||||
@@ -125,11 +125,11 @@ test("Interacting with PaginationFooter causes state update and refresh via useE
|
||||
]);
|
||||
let _, setSearchParams;
|
||||
await act(async () => {
|
||||
render(groupsJsx(upgradeGroupsSpy));
|
||||
render(groupsJsx(updateGroupsSpy));
|
||||
[_, setSearchParams] = useSearchParams();
|
||||
});
|
||||
|
||||
expect(upgradeGroupsSpy).toBeCalledWith(0, 2);
|
||||
expect(updateGroupsSpy).toBeCalledWith(0, 2);
|
||||
|
||||
var lastState =
|
||||
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 () => {
|
||||
fireEvent.click(next);
|
||||
});
|
||||
expect(setSearchParamsSpy).toBeCalledWith("limit=2&offset=2");
|
||||
|
||||
// FIXME: mocked useSelector, state seem to prevent updateGroups from being called
|
||||
// making the test environment not representative
|
||||
// expect(callbackSpy).toHaveBeenCalledWith(2, 2);
|
||||
expect(updateGroupsSpy).toBeCalledWith(2, 2);
|
||||
// mocked updateGroups means callback after load doesn't fire
|
||||
// expect(setSearchParamsSpy).toBeCalledWith("limit=2&offset=2");
|
||||
});
|
||||
|
@@ -41,7 +41,7 @@ const ServerDashboard = (props) => {
|
||||
let user_data = useSelector((state) => state.user_data);
|
||||
const user_page = useSelector((state) => state.user_page);
|
||||
|
||||
const { setOffset, offset, setLimit, handleLimit, limit, setPagination } =
|
||||
const { offset, setLimit, handleLimit, limit, setPagination } =
|
||||
usePaginationParams();
|
||||
|
||||
const name_filter = searchParams.get("name_filter") || "";
|
||||
@@ -123,26 +123,39 @@ const ServerDashboard = (props) => {
|
||||
} else {
|
||||
params.set("state", new_state_filter);
|
||||
}
|
||||
console.log("setting search params", params.toString());
|
||||
return params;
|
||||
});
|
||||
};
|
||||
|
||||
// the callback to update the displayed user list
|
||||
const updateUsersWithParams = () =>
|
||||
updateUsers({
|
||||
offset,
|
||||
const updateUsersWithParams = (params) => {
|
||||
if (params) {
|
||||
if (params.offset !== undefined && params.offset < 0) {
|
||||
params.offset = 0;
|
||||
}
|
||||
}
|
||||
return updateUsers({
|
||||
offset: offset,
|
||||
limit,
|
||||
name_filter,
|
||||
sort,
|
||||
state: state_filter,
|
||||
...params,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateUsersWithParams()
|
||||
// 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) => {
|
||||
return updateUsersWithParams(params)
|
||||
.then((data) => dispatchPageUpdate(data.items, data._pagination))
|
||||
.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) {
|
||||
return <div data-testid="no-show"></div>;
|
||||
@@ -172,14 +185,7 @@ const ServerDashboard = (props) => {
|
||||
action(user.name, server.name)
|
||||
.then((res) => {
|
||||
if (res.status < 300) {
|
||||
updateUsersWithParams()
|
||||
.then((data) => {
|
||||
dispatchPageUpdate(data.items, data._pagination);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsDisabled(false);
|
||||
setErrorAlert(`Failed to update users list.`);
|
||||
});
|
||||
loadPageData();
|
||||
} else {
|
||||
setErrorAlert(`Failed to ${name.toLowerCase()}.`);
|
||||
setIsDisabled(false);
|
||||
@@ -519,13 +525,7 @@ const ServerDashboard = (props) => {
|
||||
return res;
|
||||
})
|
||||
.then((res) => {
|
||||
updateUsersWithParams()
|
||||
.then((data) => {
|
||||
dispatchPageUpdate(data.items, data._pagination);
|
||||
})
|
||||
.catch(() =>
|
||||
setErrorAlert(`Failed to update users list.`),
|
||||
);
|
||||
loadPageData();
|
||||
return res;
|
||||
})
|
||||
.catch(() => setErrorAlert(`Failed to start servers.`));
|
||||
@@ -556,13 +556,7 @@ const ServerDashboard = (props) => {
|
||||
return res;
|
||||
})
|
||||
.then((res) => {
|
||||
updateUsersWithParams()
|
||||
.then((data) => {
|
||||
dispatchPageUpdate(data.items, data._pagination);
|
||||
})
|
||||
.catch(() =>
|
||||
setErrorAlert(`Failed to update users list.`),
|
||||
);
|
||||
loadPageData();
|
||||
return res;
|
||||
})
|
||||
.catch(() => setErrorAlert(`Failed to stop servers.`));
|
||||
@@ -590,8 +584,13 @@ const ServerDashboard = (props) => {
|
||||
limit={limit}
|
||||
visible={user_data.length}
|
||||
total={total}
|
||||
next={() => setOffset(offset + limit)}
|
||||
prev={() => setOffset(offset - limit)}
|
||||
// don't trigger via setOffset state change,
|
||||
// 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}
|
||||
/>
|
||||
<br></br>
|
||||
|
@@ -608,7 +608,7 @@ test("Search for user calls updateUsers with name filter", async () => {
|
||||
// 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 () => {
|
||||
render(serverDashboardJsx());
|
||||
});
|
||||
@@ -625,14 +625,10 @@ test("Interacting with PaginationFooter causes state update and refresh via useE
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(searchParams.get("offset")).toEqual("2");
|
||||
expect(searchParams.get("limit")).toEqual("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, "");
|
||||
expect(mockUpdateUsers).toBeCalledWith({
|
||||
...defaultUpdateUsersParams,
|
||||
offset: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test("Server delete button exists for named servers", async () => {
|
||||
|
@@ -3,7 +3,7 @@
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
# version_info updated by running `tbump`
|
||||
version_info = (5, 0, 0, "b2", "")
|
||||
version_info = (5, 0, 0, "", "")
|
||||
|
||||
# pep 440 version: no dot before beta/rc, but before .dev
|
||||
# 0.1.0rc1
|
||||
|
@@ -5,6 +5,7 @@
|
||||
import json
|
||||
import re
|
||||
from typing import List, Optional
|
||||
from urllib.parse import urlunparse
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
@@ -80,7 +81,7 @@ class _ShareAPIHandler(APIHandler):
|
||||
"""Truncated server model for use in shares
|
||||
|
||||
- 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
|
||||
"""
|
||||
user = self.users[spawner.user.id]
|
||||
@@ -95,7 +96,7 @@ class _ShareAPIHandler(APIHandler):
|
||||
}
|
||||
}
|
||||
# subset keys for sharing
|
||||
for key in ["name", "url", "ready"]:
|
||||
for key in ["name", "url", "full_url", "ready"]:
|
||||
if key in full_model:
|
||||
server_model[key] = full_model[key]
|
||||
|
||||
@@ -128,6 +129,12 @@ class _ShareAPIHandler(APIHandler):
|
||||
model["accept_url"] = url_concat(
|
||||
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
|
||||
|
||||
def _init_share_query(self, kind="share"):
|
||||
|
@@ -412,9 +412,12 @@ class JupyterHubSingleUser(ExtensionApp):
|
||||
return
|
||||
|
||||
last_activity_timestamp = isoformat(last_activity)
|
||||
failure_count = 0
|
||||
|
||||
async def notify():
|
||||
nonlocal failure_count
|
||||
self.log.debug("Notifying Hub of activity %s", last_activity_timestamp)
|
||||
|
||||
req = HTTPRequest(
|
||||
url=self.hub_activity_url,
|
||||
method='POST',
|
||||
@@ -433,8 +436,12 @@ class JupyterHubSingleUser(ExtensionApp):
|
||||
)
|
||||
try:
|
||||
await client.fetch(req)
|
||||
except Exception:
|
||||
self.log.exception("Error notifying Hub of activity")
|
||||
except Exception as e:
|
||||
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
|
||||
else:
|
||||
return True
|
||||
@@ -446,6 +453,8 @@ class JupyterHubSingleUser(ExtensionApp):
|
||||
max_wait=15,
|
||||
timeout=60,
|
||||
)
|
||||
if failure_count:
|
||||
self.log.info("Sent hub activity after %s retries", failure_count)
|
||||
self._last_activity_sent = last_activity
|
||||
|
||||
async def keep_activity_updated(self):
|
||||
|
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
# ref: https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html
|
||||
[project]
|
||||
name = "jupyterhub"
|
||||
version = "5.0.0b2"
|
||||
version = "5.0.0"
|
||||
dynamic = ["readme", "dependencies"]
|
||||
description = "JupyterHub: A multi-user server for Jupyter notebooks"
|
||||
authors = [
|
||||
@@ -147,7 +147,7 @@ indent_size = 2
|
||||
github_url = "https://github.com/jupyterhub/jupyterhub"
|
||||
|
||||
[tool.tbump.version]
|
||||
current = "5.0.0b2"
|
||||
current = "5.0.0"
|
||||
|
||||
# Example of a semver regexp.
|
||||
# Make sure this matches current_version before
|
||||
|
@@ -39,12 +39,14 @@
|
||||
<tbody>
|
||||
<tr class="home-server-row add-server-row">
|
||||
<td colspan="4">
|
||||
<input class="new-server-name"
|
||||
aria-label="server name"
|
||||
placeholder="name-your-server">
|
||||
<button role="button"
|
||||
type="button"
|
||||
class="new-server-btn btn btn-xs btn-primary">Add New Server</button>
|
||||
<div class="input-group">
|
||||
<input class="new-server-name form-control"
|
||||
aria-label="server name"
|
||||
placeholder="name-your-server">
|
||||
<button role="button"
|
||||
type="button"
|
||||
class="new-server-btn btn btn-xs btn-primary">Add New Server</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% for spawner in named_spawners %}
|
||||
|
@@ -167,7 +167,7 @@
|
||||
{% block login_widget %}
|
||||
<span id="login_widget">
|
||||
{% if user %}
|
||||
<span class="navbar-text">{{ user.name }}</span>
|
||||
<span class="navbar-text me-1">{{ user.name }}</span>
|
||||
<a id="logout"
|
||||
role="button"
|
||||
class="btn btn-sm btn-outline-dark"
|
||||
|
Reference in New Issue
Block a user