mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-09 11:03:00 +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
|
- 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:
|
||||||
|
4
.github/workflows/test-docs.yml
vendored
4
.github/workflows/test-docs.yml
vendored
@@ -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: |
|
||||||
|
5
.github/workflows/test.yml
vendored
5
.github/workflows/test.yml
vendored
@@ -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: |
|
||||||
|
@@ -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
@@ -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": [...],
|
||||||
...
|
...
|
||||||
|
@@ -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`
|
||||||
|
@@ -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.
|
||||||
|
|
||||||
|
@@ -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>
|
||||||
|
@@ -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);
|
|
||||||
});
|
});
|
||||||
|
@@ -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>
|
||||||
|
@@ -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 () => {
|
||||||
|
@@ -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
|
||||||
|
@@ -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"):
|
||||||
|
@@ -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):
|
||||||
|
@@ -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
|
||||||
|
@@ -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 %}
|
||||||
|
@@ -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"
|
||||||
|
Reference in New Issue
Block a user