mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-07 18:14:10 +00:00
Compare commits
36 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
20b3229249 | ||
![]() |
f0862f1d10 | ||
![]() |
3c5f9b255e | ||
![]() |
b6d9d5c120 | ||
![]() |
bccd0e2ff1 | ||
![]() |
a2d39c693d | ||
![]() |
76e65da9ff | ||
![]() |
eb9bb71655 | ||
![]() |
a39ef8f163 | ||
![]() |
f4727cba47 | ||
![]() |
14dfa65c75 | ||
![]() |
9f23bc2959 | ||
![]() |
24e8362401 | ||
![]() |
c4c662843c | ||
![]() |
6d5b13962c | ||
![]() |
fe64595d75 | ||
![]() |
a3c93088a8 | ||
![]() |
834229622d | ||
![]() |
44a1ea42de | ||
![]() |
3879a96b67 | ||
![]() |
d40627d397 | ||
![]() |
057cdbc9e9 | ||
![]() |
75390d2e46 | ||
![]() |
f5e4846cfa | ||
![]() |
3dc115a829 | ||
![]() |
af4ddbfc58 | ||
![]() |
50a4d1e34d | ||
![]() |
86a238334c | ||
![]() |
dacb9d1668 | ||
![]() |
95cc170383 | ||
![]() |
437a9d150f | ||
![]() |
c9616d6f11 | ||
![]() |
61aed70c4d | ||
![]() |
9abb573d47 | ||
![]() |
b074304834 | ||
![]() |
201e7ca3d8 |
@@ -11,7 +11,7 @@
|
|||||||
repos:
|
repos:
|
||||||
# Autoformat: Python code, syntax patterns are modernized
|
# Autoformat: Python code, syntax patterns are modernized
|
||||||
- repo: https://github.com/asottile/pyupgrade
|
- repo: https://github.com/asottile/pyupgrade
|
||||||
rev: v2.31.1
|
rev: v2.32.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyupgrade
|
- id: pyupgrade
|
||||||
args:
|
args:
|
||||||
@@ -19,26 +19,25 @@ repos:
|
|||||||
|
|
||||||
# Autoformat: Python code
|
# Autoformat: Python code
|
||||||
- repo: https://github.com/asottile/reorder_python_imports
|
- repo: https://github.com/asottile/reorder_python_imports
|
||||||
rev: v3.0.1
|
rev: v3.1.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: reorder-python-imports
|
- id: reorder-python-imports
|
||||||
|
|
||||||
# Autoformat: Python code
|
# Autoformat: Python code
|
||||||
- repo: https://github.com/psf/black
|
- repo: https://github.com/psf/black
|
||||||
rev: 22.1.0
|
rev: 22.3.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
args: [--target-version=py36]
|
|
||||||
|
|
||||||
# Autoformat: markdown, yaml, javascript (see the file .prettierignore)
|
# Autoformat: markdown, yaml, javascript (see the file .prettierignore)
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||||
rev: v2.5.1
|
rev: v2.6.2
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
|
|
||||||
# Autoformat and linting, misc. details
|
# Autoformat and linting, misc. details
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.1.0
|
rev: v4.2.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
exclude: share/jupyterhub/static/js/admin-react.js
|
exclude: share/jupyterhub/static/js/admin-react.js
|
||||||
|
@@ -59,7 +59,7 @@ JupyterHub also provides a
|
|||||||
[REST API][]
|
[REST API][]
|
||||||
for administration of the Hub and its users.
|
for administration of the Hub and its users.
|
||||||
|
|
||||||
[rest api]: https://juptyerhub.readthedocs.io/en/latest/reference/rest-api.html
|
[rest api]: https://jupyterhub.readthedocs.io/en/latest/reference/rest-api.html
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
@@ -11,7 +11,8 @@ jupyterlab >=3
|
|||||||
mock
|
mock
|
||||||
pre-commit
|
pre-commit
|
||||||
pytest>=3.3
|
pytest>=3.3
|
||||||
pytest-asyncio
|
pytest-asyncio; python_version < "3.7"
|
||||||
|
pytest-asyncio>=0.17; python_version >= "3.7"
|
||||||
pytest-cov
|
pytest-cov
|
||||||
requests-mock
|
requests-mock
|
||||||
tbump
|
tbump
|
||||||
|
@@ -6,7 +6,7 @@ info:
|
|||||||
description: The REST API for JupyterHub
|
description: The REST API for JupyterHub
|
||||||
license:
|
license:
|
||||||
name: BSD-3-Clause
|
name: BSD-3-Clause
|
||||||
version: 2.3.0.dev
|
version: 2.3.1
|
||||||
servers:
|
servers:
|
||||||
- url: /hub/api
|
- url: /hub/api
|
||||||
security:
|
security:
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
# Common log messages emitted by JupyterHub
|
# Interpreting common log messages
|
||||||
|
|
||||||
When debugging errors and outages, looking at the logs emitted by
|
When debugging errors and outages, looking at the logs emitted by
|
||||||
JupyterHub is very helpful. This document tries to document some common
|
JupyterHub is very helpful. This document tries to document some common
|
||||||
@@ -35,3 +35,38 @@ URL, used by [jupyter-resource-usage](https://github.com/jupyter-server/jupyter-
|
|||||||
### Actions you can take
|
### Actions you can take
|
||||||
|
|
||||||
This log message is benign, and there is usually no action for you to take.
|
This log message is benign, and there is usually no action for you to take.
|
||||||
|
|
||||||
|
## JupyterHub Singleuser Version mismatch
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```
|
||||||
|
jupyterhub version 1.5.0 != jupyterhub-singleuser version 1.3.0. This could cause failure to authenticate and result in redirect loops!
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cause
|
||||||
|
|
||||||
|
JupyterHub requires the `jupyterhub` python package installed inside the image or
|
||||||
|
environment the user server starts in. This message indicates that the version of
|
||||||
|
the `jupyterhub` package installed inside the user image or environment is not
|
||||||
|
the same version as the JupyterHub server itself. This is not necessarily always a
|
||||||
|
problem - some version drift is mostly acceptable, and the only two known cases of
|
||||||
|
breakage are across the 0.7 and 2.0 version releases. In those cases, issues pop
|
||||||
|
up immediately after upgrading your version of JupyterHub, so **always check the JupyterHub
|
||||||
|
changelog before upgrading!**. The primary problems this _could_ cause are:
|
||||||
|
|
||||||
|
1. Infinite redirect loops after the user server starts
|
||||||
|
2. Missing expected environment variables in the user server once it starts
|
||||||
|
3. Failure for the started user server to authenticate with the JupyterHub server -
|
||||||
|
note that this is _not_ the same as _user authentication_ failing!
|
||||||
|
|
||||||
|
However, for the most part, unless you are seeing these specific issues, the log
|
||||||
|
message should be counted as a warning to get the `jupyterhub` package versions
|
||||||
|
aligned, rather than as an indicator of an existing problem.
|
||||||
|
|
||||||
|
### Actions you can take
|
||||||
|
|
||||||
|
Upgrade the version of the `jupyterhub` package in your user environment or image
|
||||||
|
so it matches the version of JupyterHub running your JupyterHub server! If you
|
||||||
|
are using the [zero-to-jupyterhub](https://z2jh.jupyter.org) helm chart, you can find the appropriate
|
||||||
|
version of the `jupyterhub` package to install in your user image [here](https://jupyterhub.github.io/helm-chart/)
|
||||||
|
File diff suppressed because one or more lines are too long
@@ -7,7 +7,7 @@ JupyterHub provides four roles that are available by default:
|
|||||||
```{admonition} **Default roles**
|
```{admonition} **Default roles**
|
||||||
- `user` role provides a {ref}`default user scope <default-user-scope-target>` `self` that grants access to the user's own resources.
|
- `user` role provides a {ref}`default user scope <default-user-scope-target>` `self` that grants access to the user's own resources.
|
||||||
- `admin` role contains all available scopes and grants full rights to all actions. This role **cannot be edited**.
|
- `admin` role contains all available scopes and grants full rights to all actions. This role **cannot be edited**.
|
||||||
- `token` role provides a {ref}`default token scope <default-token-scope-target>` `all` that resolves to the same permissions as the owner of the token has.
|
- `token` role provides a {ref}`default token scope <default-token-scope-target>` `inherit` that resolves to the same permissions as the owner of the token has.
|
||||||
- `server` role allows for posting activity of "itself" only.
|
- `server` role allows for posting activity of "itself" only.
|
||||||
|
|
||||||
**These roles cannot be deleted.**
|
**These roles cannot be deleted.**
|
||||||
|
@@ -38,7 +38,7 @@ By adding a scope to an existing role, all role bearers will gain the associated
|
|||||||
Metascopes do not follow the general scope syntax. Instead, a metascope resolves to a set of scopes, which can refer to different resources, based on their owning entity. In JupyterHub, there are currently two metascopes:
|
Metascopes do not follow the general scope syntax. Instead, a metascope resolves to a set of scopes, which can refer to different resources, based on their owning entity. In JupyterHub, there are currently two metascopes:
|
||||||
|
|
||||||
1. default user scope `self`, and
|
1. default user scope `self`, and
|
||||||
2. default token scope `all`.
|
2. default token scope `inherit`.
|
||||||
|
|
||||||
(default-user-scope-target)=
|
(default-user-scope-target)=
|
||||||
|
|
||||||
@@ -57,11 +57,11 @@ The `self` scope is only valid for user entities. In other cases (e.g., for serv
|
|||||||
|
|
||||||
### Default token scope
|
### Default token scope
|
||||||
|
|
||||||
The token metascope `all` covers the same scopes as the token owner's scopes during requests. For example, if a token owner has roles containing the scopes `read:groups` and `read:users`, the `all` scope resolves to the set of scopes `{read:groups, read:users}`.
|
The token metascope `inherit` causes the token to have the same permissions as the token's owner. For example, if a token owner has roles containing the scopes `read:groups` and `read:users`, the `inherit` scope resolves to the set of scopes `{read:groups, read:users}`.
|
||||||
|
|
||||||
If the token owner has default `user` role, the `all` scope resolves to `self`, which will subsequently be expanded to include all the user-specific scopes (or empty set in the case of services).
|
If the token owner has default `user` role, the `inherit` scope resolves to `self`, which will subsequently be expanded to include all the user-specific scopes (or empty set in the case of services).
|
||||||
|
|
||||||
If the token owner is a member of any group with roles, the group scopes will also be included in resolving the `all` scope.
|
If the token owner is a member of any group with roles, the group scopes will also be included in resolving the `inherit` scope.
|
||||||
|
|
||||||
(horizontal-filtering-target)=
|
(horizontal-filtering-target)=
|
||||||
|
|
||||||
|
@@ -49,6 +49,6 @@ API tokens can also be issued to users via API ([_/hub/token_](../reference/urls
|
|||||||
|
|
||||||
### With RBAC
|
### With RBAC
|
||||||
|
|
||||||
The RBAC framework allows for granting tokens different levels of permissions via scopes attached to roles. The 'only identify' purpose of the separate OAuth tokens is no longer required. API tokens can be used used for every action, including the login and authentication, for which an API token with no role (i.e., no scope in {ref}`available-scopes-target`) is used.
|
The RBAC framework allows for granting tokens different levels of permissions via scopes attached to roles. The 'only identify' purpose of the separate OAuth tokens is no longer required. API tokens can be used for every action, including the login and authentication, for which an API token with no role (i.e., no scope in {ref}`available-scopes-target`) is used.
|
||||||
|
|
||||||
OAuth tokens are therefore dropped from the Hub upgraded with the RBAC framework.
|
OAuth tokens are therefore dropped from the Hub upgraded with the RBAC framework.
|
||||||
|
@@ -37,6 +37,15 @@ object-assign
|
|||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** @license React v17.0.2
|
||||||
|
* react-jsx-runtime.production.min.js
|
||||||
|
*
|
||||||
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
|
||||||
/** @license React v17.0.2
|
/** @license React v17.0.2
|
||||||
* react.production.min.js
|
* react.production.min.js
|
||||||
*
|
*
|
||||||
|
@@ -43,10 +43,11 @@
|
|||||||
"lodash.debounce": "^4.0.8",
|
"lodash.debounce": "^4.0.8",
|
||||||
"prop-types": "^15.7.2",
|
"prop-types": "^15.7.2",
|
||||||
"react": "^17.0.1",
|
"react": "^17.0.1",
|
||||||
"react-bootstrap": "^1.4.0",
|
"react-bootstrap": "^2.1.1",
|
||||||
"react-dom": "^17.0.1",
|
"react-dom": "^17.0.1",
|
||||||
"react-icons": "^4.1.0",
|
"react-icons": "^4.1.0",
|
||||||
"react-multi-select-component": "^3.0.7",
|
"react-multi-select-component": "^3.0.7",
|
||||||
|
"react-object-table-viewer": "^1.0.7",
|
||||||
"react-redux": "^7.2.2",
|
"react-redux": "^7.2.2",
|
||||||
"react-router": "^5.2.0",
|
"react-router": "^5.2.0",
|
||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^5.2.0",
|
||||||
@@ -60,7 +61,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.5",
|
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.5",
|
||||||
"sinon": "^13.0.1",
|
|
||||||
"babel-jest": "^26.6.3",
|
"babel-jest": "^26.6.3",
|
||||||
"enzyme": "^3.11.0",
|
"enzyme": "^3.11.0",
|
||||||
"eslint": "^7.18.0",
|
"eslint": "^7.18.0",
|
||||||
@@ -68,6 +68,7 @@
|
|||||||
"eslint-plugin-react": "^7.22.0",
|
"eslint-plugin-react": "^7.22.0",
|
||||||
"identity-obj-proxy": "^3.0.0",
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"jest": "^26.6.3",
|
"jest": "^26.6.3",
|
||||||
"prettier": "^2.2.1"
|
"prettier": "^2.2.1",
|
||||||
|
"sinon": "^13.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -14,7 +14,7 @@ export const reducers = (state = initialState, action) => {
|
|||||||
return Object.assign({}, state, {
|
return Object.assign({}, state, {
|
||||||
user_page: action.value.page,
|
user_page: action.value.page,
|
||||||
user_data: action.value.data,
|
user_data: action.value.data,
|
||||||
name_filter: action.value.name_filter,
|
name_filter: action.value.name_filter || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Updates the client group model data and stores the page
|
// Updates the client group model data and stores the page
|
||||||
|
@@ -3,7 +3,17 @@ import regeneratorRuntime from "regenerator-runtime";
|
|||||||
import { useSelector, useDispatch } from "react-redux";
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
import { Button, Col, Row, FormControl } from "react-bootstrap";
|
import {
|
||||||
|
Button,
|
||||||
|
Col,
|
||||||
|
Row,
|
||||||
|
FormControl,
|
||||||
|
Card,
|
||||||
|
CardGroup,
|
||||||
|
Collapse,
|
||||||
|
} from "react-bootstrap";
|
||||||
|
import ReactObjectTableViewer from "react-object-table-viewer";
|
||||||
|
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa";
|
import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa";
|
||||||
|
|
||||||
@@ -40,6 +50,7 @@ const ServerDashboard = (props) => {
|
|||||||
var [errorAlert, setErrorAlert] = useState(null);
|
var [errorAlert, setErrorAlert] = useState(null);
|
||||||
var [sortMethod, setSortMethod] = useState(null);
|
var [sortMethod, setSortMethod] = useState(null);
|
||||||
var [disabledButtons, setDisabledButtons] = useState({});
|
var [disabledButtons, setDisabledButtons] = useState({});
|
||||||
|
const [collapseStates, setCollapseStates] = useState({});
|
||||||
|
|
||||||
var user_data = useSelector((state) => state.user_data),
|
var user_data = useSelector((state) => state.user_data),
|
||||||
user_page = useSelector((state) => state.user_page),
|
user_page = useSelector((state) => state.user_page),
|
||||||
@@ -189,6 +200,124 @@ const ServerDashboard = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ServerRowTable = ({ data }) => {
|
||||||
|
return (
|
||||||
|
<ReactObjectTableViewer
|
||||||
|
className="table-striped table-bordered"
|
||||||
|
style={{
|
||||||
|
padding: "3px 6px",
|
||||||
|
margin: "auto",
|
||||||
|
}}
|
||||||
|
keyStyle={{
|
||||||
|
padding: "4px",
|
||||||
|
}}
|
||||||
|
valueStyle={{
|
||||||
|
padding: "4px",
|
||||||
|
}}
|
||||||
|
data={data}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const serverRow = (user, server) => {
|
||||||
|
const { servers, ...userNoServers } = user;
|
||||||
|
const serverNameDash = server.name ? `-${server.name}` : "";
|
||||||
|
const userServerName = user.name + serverNameDash;
|
||||||
|
const open = collapseStates[userServerName] || false;
|
||||||
|
return [
|
||||||
|
<tr key={`${userServerName}-row`} className="user-row">
|
||||||
|
<td data-testid="user-row-name">
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
setCollapseStates({
|
||||||
|
...collapseStates,
|
||||||
|
[userServerName]: !open,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
aria-controls={`${userServerName}-collapse`}
|
||||||
|
aria-expanded={open}
|
||||||
|
data-testid={`${userServerName}-collapse-button`}
|
||||||
|
variant={open ? "secondary" : "primary"}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<span className="caret"></span>
|
||||||
|
</Button>{" "}
|
||||||
|
</span>
|
||||||
|
<span data-testid={`user-name-div-${userServerName}`}>
|
||||||
|
{user.name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td data-testid="user-row-admin">{user.admin ? "admin" : ""}</td>
|
||||||
|
|
||||||
|
<td data-testid="user-row-server">
|
||||||
|
{server.name ? (
|
||||||
|
<p className="text-secondary">{server.name}</p>
|
||||||
|
) : (
|
||||||
|
<p style={{ color: "lightgrey" }}>[MAIN]</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td data-testid="user-row-last-activity">
|
||||||
|
{server.last_activity ? timeSince(server.last_activity) : "Never"}
|
||||||
|
</td>
|
||||||
|
<td data-testid="user-row-server-activity">
|
||||||
|
{server.started ? (
|
||||||
|
// Stop Single-user server
|
||||||
|
<>
|
||||||
|
<StopServerButton serverName={server.name} userName={user.name} />
|
||||||
|
<AccessServerButton url={server.url} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Start Single-user server
|
||||||
|
<>
|
||||||
|
<StartServerButton
|
||||||
|
serverName={server.name}
|
||||||
|
userName={user.name}
|
||||||
|
style={{ marginRight: 20 }}
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
href={`${base_url}spawn/${user.name}${
|
||||||
|
server.name && "/" + server.name
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-xs"
|
||||||
|
style={{ marginRight: 20 }}
|
||||||
|
>
|
||||||
|
Spawn Page
|
||||||
|
</button>
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<EditUserCell user={user} />
|
||||||
|
</tr>,
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={6}
|
||||||
|
style={{ padding: 0 }}
|
||||||
|
data-testid={`${userServerName}-td`}
|
||||||
|
>
|
||||||
|
<Collapse in={open} data-testid={`${userServerName}-collapse`}>
|
||||||
|
<CardGroup
|
||||||
|
id={`${userServerName}-card-group`}
|
||||||
|
style={{ width: "100%", margin: "0 auto", float: "none" }}
|
||||||
|
>
|
||||||
|
<Card style={{ width: "100%", padding: 3, margin: "0 auto" }}>
|
||||||
|
<Card.Title>User</Card.Title>
|
||||||
|
<ServerRowTable data={userNoServers} />
|
||||||
|
</Card>
|
||||||
|
<Card style={{ width: "100%", padding: 3, margin: "0 auto" }}>
|
||||||
|
<Card.Title>Server</Card.Title>
|
||||||
|
<ServerRowTable data={server} />
|
||||||
|
</Card>
|
||||||
|
</CardGroup>
|
||||||
|
</Collapse>
|
||||||
|
</td>
|
||||||
|
</tr>,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
let servers = user_data.flatMap((user) => {
|
let servers = user_data.flatMap((user) => {
|
||||||
let userServers = Object.values({
|
let userServers = Object.values({
|
||||||
"": user.server || {},
|
"": user.server || {},
|
||||||
@@ -225,7 +354,7 @@ const ServerDashboard = (props) => {
|
|||||||
name="user_search"
|
name="user_search"
|
||||||
placeholder="Search users"
|
placeholder="Search users"
|
||||||
aria-label="user-search"
|
aria-label="user-search"
|
||||||
value={name_filter}
|
defaultValue={name_filter}
|
||||||
onChange={handleSearch}
|
onChange={handleSearch}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -234,7 +363,7 @@ const ServerDashboard = (props) => {
|
|||||||
<Link to="/groups">{"> Manage Groups"}</Link>
|
<Link to="/groups">{"> Manage Groups"}</Link>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
<table className="table table-striped table-bordered table-hover">
|
<table className="table table-bordered table-hover">
|
||||||
<thead className="admin-table-head">
|
<thead className="admin-table-head">
|
||||||
<tr>
|
<tr>
|
||||||
<th id="user-header">
|
<th id="user-header">
|
||||||
@@ -373,63 +502,7 @@ const ServerDashboard = (props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{servers.map(([user, server], i) => {
|
{servers.flatMap(([user, server]) => serverRow(user, server))}
|
||||||
server.name = server.name || "";
|
|
||||||
return (
|
|
||||||
<tr key={i + "row"} className="user-row">
|
|
||||||
<td data-testid="user-row-name">{user.name}</td>
|
|
||||||
<td data-testid="user-row-admin">
|
|
||||||
{user.admin ? "admin" : ""}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td data-testid="user-row-server">
|
|
||||||
{server.name ? (
|
|
||||||
<p class="text-secondary">{server.name}</p>
|
|
||||||
) : (
|
|
||||||
<p style={{ color: "lightgrey" }}>[MAIN]</p>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td data-testid="user-row-last-activity">
|
|
||||||
{server.last_activity
|
|
||||||
? timeSince(server.last_activity)
|
|
||||||
: "Never"}
|
|
||||||
</td>
|
|
||||||
<td data-testid="user-row-server-activity">
|
|
||||||
{server.started ? (
|
|
||||||
// Stop Single-user server
|
|
||||||
<>
|
|
||||||
<StopServerButton
|
|
||||||
serverName={server.name}
|
|
||||||
userName={user.name}
|
|
||||||
/>
|
|
||||||
<AccessServerButton url={server.url} />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
// Start Single-user server
|
|
||||||
<>
|
|
||||||
<StartServerButton
|
|
||||||
serverName={server.name}
|
|
||||||
userName={user.name}
|
|
||||||
/>
|
|
||||||
<a
|
|
||||||
href={`${base_url}spawn/${user.name}${
|
|
||||||
server.name && "/" + server.name
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
className="btn btn-secondary btn-xs"
|
|
||||||
style={{ marginRight: 20 }}
|
|
||||||
>
|
|
||||||
Spawn Page
|
|
||||||
</button>
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<EditUserCell user={user} />
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<PaginationFooter
|
<PaginationFooter
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import { act } from "react-dom/test-utils";
|
import { act } from "react-dom/test-utils";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { HashRouter, Switch } from "react-router-dom";
|
import { HashRouter, Switch } from "react-router-dom";
|
||||||
import { Provider, useSelector } from "react-redux";
|
import { Provider, useSelector } from "react-redux";
|
||||||
@@ -76,8 +77,8 @@ test("Renders users from props.user_data into table", async () => {
|
|||||||
render(serverDashboardJsx(callbackSpy));
|
render(serverDashboardJsx(callbackSpy));
|
||||||
});
|
});
|
||||||
|
|
||||||
let foo = screen.getByText("foo");
|
let foo = screen.getByTestId("user-name-div-foo");
|
||||||
let bar = screen.getByText("bar");
|
let bar = screen.getByTestId("user-name-div-bar");
|
||||||
|
|
||||||
expect(foo).toBeVisible();
|
expect(foo).toBeVisible();
|
||||||
expect(bar).toBeVisible();
|
expect(bar).toBeVisible();
|
||||||
@@ -156,12 +157,12 @@ test("Sorts according to username", async () => {
|
|||||||
fireEvent.click(handler);
|
fireEvent.click(handler);
|
||||||
|
|
||||||
let first = screen.getAllByTestId("user-row-name")[0];
|
let first = screen.getAllByTestId("user-row-name")[0];
|
||||||
expect(first.textContent).toBe("bar");
|
expect(first.textContent).toContain("bar");
|
||||||
|
|
||||||
fireEvent.click(handler);
|
fireEvent.click(handler);
|
||||||
|
|
||||||
first = screen.getAllByTestId("user-row-name")[0];
|
first = screen.getAllByTestId("user-row-name")[0];
|
||||||
expect(first.textContent).toBe("foo");
|
expect(first.textContent).toContain("foo");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Sorts according to admin", async () => {
|
test("Sorts according to admin", async () => {
|
||||||
@@ -194,12 +195,12 @@ test("Sorts according to last activity", async () => {
|
|||||||
fireEvent.click(handler);
|
fireEvent.click(handler);
|
||||||
|
|
||||||
let first = screen.getAllByTestId("user-row-name")[0];
|
let first = screen.getAllByTestId("user-row-name")[0];
|
||||||
expect(first.textContent).toBe("foo");
|
expect(first.textContent).toContain("foo");
|
||||||
|
|
||||||
fireEvent.click(handler);
|
fireEvent.click(handler);
|
||||||
|
|
||||||
first = screen.getAllByTestId("user-row-name")[0];
|
first = screen.getAllByTestId("user-row-name")[0];
|
||||||
expect(first.textContent).toBe("bar");
|
expect(first.textContent).toContain("bar");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Sorts according to server status (running/not running)", async () => {
|
test("Sorts according to server status (running/not running)", async () => {
|
||||||
@@ -213,12 +214,53 @@ test("Sorts according to server status (running/not running)", async () => {
|
|||||||
fireEvent.click(handler);
|
fireEvent.click(handler);
|
||||||
|
|
||||||
let first = screen.getAllByTestId("user-row-name")[0];
|
let first = screen.getAllByTestId("user-row-name")[0];
|
||||||
expect(first.textContent).toBe("foo");
|
expect(first.textContent).toContain("foo");
|
||||||
|
|
||||||
fireEvent.click(handler);
|
fireEvent.click(handler);
|
||||||
|
|
||||||
first = screen.getAllByTestId("user-row-name")[0];
|
first = screen.getAllByTestId("user-row-name")[0];
|
||||||
expect(first.textContent).toBe("bar");
|
expect(first.textContent).toContain("bar");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Shows server details with button click", async () => {
|
||||||
|
let callbackSpy = mockAsync();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(serverDashboardJsx(callbackSpy));
|
||||||
|
});
|
||||||
|
let button = screen.getByTestId("foo-collapse-button");
|
||||||
|
let collapse = screen.getByTestId("foo-collapse");
|
||||||
|
let collapseBar = screen.getByTestId("bar-collapse");
|
||||||
|
|
||||||
|
// expect().toBeVisible does not work here with collapse.
|
||||||
|
expect(collapse).toHaveClass("collapse");
|
||||||
|
expect(collapse).not.toHaveClass("show");
|
||||||
|
expect(collapseBar).not.toHaveClass("show");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(button);
|
||||||
|
});
|
||||||
|
clock.tick(400);
|
||||||
|
|
||||||
|
expect(collapse).toHaveClass("collapse show");
|
||||||
|
expect(collapseBar).not.toHaveClass("show");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(button);
|
||||||
|
});
|
||||||
|
clock.tick(400);
|
||||||
|
|
||||||
|
expect(collapse).toHaveClass("collapse");
|
||||||
|
expect(collapse).not.toHaveClass("show");
|
||||||
|
expect(collapseBar).not.toHaveClass("show");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(button);
|
||||||
|
});
|
||||||
|
clock.tick(400);
|
||||||
|
|
||||||
|
expect(collapse).toHaveClass("collapse show");
|
||||||
|
expect(collapseBar).not.toHaveClass("show");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Renders nothing if required data is not available", async () => {
|
test("Renders nothing if required data is not available", async () => {
|
||||||
@@ -467,15 +509,15 @@ test("Search for user calls updateUsers with name filter", async () => {
|
|||||||
|
|
||||||
let search = screen.getByLabelText("user-search");
|
let search = screen.getByLabelText("user-search");
|
||||||
|
|
||||||
fireEvent.change(search, { target: { value: "a" } });
|
userEvent.type(search, "a");
|
||||||
clock.tick(400);
|
|
||||||
expect(mockUpdateUsers.mock.calls).toHaveLength(2);
|
|
||||||
expect(mockUpdateUsers.mock.calls[1][2]).toEqual("a");
|
|
||||||
expect(search.value).toEqual("a");
|
expect(search.value).toEqual("a");
|
||||||
|
|
||||||
fireEvent.change(search, { target: { value: "ab" } });
|
|
||||||
clock.tick(400);
|
clock.tick(400);
|
||||||
expect(mockUpdateUsers.mock.calls).toHaveLength(3);
|
expect(mockUpdateUsers.mock.calls[1][2]).toEqual("a");
|
||||||
expect(mockUpdateUsers.mock.calls[2][2]).toEqual("ab");
|
expect(mockUpdateUsers.mock.calls).toHaveLength(2);
|
||||||
|
|
||||||
|
userEvent.type(search, "b");
|
||||||
expect(search.value).toEqual("ab");
|
expect(search.value).toEqual("ab");
|
||||||
|
clock.tick(400);
|
||||||
|
expect(mockUpdateUsers.mock.calls[2][2]).toEqual("ab");
|
||||||
|
expect(mockUpdateUsers.mock.calls).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
@@ -4,7 +4,7 @@ import { jhapiRequest } from "./jhapiUtil";
|
|||||||
const withAPI = withProps(() => ({
|
const withAPI = withProps(() => ({
|
||||||
updateUsers: (offset, limit, name_filter) =>
|
updateUsers: (offset, limit, name_filter) =>
|
||||||
jhapiRequest(
|
jhapiRequest(
|
||||||
`/users?offset=${offset}&limit=${limit}&name_filter=${name_filter}`,
|
`/users?offset=${offset}&limit=${limit}&name_filter=${name_filter || ""}`,
|
||||||
"GET"
|
"GET"
|
||||||
).then((data) => data.json()),
|
).then((data) => data.json()),
|
||||||
updateGroups: (offset, limit) =>
|
updateGroups: (offset, limit) =>
|
||||||
|
@@ -928,7 +928,7 @@
|
|||||||
"@babel/plugin-transform-react-jsx-development" "^7.16.7"
|
"@babel/plugin-transform-react-jsx-development" "^7.16.7"
|
||||||
"@babel/plugin-transform-react-pure-annotations" "^7.16.7"
|
"@babel/plugin-transform-react-pure-annotations" "^7.16.7"
|
||||||
|
|
||||||
"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.8", "@babel/runtime@^7.14.0", "@babel/runtime@^7.15.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2":
|
"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.16", "@babel/runtime@^7.15.4", "@babel/runtime@^7.17.2", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2":
|
||||||
version "7.17.7"
|
version "7.17.7"
|
||||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.7.tgz#a5f3328dc41ff39d803f311cfe17703418cf9825"
|
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.7.tgz#a5f3328dc41ff39d803f311cfe17703418cf9825"
|
||||||
integrity sha512-L6rvG9GDxaLgFjg41K+5Yv9OMrU98sWe+Ykmc6FDJW/+vYZMhdOMKkISgzptMaERHvS2Y2lw9MDRm2gHhlQQoA==
|
integrity sha512-L6rvG9GDxaLgFjg41K+5Yv9OMrU98sWe+Ykmc6FDJW/+vYZMhdOMKkISgzptMaERHvS2Y2lw9MDRm2gHhlQQoA==
|
||||||
@@ -1215,23 +1215,41 @@
|
|||||||
"@jridgewell/resolve-uri" "^3.0.3"
|
"@jridgewell/resolve-uri" "^3.0.3"
|
||||||
"@jridgewell/sourcemap-codec" "^1.4.10"
|
"@jridgewell/sourcemap-codec" "^1.4.10"
|
||||||
|
|
||||||
"@popperjs/core@^2.8.6":
|
"@popperjs/core@^2.10.1":
|
||||||
version "2.11.4"
|
version "2.11.4"
|
||||||
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.4.tgz#d8c7b8db9226d2d7664553a0741ad7d0397ee503"
|
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.4.tgz#d8c7b8db9226d2d7664553a0741ad7d0397ee503"
|
||||||
integrity sha512-q/ytXxO5NKvyT37pmisQAItCFqA7FD/vNb8dgaJy3/630Fsc+Mz9/9f2SziBoIZ30TJooXyTwZmhi1zjXmObYg==
|
integrity sha512-q/ytXxO5NKvyT37pmisQAItCFqA7FD/vNb8dgaJy3/630Fsc+Mz9/9f2SziBoIZ30TJooXyTwZmhi1zjXmObYg==
|
||||||
|
|
||||||
"@restart/context@^2.1.4":
|
"@react-aria/ssr@^3.0.1":
|
||||||
version "2.1.4"
|
version "3.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/@restart/context/-/context-2.1.4.tgz#a99d87c299a34c28bd85bb489cb07bfd23149c02"
|
resolved "https://registry.yarnpkg.com/@react-aria/ssr/-/ssr-3.1.2.tgz#665a6fd56385068c7417922af2d0d71b0618e52d"
|
||||||
integrity sha512-INJYZQJP7g+IoDUh/475NlGiTeMfwTXUEr3tmRneckHIxNolGOW9CTq83S8cxq0CgJwwcMzMJFchxvlwe7Rk8Q==
|
integrity sha512-amXY11ImpokvkTMeKRHjsSsG7v1yzzs6yeqArCyBIk60J3Yhgxwx9Cah+Uu/804ATFwqzN22AXIo7SdtIaMP+g==
|
||||||
|
dependencies:
|
||||||
|
"@babel/runtime" "^7.6.2"
|
||||||
|
|
||||||
"@restart/hooks@^0.3.26":
|
"@restart/hooks@^0.4.0", "@restart/hooks@^0.4.5":
|
||||||
version "0.3.27"
|
version "0.4.5"
|
||||||
resolved "https://registry.yarnpkg.com/@restart/hooks/-/hooks-0.3.27.tgz#91f356d66d4699a8cd8b3d008402708b6a9dc505"
|
resolved "https://registry.yarnpkg.com/@restart/hooks/-/hooks-0.4.5.tgz#e7acbea237bfc9e479970500cf87538b41a1ed02"
|
||||||
integrity sha512-s984xV/EapUIfkjlf8wz9weP2O9TNKR96C68FfMEy2bE69+H4cNv3RD4Mf97lW7Htt7PjZrYTjSC8f3SB9VCXw==
|
integrity sha512-tLGtY0aHeIfT7aPwUkvQuhIy3+q3w4iqmUzFLPlOAf/vNUacLaBt1j/S//jv/dQhenRh8jvswyMojCwmLvJw8A==
|
||||||
dependencies:
|
dependencies:
|
||||||
dequal "^2.0.2"
|
dequal "^2.0.2"
|
||||||
|
|
||||||
|
"@restart/ui@^1.0.2":
|
||||||
|
version "1.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@restart/ui/-/ui-1.0.2.tgz#009a06ae698d624672c5e6a776efd0e8a6017842"
|
||||||
|
integrity sha512-vKGe0UBJLnbvNAjr8ljlDvphf2HkpjBjXsblmgKPvKdZBDn/mtAz89wmznaomIaEJ9VNoSEY0vA5T5MDi2jIcQ==
|
||||||
|
dependencies:
|
||||||
|
"@babel/runtime" "^7.13.16"
|
||||||
|
"@popperjs/core" "^2.10.1"
|
||||||
|
"@react-aria/ssr" "^3.0.1"
|
||||||
|
"@restart/hooks" "^0.4.0"
|
||||||
|
"@types/warning" "^3.0.0"
|
||||||
|
dequal "^2.0.2"
|
||||||
|
dom-helpers "^5.2.0"
|
||||||
|
prop-types "^15.7.2"
|
||||||
|
uncontrollable "^7.2.1"
|
||||||
|
warning "^4.0.3"
|
||||||
|
|
||||||
"@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.8.3":
|
"@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.8.3":
|
||||||
version "1.8.3"
|
version "1.8.3"
|
||||||
resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d"
|
resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d"
|
||||||
@@ -1399,7 +1417,7 @@
|
|||||||
"@types/react" "*"
|
"@types/react" "*"
|
||||||
hoist-non-react-statics "^3.3.0"
|
hoist-non-react-statics "^3.3.0"
|
||||||
|
|
||||||
"@types/invariant@^2.2.33":
|
"@types/invariant@^2.2.35":
|
||||||
version "2.2.35"
|
version "2.2.35"
|
||||||
resolved "https://registry.yarnpkg.com/@types/invariant/-/invariant-2.2.35.tgz#cd3ebf581a6557452735688d8daba6cf0bd5a3be"
|
resolved "https://registry.yarnpkg.com/@types/invariant/-/invariant-2.2.35.tgz#cd3ebf581a6557452735688d8daba6cf0bd5a3be"
|
||||||
integrity sha512-DxX1V9P8zdJPYQat1gHyY0xj3efl8gnMVjiM9iCY6y27lj+PoQWkgjt8jDqmovPqULkKVpKRg8J36iQiA+EtEg==
|
integrity sha512-DxX1V9P8zdJPYQat1gHyY0xj3efl8gnMVjiM9iCY6y27lj+PoQWkgjt8jDqmovPqULkKVpKRg8J36iQiA+EtEg==
|
||||||
@@ -1456,7 +1474,7 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.4.4.tgz#5d9b63132df54d8909fce1c3f8ca260fdd693e17"
|
resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.4.4.tgz#5d9b63132df54d8909fce1c3f8ca260fdd693e17"
|
||||||
integrity sha512-ReVR2rLTV1kvtlWFyuot+d1pkpG2Fw/XKE3PDAdj57rbM97ttSp9JZ2UsP+2EHTylra9cUf6JA7tGwW1INzUrA==
|
integrity sha512-ReVR2rLTV1kvtlWFyuot+d1pkpG2Fw/XKE3PDAdj57rbM97ttSp9JZ2UsP+2EHTylra9cUf6JA7tGwW1INzUrA==
|
||||||
|
|
||||||
"@types/prop-types@*", "@types/prop-types@^15.7.3":
|
"@types/prop-types@*", "@types/prop-types@^15.7.4":
|
||||||
version "15.7.4"
|
version "15.7.4"
|
||||||
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.4.tgz#fcf7205c25dff795ee79af1e30da2c9790808f11"
|
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.4.tgz#fcf7205c25dff795ee79af1e30da2c9790808f11"
|
||||||
integrity sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==
|
integrity sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==
|
||||||
@@ -1478,7 +1496,7 @@
|
|||||||
hoist-non-react-statics "^3.3.0"
|
hoist-non-react-statics "^3.3.0"
|
||||||
redux "^4.0.0"
|
redux "^4.0.0"
|
||||||
|
|
||||||
"@types/react-transition-group@^4.4.1":
|
"@types/react-transition-group@^4.4.4":
|
||||||
version "4.4.4"
|
version "4.4.4"
|
||||||
resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.4.tgz#acd4cceaa2be6b757db61ed7b432e103242d163e"
|
resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.4.tgz#acd4cceaa2be6b757db61ed7b432e103242d163e"
|
||||||
integrity sha512-7gAPz7anVK5xzbeQW9wFBDg7G++aPLAFY0QaSMOou9rJZpbuI58WAuJrgu+qR92l61grlnCUe7AFX8KGahAgug==
|
integrity sha512-7gAPz7anVK5xzbeQW9wFBDg7G++aPLAFY0QaSMOou9rJZpbuI58WAuJrgu+qR92l61grlnCUe7AFX8KGahAgug==
|
||||||
@@ -6167,30 +6185,29 @@ raw-body@2.4.3:
|
|||||||
iconv-lite "0.4.24"
|
iconv-lite "0.4.24"
|
||||||
unpipe "1.0.0"
|
unpipe "1.0.0"
|
||||||
|
|
||||||
react-bootstrap@^1.4.0:
|
react-bootstrap@^2.1.1:
|
||||||
version "1.6.4"
|
version "2.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/react-bootstrap/-/react-bootstrap-1.6.4.tgz#94d5d2422e26bba277656d3529128e14f838b7ca"
|
resolved "https://registry.yarnpkg.com/react-bootstrap/-/react-bootstrap-2.2.1.tgz#2a6ad0931e9367882ec3fc88a70ed0b8ace90b26"
|
||||||
integrity sha512-z3BhBD4bEZuLP8VrYqAD7OT7axdcSkkyvWBWnS2U/4MhyabUihrUyucPWkan7aMI1XIHbmH4LCpEtzWGfx/yfA==
|
integrity sha512-x8lpVQflsbevphuWbTnTNCatcbKyPJNrP2WyQ1MJYmFEcVjbTbai1yZhdlXr0QUxLQLxA8g5hQWb5TwJtaZoCA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@babel/runtime" "^7.14.0"
|
"@babel/runtime" "^7.17.2"
|
||||||
"@restart/context" "^2.1.4"
|
"@restart/hooks" "^0.4.5"
|
||||||
"@restart/hooks" "^0.3.26"
|
"@restart/ui" "^1.0.2"
|
||||||
"@types/invariant" "^2.2.33"
|
"@types/invariant" "^2.2.35"
|
||||||
"@types/prop-types" "^15.7.3"
|
"@types/prop-types" "^15.7.4"
|
||||||
"@types/react" ">=16.14.8"
|
"@types/react" ">=16.14.8"
|
||||||
"@types/react-transition-group" "^4.4.1"
|
"@types/react-transition-group" "^4.4.4"
|
||||||
"@types/warning" "^3.0.0"
|
"@types/warning" "^3.0.0"
|
||||||
classnames "^2.3.1"
|
classnames "^2.3.1"
|
||||||
dom-helpers "^5.2.1"
|
dom-helpers "^5.2.1"
|
||||||
invariant "^2.2.4"
|
invariant "^2.2.4"
|
||||||
prop-types "^15.7.2"
|
prop-types "^15.8.1"
|
||||||
prop-types-extra "^1.1.0"
|
prop-types-extra "^1.1.0"
|
||||||
react-overlays "^5.1.1"
|
react-transition-group "^4.4.2"
|
||||||
react-transition-group "^4.4.1"
|
|
||||||
uncontrollable "^7.2.1"
|
uncontrollable "^7.2.1"
|
||||||
warning "^4.0.3"
|
warning "^4.0.3"
|
||||||
|
|
||||||
react-dom@^17.0.1:
|
react-dom@17.0.2, react-dom@^17.0.1:
|
||||||
version "17.0.2"
|
version "17.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"
|
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"
|
||||||
integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==
|
integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==
|
||||||
@@ -6226,19 +6243,13 @@ react-multi-select-component@^3.0.7:
|
|||||||
dependencies:
|
dependencies:
|
||||||
goober "^2.0.30"
|
goober "^2.0.30"
|
||||||
|
|
||||||
react-overlays@^5.1.1:
|
react-object-table-viewer@^1.0.7:
|
||||||
version "5.1.1"
|
version "1.0.7"
|
||||||
resolved "https://registry.yarnpkg.com/react-overlays/-/react-overlays-5.1.1.tgz#2e7cf49744b56537c7828ccb94cfc63dd778ae4f"
|
resolved "https://registry.yarnpkg.com/react-object-table-viewer/-/react-object-table-viewer-1.0.7.tgz#31816021fa4526641c6b66bd9433ec9b78c2e472"
|
||||||
integrity sha512-eCN2s2/+GVZzpnId4XVWtvDPYYBD2EtOGP74hE+8yDskPzFy9+pV1H3ZZihxuRdEbQzzacySaaDkR7xE0ydl4Q==
|
integrity sha512-OezCet8+BmEdJJHO5WGPFPRWXxw4Ls6HsV4Uh1kRPlmRXLOTNqWt/ZHmH8NhTl1BA9HkdhEegKVqc2b61wDMLg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@babel/runtime" "^7.13.8"
|
react "^17.0.2"
|
||||||
"@popperjs/core" "^2.8.6"
|
react-dom "17.0.2"
|
||||||
"@restart/hooks" "^0.3.26"
|
|
||||||
"@types/warning" "^3.0.0"
|
|
||||||
dom-helpers "^5.2.0"
|
|
||||||
prop-types "^15.7.2"
|
|
||||||
uncontrollable "^7.2.1"
|
|
||||||
warning "^4.0.3"
|
|
||||||
|
|
||||||
react-redux@^7.2.2:
|
react-redux@^7.2.2:
|
||||||
version "7.2.6"
|
version "7.2.6"
|
||||||
@@ -6299,7 +6310,7 @@ react-test-renderer@^17.0.0:
|
|||||||
react-shallow-renderer "^16.13.1"
|
react-shallow-renderer "^16.13.1"
|
||||||
scheduler "^0.20.2"
|
scheduler "^0.20.2"
|
||||||
|
|
||||||
react-transition-group@^4.4.1:
|
react-transition-group@^4.4.2:
|
||||||
version "4.4.2"
|
version "4.4.2"
|
||||||
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.2.tgz#8b59a56f09ced7b55cbd53c36768b922890d5470"
|
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.2.tgz#8b59a56f09ced7b55cbd53c36768b922890d5470"
|
||||||
integrity sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==
|
integrity sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==
|
||||||
@@ -6309,7 +6320,7 @@ react-transition-group@^4.4.1:
|
|||||||
loose-envify "^1.4.0"
|
loose-envify "^1.4.0"
|
||||||
prop-types "^15.6.2"
|
prop-types "^15.6.2"
|
||||||
|
|
||||||
react@^17.0.1:
|
react@^17.0.1, react@^17.0.2:
|
||||||
version "17.0.2"
|
version "17.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
|
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
|
||||||
integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==
|
integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==
|
||||||
|
@@ -2,7 +2,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 = (2, 3, 0, "", "dev")
|
version_info = (2, 3, 1, "", "")
|
||||||
|
|
||||||
# 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
|
||||||
|
@@ -50,7 +50,7 @@ class GroupListAPIHandler(_GroupAPIHandler):
|
|||||||
# the only valid filter is group=...
|
# the only valid filter is group=...
|
||||||
# don't expand invalid !server=x to all groups!
|
# don't expand invalid !server=x to all groups!
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
"Invalid filter on list:group for {self.current_user}: {sub_scope}"
|
f"Invalid filter on list:group for {self.current_user}: {sub_scope}"
|
||||||
)
|
)
|
||||||
raise web.HTTPError(403)
|
raise web.HTTPError(403)
|
||||||
query = query.filter(orm.Group.name.in_(sub_scope['group']))
|
query = query.filter(orm.Group.name.in_(sub_scope['group']))
|
||||||
|
@@ -47,9 +47,8 @@ class ShutdownAPIHandler(APIHandler):
|
|||||||
self.set_status(202)
|
self.set_status(202)
|
||||||
self.finish(json.dumps({"message": "Shutting down Hub"}))
|
self.finish(json.dumps({"message": "Shutting down Hub"}))
|
||||||
|
|
||||||
# stop the eventloop, which will trigger cleanup
|
# instruct the app to stop, which will trigger cleanup
|
||||||
loop = IOLoop.current()
|
app.stop()
|
||||||
loop.add_callback(loop.stop)
|
|
||||||
|
|
||||||
|
|
||||||
class RootAPIHandler(APIHandler):
|
class RootAPIHandler(APIHandler):
|
||||||
|
@@ -131,7 +131,7 @@ class UserListAPIHandler(APIHandler):
|
|||||||
if not set(sub_scope).issubset({'group', 'user'}):
|
if not set(sub_scope).issubset({'group', 'user'}):
|
||||||
# don't expand invalid !server=x filter to all users!
|
# don't expand invalid !server=x filter to all users!
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
"Invalid filter on list:user for {self.current_user}: {sub_scope}"
|
f"Invalid filter on list:user for {self.current_user}: {sub_scope}"
|
||||||
)
|
)
|
||||||
raise web.HTTPError(403)
|
raise web.HTTPError(403)
|
||||||
filters = []
|
filters = []
|
||||||
|
@@ -1689,7 +1689,9 @@ class JupyterHub(Application):
|
|||||||
for authority, files in self.internal_ssl_authorities.items():
|
for authority, files in self.internal_ssl_authorities.items():
|
||||||
if files:
|
if files:
|
||||||
self.log.info("Adding CA for %s", authority)
|
self.log.info("Adding CA for %s", authority)
|
||||||
certipy.store.add_record(authority, is_ca=True, files=files)
|
certipy.store.add_record(
|
||||||
|
authority, is_ca=True, files=files, overwrite=True
|
||||||
|
)
|
||||||
|
|
||||||
self.internal_trust_bundles = certipy.trust_from_graph(
|
self.internal_trust_bundles = certipy.trust_from_graph(
|
||||||
self.internal_ssl_components_trust
|
self.internal_ssl_components_trust
|
||||||
@@ -3241,9 +3243,15 @@ class JupyterHub(Application):
|
|||||||
loop.make_current()
|
loop.make_current()
|
||||||
loop.run_sync(self.cleanup)
|
loop.run_sync(self.cleanup)
|
||||||
|
|
||||||
async def shutdown_cancel_tasks(self, sig):
|
async def shutdown_cancel_tasks(self, sig=None):
|
||||||
"""Cancel all other tasks of the event loop and initiate cleanup"""
|
"""Cancel all other tasks of the event loop and initiate cleanup"""
|
||||||
self.log.critical("Received signal %s, initiating shutdown...", sig.name)
|
if sig is None:
|
||||||
|
self.log.critical("Initiating shutdown...")
|
||||||
|
else:
|
||||||
|
self.log.critical("Received signal %s, initiating shutdown...", sig.name)
|
||||||
|
|
||||||
|
await self.cleanup()
|
||||||
|
|
||||||
tasks = [t for t in asyncio_all_tasks() if t is not asyncio_current_task()]
|
tasks = [t for t in asyncio_all_tasks() if t is not asyncio_current_task()]
|
||||||
|
|
||||||
if tasks:
|
if tasks:
|
||||||
@@ -3260,7 +3268,6 @@ class JupyterHub(Application):
|
|||||||
tasks = [t for t in asyncio_all_tasks()]
|
tasks = [t for t in asyncio_all_tasks()]
|
||||||
for t in tasks:
|
for t in tasks:
|
||||||
self.log.debug("Task status: %s", t)
|
self.log.debug("Task status: %s", t)
|
||||||
await self.cleanup()
|
|
||||||
asyncio.get_event_loop().stop()
|
asyncio.get_event_loop().stop()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
@@ -3268,7 +3275,7 @@ class JupyterHub(Application):
|
|||||||
return
|
return
|
||||||
if self.http_server:
|
if self.http_server:
|
||||||
self.http_server.stop()
|
self.http_server.stop()
|
||||||
self.io_loop.add_callback(self.io_loop.stop)
|
self.io_loop.add_callback(self.shutdown_cancel_tasks)
|
||||||
|
|
||||||
async def start_show_config(self):
|
async def start_show_config(self):
|
||||||
"""Async wrapper around base start_show_config method"""
|
"""Async wrapper around base start_show_config method"""
|
||||||
|
@@ -529,7 +529,7 @@ class BaseHandler(RequestHandler):
|
|||||||
# clear_cookie only accepts a subset of set_cookie's kwargs
|
# clear_cookie only accepts a subset of set_cookie's kwargs
|
||||||
clear_xsrf_cookie_kwargs = {
|
clear_xsrf_cookie_kwargs = {
|
||||||
key: value
|
key: value
|
||||||
for key, value in self.settings.get('xsrf_cookie_kwargs', {})
|
for key, value in self.settings.get('xsrf_cookie_kwargs', {}).items()
|
||||||
if key in {"path", "domain"}
|
if key in {"path", "domain"}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -642,29 +642,32 @@ class BaseHandler(RequestHandler):
|
|||||||
next_url = next_url.replace('\\', '%5C')
|
next_url = next_url.replace('\\', '%5C')
|
||||||
proto = get_browser_protocol(self.request)
|
proto = get_browser_protocol(self.request)
|
||||||
host = self.request.host
|
host = self.request.host
|
||||||
|
if next_url.startswith("///"):
|
||||||
|
# strip more than 2 leading // down to 2
|
||||||
|
# because urlparse treats that as empty netloc,
|
||||||
|
# whereas browsers treat more than two leading // the same as //,
|
||||||
|
# so netloc is the first non-/ bit
|
||||||
|
next_url = "//" + next_url.lstrip("/")
|
||||||
|
parsed_next_url = urlparse(next_url)
|
||||||
|
|
||||||
if (next_url + '/').startswith((f'{proto}://{host}/', f'//{host}/',)) or (
|
if (next_url + '/').startswith((f'{proto}://{host}/', f'//{host}/',)) or (
|
||||||
self.subdomain_host
|
self.subdomain_host
|
||||||
and urlparse(next_url).netloc
|
and parsed_next_url.netloc
|
||||||
and ("." + urlparse(next_url).netloc).endswith(
|
and ("." + parsed_next_url.netloc).endswith(
|
||||||
"." + urlparse(self.subdomain_host).netloc
|
"." + urlparse(self.subdomain_host).netloc
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
# treat absolute URLs for our host as absolute paths:
|
# treat absolute URLs for our host as absolute paths:
|
||||||
# below, redirects that aren't strictly paths
|
# below, redirects that aren't strictly paths are rejected
|
||||||
parsed = urlparse(next_url)
|
next_url = parsed_next_url.path
|
||||||
next_url = parsed.path
|
if parsed_next_url.query:
|
||||||
if parsed.query:
|
next_url = next_url + '?' + parsed_next_url.query
|
||||||
next_url = next_url + '?' + parsed.query
|
if parsed_next_url.fragment:
|
||||||
if parsed.fragment:
|
next_url = next_url + '#' + parsed_next_url.fragment
|
||||||
next_url = next_url + '#' + parsed.fragment
|
parsed_next_url = urlparse(next_url)
|
||||||
|
|
||||||
# if it still has host info, it didn't match our above check for *this* host
|
# if it still has host info, it didn't match our above check for *this* host
|
||||||
if next_url and (
|
if next_url and (parsed_next_url.netloc or not next_url.startswith('/')):
|
||||||
'://' in next_url
|
|
||||||
or next_url.startswith('//')
|
|
||||||
or not next_url.startswith('/')
|
|
||||||
):
|
|
||||||
self.log.warning("Disallowing redirect outside JupyterHub: %r", next_url)
|
self.log.warning("Disallowing redirect outside JupyterHub: %r", next_url)
|
||||||
next_url = ''
|
next_url = ''
|
||||||
|
|
||||||
|
@@ -498,7 +498,7 @@ class TokenPageHandler(BaseHandler):
|
|||||||
continue
|
continue
|
||||||
if not token.client_id:
|
if not token.client_id:
|
||||||
# token should have been deleted when client was deleted
|
# token should have been deleted when client was deleted
|
||||||
self.log.warning("Deleting stale oauth token {token}")
|
self.log.warning(f"Deleting stale oauth token {token}")
|
||||||
self.db.delete(token)
|
self.db.delete(token)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
continue
|
continue
|
||||||
|
@@ -536,9 +536,7 @@ class Hashed(Expiring):
|
|||||||
prefix = token[: cls.prefix_length]
|
prefix = token[: cls.prefix_length]
|
||||||
# since we can't filter on hashed values, filter on prefix
|
# since we can't filter on hashed values, filter on prefix
|
||||||
# so we aren't comparing with all tokens
|
# so we aren't comparing with all tokens
|
||||||
prefix_match = db.query(cls).filter(
|
prefix_match = db.query(cls).filter_by(prefix=prefix)
|
||||||
bindparam('prefix', prefix).startswith(cls.prefix)
|
|
||||||
)
|
|
||||||
prefix_match = prefix_match.filter(
|
prefix_match = prefix_match.filter(
|
||||||
or_(cls.expires_at == None, cls.expires_at >= cls.now())
|
or_(cls.expires_at == None, cls.expires_at >= cls.now())
|
||||||
)
|
)
|
||||||
|
@@ -29,9 +29,9 @@ else:
|
|||||||
try:
|
try:
|
||||||
App = import_item(JUPYTERHUB_SINGLEUSER_APP)
|
App = import_item(JUPYTERHUB_SINGLEUSER_APP)
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
continue
|
|
||||||
if _import_error is None:
|
if _import_error is None:
|
||||||
_import_error = e
|
_import_error = e
|
||||||
|
continue
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
if App is None:
|
if App is None:
|
||||||
|
@@ -182,6 +182,7 @@ page_template = """
|
|||||||
|
|
||||||
<span>
|
<span>
|
||||||
<a href='{{hub_control_panel_url}}'
|
<a href='{{hub_control_panel_url}}'
|
||||||
|
id='jupyterhub-control-panel-link'
|
||||||
class='btn btn-default btn-sm navbar-btn pull-right'
|
class='btn btn-default btn-sm navbar-btn pull-right'
|
||||||
style='margin-right: 4px; margin-left: 2px;'>
|
style='margin-right: 4px; margin-left: 2px;'>
|
||||||
Control Panel
|
Control Panel
|
||||||
@@ -633,8 +634,15 @@ class SingleUserNotebookAppMixin(Configurable):
|
|||||||
# disable trash by default
|
# disable trash by default
|
||||||
# this can be re-enabled by config
|
# this can be re-enabled by config
|
||||||
self.config.FileContentsManager.delete_to_trash = False
|
self.config.FileContentsManager.delete_to_trash = False
|
||||||
|
# load default-url env at higher priority than `@default`,
|
||||||
|
# which may have their own _defaults_ which should not override explicit default_url config
|
||||||
|
# via e.g. c.Spawner.default_url. Seen in jupyterlab's SingleUserLabApp.
|
||||||
|
default_url = os.environ.get("JUPYTERHUB_DEFAULT_URL")
|
||||||
|
if default_url:
|
||||||
|
self.config[self.__class__.__name__].default_url = default_url
|
||||||
self._log_app_versions()
|
self._log_app_versions()
|
||||||
return super().initialize(argv)
|
super().initialize(argv)
|
||||||
|
self.patch_templates()
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
self.log.info("Starting jupyterhub-singleuser server version %s", __version__)
|
self.log.info("Starting jupyterhub-singleuser server version %s", __version__)
|
||||||
@@ -705,7 +713,6 @@ class SingleUserNotebookAppMixin(Configurable):
|
|||||||
|
|
||||||
# apply X-JupyterHub-Version to *all* request handlers (even redirects)
|
# apply X-JupyterHub-Version to *all* request handlers (even redirects)
|
||||||
self.patch_default_headers()
|
self.patch_default_headers()
|
||||||
self.patch_templates()
|
|
||||||
|
|
||||||
def page_config_hook(self, handler, page_config):
|
def page_config_hook(self, handler, page_config):
|
||||||
"""JupyterLab page config hook
|
"""JupyterLab page config hook
|
||||||
@@ -738,19 +745,30 @@ class SingleUserNotebookAppMixin(Configurable):
|
|||||||
)
|
)
|
||||||
self.jinja_template_vars['hub_host'] = self.hub_host
|
self.jinja_template_vars['hub_host'] = self.hub_host
|
||||||
self.jinja_template_vars['hub_prefix'] = self.hub_prefix
|
self.jinja_template_vars['hub_prefix'] = self.hub_prefix
|
||||||
env = self.web_app.settings['jinja2_env']
|
self.jinja_template_vars[
|
||||||
|
'hub_control_panel_url'
|
||||||
|
] = self.hub_host + url_path_join(self.hub_prefix, 'home')
|
||||||
|
|
||||||
env.globals['hub_control_panel_url'] = self.hub_host + url_path_join(
|
settings = self.web_app.settings
|
||||||
self.hub_prefix, 'home'
|
# patch classic notebook jinja env
|
||||||
)
|
jinja_envs = []
|
||||||
|
if 'jinja2_env' in settings:
|
||||||
|
# default jinja env (should we do this on jupyter-server, or only notebook?)
|
||||||
|
jinja_envs.append(settings['jinja2_env'])
|
||||||
|
if 'notebook_jinja2_env' in settings:
|
||||||
|
# when running with jupyter-server, classic notebook (nbclassic server extension)
|
||||||
|
# gets its own jinja env, which needs the same patch
|
||||||
|
jinja_envs.append(settings['notebook_jinja2_env'])
|
||||||
|
|
||||||
# patch jinja env loading to modify page template
|
# patch jinja env loading to get modified template, only for base page.html
|
||||||
def get_page(name):
|
def get_page(name):
|
||||||
if name == 'page.html':
|
if name == 'page.html':
|
||||||
return page_template
|
return page_template
|
||||||
|
|
||||||
orig_loader = env.loader
|
for jinja_env in jinja_envs:
|
||||||
env.loader = ChoiceLoader([FunctionLoader(get_page), orig_loader])
|
jinja_env.loader = ChoiceLoader(
|
||||||
|
[FunctionLoader(get_page), jinja_env.loader]
|
||||||
|
)
|
||||||
|
|
||||||
def load_server_extensions(self):
|
def load_server_extensions(self):
|
||||||
# Loading LabApp sets $JUPYTERHUB_API_TOKEN on load, which is incorrect
|
# Loading LabApp sets $JUPYTERHUB_API_TOKEN on load, which is incorrect
|
||||||
|
@@ -97,10 +97,15 @@ class Spawner(LoggingConfigurable):
|
|||||||
|
|
||||||
Used in logging for consistency with named servers.
|
Used in logging for consistency with named servers.
|
||||||
"""
|
"""
|
||||||
if self.name:
|
if self.user:
|
||||||
return f'{self.user.name}:{self.name}'
|
user_name = self.user.name
|
||||||
else:
|
else:
|
||||||
return self.user.name
|
# no user, only happens in mock tests
|
||||||
|
user_name = "(no user)"
|
||||||
|
if self.name:
|
||||||
|
return f"{user_name}:{self.name}"
|
||||||
|
else:
|
||||||
|
return user_name
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _failed(self):
|
def _failed(self):
|
||||||
@@ -228,7 +233,7 @@ class Spawner(LoggingConfigurable):
|
|||||||
self.orm_spawner.server = server.orm_server
|
self.orm_spawner.server = server.orm_server
|
||||||
elif server is not None:
|
elif server is not None:
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
"Setting Spawner.server for {self._log_name} with no underlying orm_spawner"
|
f"Setting Spawner.server for {self._log_name} with no underlying orm_spawner"
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -871,9 +876,6 @@ class Spawner(LoggingConfigurable):
|
|||||||
|
|
||||||
if self.server:
|
if self.server:
|
||||||
base_url = self.server.base_url
|
base_url = self.server.base_url
|
||||||
if self.ip or self.port:
|
|
||||||
self.server.ip = self.ip
|
|
||||||
self.server.port = self.port
|
|
||||||
env['JUPYTERHUB_SERVICE_PREFIX'] = self.server.base_url
|
env['JUPYTERHUB_SERVICE_PREFIX'] = self.server.base_url
|
||||||
else:
|
else:
|
||||||
# this should only occur in mock/testing scenarios
|
# this should only occur in mock/testing scenarios
|
||||||
|
@@ -57,12 +57,14 @@ from .utils import add_user
|
|||||||
_db = None
|
_db = None
|
||||||
|
|
||||||
|
|
||||||
def pytest_collection_modifyitems(items):
|
def _pytest_collection_modifyitems(items):
|
||||||
"""This function is automatically run by pytest passing all collected test
|
"""This function is automatically run by pytest passing all collected test
|
||||||
functions.
|
functions.
|
||||||
|
|
||||||
We use it to add asyncio marker to all async tests and assert we don't use
|
We use it to add asyncio marker to all async tests and assert we don't use
|
||||||
test functions that are async generators which wouldn't make sense.
|
test functions that are async generators which wouldn't make sense.
|
||||||
|
|
||||||
|
It is no longer required with pytest-asyncio >= 0.17
|
||||||
"""
|
"""
|
||||||
for item in items:
|
for item in items:
|
||||||
if inspect.iscoroutinefunction(item.obj):
|
if inspect.iscoroutinefunction(item.obj):
|
||||||
@@ -70,6 +72,13 @@ def pytest_collection_modifyitems(items):
|
|||||||
assert not inspect.isasyncgenfunction(item.obj)
|
assert not inspect.isasyncgenfunction(item.obj)
|
||||||
|
|
||||||
|
|
||||||
|
if sys.version_info < (3, 7):
|
||||||
|
# apply pytest-asyncio's 'auto' mode on Python 3.6.
|
||||||
|
# 'auto' mode is new in pytest-asyncio 0.17,
|
||||||
|
# which requires Python 3.7.
|
||||||
|
pytest_collection_modifyitems = _pytest_collection_modifyitems
|
||||||
|
|
||||||
|
|
||||||
@fixture(scope='module')
|
@fixture(scope='module')
|
||||||
def ssl_tmpdir(tmpdir_factory):
|
def ssl_tmpdir(tmpdir_factory):
|
||||||
return tmpdir_factory.mktemp('ssl')
|
return tmpdir_factory.mktemp('ssl')
|
||||||
@@ -182,6 +191,8 @@ def cleanup_after(request, io_loop):
|
|||||||
if not MockHub.initialized():
|
if not MockHub.initialized():
|
||||||
return
|
return
|
||||||
app = MockHub.instance()
|
app = MockHub.instance()
|
||||||
|
if app.db_file.closed:
|
||||||
|
return
|
||||||
for uid, user in list(app.users.items()):
|
for uid, user in list(app.users.items()):
|
||||||
for name, spawner in list(user.spawners.items()):
|
for name, spawner in list(user.spawners.items()):
|
||||||
if spawner.active:
|
if spawner.active:
|
||||||
|
@@ -333,26 +333,28 @@ class MockHub(JupyterHub):
|
|||||||
roles.assign_default_roles(self.db, entity=user)
|
roles.assign_default_roles(self.db, entity=user)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
def stop(self):
|
_stop_called = False
|
||||||
super().stop()
|
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
if self._stop_called:
|
||||||
|
return
|
||||||
|
self._stop_called = True
|
||||||
# run cleanup in a background thread
|
# run cleanup in a background thread
|
||||||
# to avoid multiple eventloops in the same thread errors from asyncio
|
# to avoid multiple eventloops in the same thread errors from asyncio
|
||||||
|
|
||||||
def cleanup():
|
def cleanup():
|
||||||
asyncio.set_event_loop(asyncio.new_event_loop())
|
loop = asyncio.new_event_loop()
|
||||||
loop = IOLoop.current()
|
loop.run_until_complete(self.cleanup())
|
||||||
loop.run_sync(self.cleanup)
|
|
||||||
loop.close()
|
loop.close()
|
||||||
|
|
||||||
pool = ThreadPoolExecutor(1)
|
with ThreadPoolExecutor(1) as pool:
|
||||||
f = pool.submit(cleanup)
|
f = pool.submit(cleanup)
|
||||||
# wait for cleanup to finish
|
# wait for cleanup to finish
|
||||||
f.result()
|
f.result()
|
||||||
pool.shutdown()
|
|
||||||
|
|
||||||
# ignore the call that will fire in atexit
|
# prevent redundant atexit from running
|
||||||
self.cleanup = lambda: None
|
self._atexit_ran = True
|
||||||
|
super().stop()
|
||||||
self.db_file.close()
|
self.db_file.close()
|
||||||
|
|
||||||
async def login_user(self, name):
|
async def login_user(self, name):
|
||||||
|
@@ -2104,14 +2104,23 @@ def test_shutdown(app):
|
|||||||
)
|
)
|
||||||
return r
|
return r
|
||||||
|
|
||||||
real_stop = loop.stop
|
real_stop = loop.asyncio_loop.stop
|
||||||
|
|
||||||
def stop():
|
def stop():
|
||||||
stop.called = True
|
stop.called = True
|
||||||
loop.call_later(1, real_stop)
|
loop.call_later(1, real_stop)
|
||||||
|
|
||||||
with mock.patch.object(loop, 'stop', stop):
|
real_cleanup = app.cleanup
|
||||||
|
|
||||||
|
def cleanup():
|
||||||
|
cleanup.called = True
|
||||||
|
return real_cleanup()
|
||||||
|
|
||||||
|
app.cleanup = cleanup
|
||||||
|
|
||||||
|
with mock.patch.object(loop.asyncio_loop, 'stop', stop):
|
||||||
r = loop.run_sync(shutdown, timeout=5)
|
r = loop.run_sync(shutdown, timeout=5)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
reply = r.json()
|
reply = r.json()
|
||||||
|
assert cleanup.called
|
||||||
assert stop.called
|
assert stop.called
|
||||||
|
@@ -15,7 +15,6 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import traitlets
|
import traitlets
|
||||||
from distutils.version import LooseVersion as V
|
|
||||||
from traitlets.config import Config
|
from traitlets.config import Config
|
||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
@@ -33,7 +32,7 @@ def test_help_all():
|
|||||||
assert '--JupyterHub.ip' in out
|
assert '--JupyterHub.ip' in out
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(V(traitlets.__version__) < V('5'), reason="requires traitlets 5")
|
@pytest.mark.skipif(traitlets.version_info < (5,), reason="requires traitlets 5")
|
||||||
def test_show_config(tmpdir):
|
def test_show_config(tmpdir):
|
||||||
tmpdir.chdir()
|
tmpdir.chdir()
|
||||||
p = Popen(
|
p = Popen(
|
||||||
|
@@ -771,6 +771,10 @@ async def test_login_strip(app):
|
|||||||
(False, '/user/other', '/hub/user/other', None),
|
(False, '/user/other', '/hub/user/other', None),
|
||||||
(False, '/absolute', '/absolute', None),
|
(False, '/absolute', '/absolute', None),
|
||||||
(False, '/has?query#andhash', '/has?query#andhash', None),
|
(False, '/has?query#andhash', '/has?query#andhash', None),
|
||||||
|
# :// in query string or fragment
|
||||||
|
(False, '/has?repo=https/host.git', '/has?repo=https/host.git', None),
|
||||||
|
(False, '/has?repo=https://host.git', '/has?repo=https://host.git', None),
|
||||||
|
(False, '/has#repo=https://host.git', '/has#repo=https://host.git', None),
|
||||||
# next_url outside is not allowed
|
# next_url outside is not allowed
|
||||||
(False, 'relative/path', '', None),
|
(False, 'relative/path', '', None),
|
||||||
(False, 'https://other.domain', '', None),
|
(False, 'https://other.domain', '', None),
|
||||||
@@ -810,7 +814,9 @@ async def test_login_redirect(app, running, next_url, location, params):
|
|||||||
if params:
|
if params:
|
||||||
url = url_concat(url, params)
|
url = url_concat(url, params)
|
||||||
if next_url:
|
if next_url:
|
||||||
if '//' not in next_url and next_url.startswith('/'):
|
if next_url.startswith('/') and not (
|
||||||
|
next_url.startswith("//") or urlparse(next_url).netloc
|
||||||
|
):
|
||||||
next_url = ujoin(app.base_url, next_url, '')
|
next_url = ujoin(app.base_url, next_url, '')
|
||||||
url = url_concat(url, dict(next=next_url))
|
url = url_concat(url, dict(next=next_url))
|
||||||
|
|
||||||
|
@@ -5,9 +5,11 @@ from contextlib import contextmanager
|
|||||||
from subprocess import CalledProcessError
|
from subprocess import CalledProcessError
|
||||||
from subprocess import check_output
|
from subprocess import check_output
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
from urllib.parse import urlencode
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
import jupyterhub
|
import jupyterhub
|
||||||
from .. import orm
|
from .. import orm
|
||||||
@@ -16,6 +18,7 @@ from .mocking import public_url
|
|||||||
from .mocking import StubSingleUserSpawner
|
from .mocking import StubSingleUserSpawner
|
||||||
from .utils import async_requests
|
from .utils import async_requests
|
||||||
from .utils import AsyncSession
|
from .utils import AsyncSession
|
||||||
|
from .utils import get_page
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
@@ -225,3 +228,22 @@ def test_singleuser_app_class(JUPYTERHUB_SINGLEUSER_APP):
|
|||||||
else:
|
else:
|
||||||
assert '--ServerApp.' in out
|
assert '--ServerApp.' in out
|
||||||
assert '--NotebookApp.' not in out
|
assert '--NotebookApp.' not in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_nbclassic_control_panel(app, user):
|
||||||
|
# use StubSingleUserSpawner to launch a single-user app in a thread
|
||||||
|
app.spawner_class = StubSingleUserSpawner
|
||||||
|
app.tornado_settings['spawner_class'] = StubSingleUserSpawner
|
||||||
|
|
||||||
|
# login, start the server
|
||||||
|
await user.spawn()
|
||||||
|
cookies = await app.login_user(user.name)
|
||||||
|
next_url = url_path_join(user.url, "tree/")
|
||||||
|
url = '/?' + urlencode({'next': next_url})
|
||||||
|
r = await get_page(url, app, cookies=cookies)
|
||||||
|
r.raise_for_status()
|
||||||
|
assert urlparse(r.url).path == urlparse(next_url).path
|
||||||
|
page = BeautifulSoup(r.text, "html.parser")
|
||||||
|
link = page.find("a", id="jupyterhub-control-panel-link")
|
||||||
|
assert link, f"Missing jupyterhub-control-panel-link in {page}"
|
||||||
|
assert link["href"] == url_path_join(app.base_url, "hub/home")
|
||||||
|
@@ -282,9 +282,9 @@ class User:
|
|||||||
new_groups = set(group_names).difference(current_groups)
|
new_groups = set(group_names).difference(current_groups)
|
||||||
removed_groups = current_groups.difference(group_names)
|
removed_groups = current_groups.difference(group_names)
|
||||||
if new_groups:
|
if new_groups:
|
||||||
self.log.info("Adding user {self.name} to group(s): {new_groups}")
|
self.log.info(f"Adding user {self.name} to group(s): {new_groups}")
|
||||||
if removed_groups:
|
if removed_groups:
|
||||||
self.log.info("Removing user {self.name} from group(s): {removed_groups}")
|
self.log.info(f"Removing user {self.name} from group(s): {removed_groups}")
|
||||||
|
|
||||||
if group_names:
|
if group_names:
|
||||||
groups = (
|
groups = (
|
||||||
@@ -812,7 +812,7 @@ class User:
|
|||||||
e.reason = 'timeout'
|
e.reason = 'timeout'
|
||||||
self.settings['statsd'].incr('spawner.failure.timeout')
|
self.settings['statsd'].incr('spawner.failure.timeout')
|
||||||
else:
|
else:
|
||||||
self.log.error(
|
self.log.exception(
|
||||||
"Unhandled error starting {user}'s server: {error}".format(
|
"Unhandled error starting {user}'s server: {error}".format(
|
||||||
user=self.name, error=e
|
user=self.name, error=e
|
||||||
)
|
)
|
||||||
@@ -822,7 +822,7 @@ class User:
|
|||||||
try:
|
try:
|
||||||
await self.stop(spawner.name)
|
await self.stop(spawner.name)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.log.error(
|
self.log.exception(
|
||||||
"Failed to cleanup {user}'s server that failed to start".format(
|
"Failed to cleanup {user}'s server that failed to start".format(
|
||||||
user=self.name
|
user=self.name
|
||||||
),
|
),
|
||||||
@@ -870,7 +870,7 @@ class User:
|
|||||||
self.settings['statsd'].incr('spawner.failure.http_timeout')
|
self.settings['statsd'].incr('spawner.failure.http_timeout')
|
||||||
else:
|
else:
|
||||||
e.reason = 'error'
|
e.reason = 'error'
|
||||||
self.log.error(
|
self.log.exception(
|
||||||
"Unhandled error waiting for {user}'s server to show up at {url}: {error}".format(
|
"Unhandled error waiting for {user}'s server to show up at {url}: {error}".format(
|
||||||
user=self.name, url=server.url, error=e
|
user=self.name, url=server.url, error=e
|
||||||
)
|
)
|
||||||
@@ -879,7 +879,7 @@ class User:
|
|||||||
try:
|
try:
|
||||||
await self.stop(spawner.name)
|
await self.stop(spawner.name)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.log.error(
|
self.log.exception(
|
||||||
"Failed to cleanup {user}'s server that failed to start".format(
|
"Failed to cleanup {user}'s server that failed to start".format(
|
||||||
user=self.name
|
user=self.name
|
||||||
),
|
),
|
||||||
|
@@ -1,9 +1,13 @@
|
|||||||
[tool.black]
|
[tool.black]
|
||||||
skip-string-normalization = true
|
skip-string-normalization = true
|
||||||
|
# target-version should be all supported versions, see
|
||||||
|
# https://github.com/psf/black/issues/751#issuecomment-473066811
|
||||||
target_version = [
|
target_version = [
|
||||||
"py36",
|
"py36",
|
||||||
"py37",
|
"py37",
|
||||||
"py38",
|
"py38",
|
||||||
|
"py39",
|
||||||
|
"py310",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.tbump]
|
[tool.tbump]
|
||||||
@@ -11,7 +15,7 @@ target_version = [
|
|||||||
github_url = "https://github.com/jupyterhub/jupyterhub"
|
github_url = "https://github.com/jupyterhub/jupyterhub"
|
||||||
|
|
||||||
[tool.tbump.version]
|
[tool.tbump.version]
|
||||||
current = "2.3.0.dev"
|
current = "2.3.1"
|
||||||
|
|
||||||
# Example of a semver regexp.
|
# Example of a semver regexp.
|
||||||
# Make sure this matches current_version before
|
# Make sure this matches current_version before
|
||||||
|
@@ -3,6 +3,9 @@
|
|||||||
# so we have to disable this until pytest 3.11
|
# so we have to disable this until pytest 3.11
|
||||||
# minversion = 3.3
|
# minversion = 3.3
|
||||||
|
|
||||||
|
# automatically run coroutine tests with asyncio
|
||||||
|
asyncio_mode = auto
|
||||||
|
|
||||||
# jupyter_server plugin is incompatible with notebook imports
|
# jupyter_server plugin is incompatible with notebook imports
|
||||||
addopts = -p no:jupyter_server
|
addopts = -p no:jupyter_server
|
||||||
|
|
||||||
|
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user