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
with:
python-version: "3.11"
cache: pip
- uses: actions/setup-node@v4
with:

View File

@@ -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: |

View File

@@ -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: |

View File

@@ -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

View File

@@ -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": [...],
...

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
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`

View File

@@ -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.

View File

@@ -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>

View File

@@ -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");
});

View File

@@ -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>

View File

@@ -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 () => {

View File

@@ -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

View File

@@ -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"):

View File

@@ -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):

View File

@@ -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

View File

@@ -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 %}

View File

@@ -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"