mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-19 07:53:00 +00:00
Merge branch 'jupyterhub:main' into group_property_feature
This commit is contained in:
@@ -10,6 +10,14 @@ import "./server-dashboard.css";
|
|||||||
import { timeSince } from "../../util/timeSince";
|
import { timeSince } from "../../util/timeSince";
|
||||||
import PaginationFooter from "../PaginationFooter/PaginationFooter";
|
import PaginationFooter from "../PaginationFooter/PaginationFooter";
|
||||||
|
|
||||||
|
const AccessServerButton = ({ userName, serverName }) => (
|
||||||
|
<a href={`/user/${userName}/${serverName || ""}`}>
|
||||||
|
<button className="btn btn-primary btn-xs" style={{ marginRight: 20 }}>
|
||||||
|
Access Server
|
||||||
|
</button>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
|
||||||
const ServerDashboard = (props) => {
|
const ServerDashboard = (props) => {
|
||||||
// sort methods
|
// sort methods
|
||||||
var usernameDesc = (e) => e.sort((a, b) => (a.name > b.name ? 1 : -1)),
|
var usernameDesc = (e) => e.sort((a, b) => (a.name > b.name ? 1 : -1)),
|
||||||
@@ -29,6 +37,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 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),
|
||||||
@@ -72,6 +81,101 @@ const ServerDashboard = (props) => {
|
|||||||
user_data = sortMethod(user_data);
|
user_data = sortMethod(user_data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const StopServerButton = ({ serverName, userName }) => {
|
||||||
|
var [isDisabled, setIsDisabled] = useState(false);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="btn btn-danger btn-xs stop-button"
|
||||||
|
disabled={isDisabled}
|
||||||
|
onClick={() => {
|
||||||
|
setIsDisabled(true);
|
||||||
|
stopServer(userName, serverName)
|
||||||
|
.then((res) => {
|
||||||
|
if (res.status < 300) {
|
||||||
|
updateUsers(...slice)
|
||||||
|
.then((data) => {
|
||||||
|
dispatchPageUpdate(data, page);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setIsDisabled(false);
|
||||||
|
setErrorAlert(`Failed to update users list.`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setErrorAlert(`Failed to stop server.`);
|
||||||
|
setIsDisabled(false);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setErrorAlert(`Failed to stop server.`);
|
||||||
|
setIsDisabled(false);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Stop Server
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StartServerButton = ({ serverName, userName }) => {
|
||||||
|
var [isDisabled, setIsDisabled] = useState(false);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="btn btn-success btn-xs start-button"
|
||||||
|
disabled={isDisabled}
|
||||||
|
onClick={() => {
|
||||||
|
setIsDisabled(true);
|
||||||
|
startServer(userName, serverName)
|
||||||
|
.then((res) => {
|
||||||
|
if (res.status < 300) {
|
||||||
|
updateUsers(...slice)
|
||||||
|
.then((data) => {
|
||||||
|
dispatchPageUpdate(data, page);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setErrorAlert(`Failed to update users list.`);
|
||||||
|
setIsDisabled(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setErrorAlert(`Failed to start server.`);
|
||||||
|
setIsDisabled(false);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setErrorAlert(`Failed to start server.`);
|
||||||
|
setIsDisabled(false);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Start Server
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditUserCell = ({ user, numServers, serverName }) => {
|
||||||
|
if (serverName) return null;
|
||||||
|
return (
|
||||||
|
<td rowspan={numServers}>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-xs"
|
||||||
|
style={{ marginRight: 20 }}
|
||||||
|
onClick={() =>
|
||||||
|
history.push({
|
||||||
|
pathname: "/edit-user",
|
||||||
|
state: {
|
||||||
|
username: user.name,
|
||||||
|
has_admin: user.admin,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Edit User
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container" data-testid="container">
|
<div className="container" data-testid="container">
|
||||||
{errorAlert != null ? (
|
{errorAlert != null ? (
|
||||||
@@ -115,6 +219,14 @@ const ServerDashboard = (props) => {
|
|||||||
testid="admin-sort"
|
testid="admin-sort"
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
|
<th id="server-header">
|
||||||
|
Server{" "}
|
||||||
|
<SortHandler
|
||||||
|
sorts={{ asc: usernameAsc, desc: usernameDesc }}
|
||||||
|
callback={(method) => setSortMethod(() => method)}
|
||||||
|
testid="server-sort"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
<th id="last-activity-header">
|
<th id="last-activity-header">
|
||||||
Last Activity{" "}
|
Last Activity{" "}
|
||||||
<SortHandler
|
<SortHandler
|
||||||
@@ -227,88 +339,88 @@ const ServerDashboard = (props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{user_data.map((e, i) => (
|
{user_data.flatMap((e, i) => {
|
||||||
<tr key={i + "row"} className="user-row">
|
let userServers = Object.values({
|
||||||
<td data-testid="user-row-name">{e.name}</td>
|
"": e.server,
|
||||||
<td data-testid="user-row-admin">{e.admin ? "admin" : ""}</td>
|
...(e.servers || {}),
|
||||||
<td data-testid="user-row-last-activity">
|
});
|
||||||
{e.last_activity ? timeSince(e.last_activity) : "Never"}
|
return userServers.map((server) => {
|
||||||
</td>
|
server = { name: "", ...server };
|
||||||
<td data-testid="user-row-server-activity">
|
return (
|
||||||
{e.server != null ? (
|
<tr key={i + "row"} className="user-row">
|
||||||
// Stop Single-user server
|
{!server.name && (
|
||||||
<button
|
<td
|
||||||
className="btn btn-danger btn-xs stop-button"
|
data-testid="user-row-name"
|
||||||
onClick={() =>
|
rowspan={userServers.length}
|
||||||
stopServer(e.name)
|
>
|
||||||
.then((res) => {
|
{e.name}
|
||||||
if (res.status < 300) {
|
</td>
|
||||||
updateUsers(...slice)
|
)}
|
||||||
.then((data) => {
|
{!server.name && (
|
||||||
dispatchPageUpdate(data, page);
|
<td
|
||||||
})
|
data-testid="user-row-admin"
|
||||||
.catch(() =>
|
rowspan={userServers.length}
|
||||||
setErrorAlert(`Failed to update users list.`)
|
>
|
||||||
);
|
{e.admin ? "admin" : ""}
|
||||||
} else {
|
</td>
|
||||||
setErrorAlert(`Failed to stop server.`);
|
)}
|
||||||
}
|
|
||||||
return res;
|
<td data-testid="user-row-server">
|
||||||
})
|
{server.name ? (
|
||||||
.catch(() => setErrorAlert(`Failed to stop server.`))
|
<p class="text-secondary">{server.name}</p>
|
||||||
}
|
) : (
|
||||||
>
|
<p style={{ color: "lightgrey" }}>[MAIN]</p>
|
||||||
Stop Server
|
)}
|
||||||
</button>
|
</td>
|
||||||
) : (
|
<td data-testid="user-row-last-activity">
|
||||||
// Start Single-user server
|
{server.last_activity
|
||||||
<button
|
? timeSince(server.last_activity)
|
||||||
className="btn btn-primary btn-xs start-button"
|
: "Never"}
|
||||||
onClick={() =>
|
</td>
|
||||||
startServer(e.name)
|
<td data-testid="user-row-server-activity">
|
||||||
.then((res) => {
|
{server.started ? (
|
||||||
if (res.status < 300) {
|
// Stop Single-user server
|
||||||
updateUsers(...slice)
|
<>
|
||||||
.then((data) => {
|
<StopServerButton
|
||||||
dispatchPageUpdate(data, page);
|
serverName={server.name}
|
||||||
})
|
userName={e.name}
|
||||||
.catch(() =>
|
/>
|
||||||
setErrorAlert(`Failed to update users list.`)
|
<AccessServerButton
|
||||||
);
|
serverName={server.name}
|
||||||
} else {
|
userName={e.name}
|
||||||
setErrorAlert(`Failed to start server.`);
|
/>
|
||||||
}
|
</>
|
||||||
return res;
|
) : (
|
||||||
})
|
// Start Single-user server
|
||||||
.catch(() => {
|
<>
|
||||||
setErrorAlert(`Failed to start server.`);
|
<StartServerButton
|
||||||
})
|
serverName={server.name}
|
||||||
}
|
userName={e.name}
|
||||||
>
|
/>
|
||||||
Start Server
|
<a
|
||||||
</button>
|
href={`/spawn/${e.name}${
|
||||||
)}
|
server.name && "/" + server.name
|
||||||
</td>
|
}`}
|
||||||
<td>
|
>
|
||||||
{/* Edit User */}
|
<button
|
||||||
<button
|
className="btn btn-secondary btn-xs"
|
||||||
className="btn btn-primary btn-xs"
|
style={{ marginRight: 20 }}
|
||||||
style={{ marginRight: 20 }}
|
>
|
||||||
onClick={() =>
|
Spawn Page
|
||||||
history.push({
|
</button>
|
||||||
pathname: "/edit-user",
|
</a>
|
||||||
state: {
|
</>
|
||||||
username: e.name,
|
)}
|
||||||
has_admin: e.admin,
|
</td>
|
||||||
},
|
<EditUserCell
|
||||||
})
|
user={e}
|
||||||
}
|
numServers={userServers.length}
|
||||||
>
|
serverName={server.name}
|
||||||
edit user
|
/>
|
||||||
</button>
|
</tr>
|
||||||
</td>
|
);
|
||||||
</tr>
|
});
|
||||||
))}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<PaginationFooter
|
<PaginationFooter
|
||||||
|
@@ -11,8 +11,10 @@ const withAPI = withProps(() => ({
|
|||||||
(data) => data.json()
|
(data) => data.json()
|
||||||
),
|
),
|
||||||
shutdownHub: () => jhapiRequest("/shutdown", "POST"),
|
shutdownHub: () => jhapiRequest("/shutdown", "POST"),
|
||||||
startServer: (name) => jhapiRequest("/users/" + name + "/server", "POST"),
|
startServer: (name, serverName = "") =>
|
||||||
stopServer: (name) => jhapiRequest("/users/" + name + "/server", "DELETE"),
|
jhapiRequest("/users/" + name + "/servers/" + (serverName || ""), "POST"),
|
||||||
|
stopServer: (name, serverName = "") =>
|
||||||
|
jhapiRequest("/users/" + name + "/servers/" + (serverName || ""), "DELETE"),
|
||||||
startAll: (names) =>
|
startAll: (names) =>
|
||||||
names.map((e) => jhapiRequest("/users/" + e + "/server", "POST")),
|
names.map((e) => jhapiRequest("/users/" + e + "/server", "POST")),
|
||||||
stopAll: (names) =>
|
stopAll: (names) =>
|
||||||
|
@@ -3664,9 +3664,9 @@ flatted@^3.1.0:
|
|||||||
integrity sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==
|
integrity sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==
|
||||||
|
|
||||||
follow-redirects@^1.0.0:
|
follow-redirects@^1.0.0:
|
||||||
version "1.14.7"
|
version "1.14.8"
|
||||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.7.tgz#2004c02eb9436eee9a21446a6477debf17e81685"
|
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.8.tgz#016996fb9a11a100566398b1c6839337d7bfa8fc"
|
||||||
integrity sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ==
|
integrity sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA==
|
||||||
|
|
||||||
for-in@^1.0.2:
|
for-in@^1.0.2:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
|
@@ -975,16 +975,24 @@ class PAMAuthenticator(LocalAuthenticator):
|
|||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
open_sessions = Bool(
|
open_sessions = Bool(
|
||||||
True,
|
False,
|
||||||
help="""
|
help="""
|
||||||
Whether to open a new PAM session when spawners are started.
|
Whether to open a new PAM session when spawners are started.
|
||||||
|
|
||||||
This may trigger things like mounting shared filsystems,
|
This may trigger things like mounting shared filesystems,
|
||||||
loading credentials, etc. depending on system configuration,
|
loading credentials, etc. depending on system configuration.
|
||||||
but it does not always work.
|
|
||||||
|
The lifecycle of PAM sessions is not correct,
|
||||||
|
so many PAM session configurations will not work.
|
||||||
|
|
||||||
If any errors are encountered when opening/closing PAM sessions,
|
If any errors are encountered when opening/closing PAM sessions,
|
||||||
this is automatically set to False.
|
this is automatically set to False.
|
||||||
|
|
||||||
|
.. versionchanged:: 2.2
|
||||||
|
|
||||||
|
Due to longstanding problems in the session lifecycle,
|
||||||
|
this is now disabled by default.
|
||||||
|
You may opt-in to opening sessions by setting this to True.
|
||||||
""",
|
""",
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
|
@@ -255,7 +255,7 @@ class SpawnHandler(BaseHandler):
|
|||||||
self.log.debug(
|
self.log.debug(
|
||||||
"Triggering spawn with supplied form options for %s", spawner._log_name
|
"Triggering spawn with supplied form options for %s", spawner._log_name
|
||||||
)
|
)
|
||||||
options = await maybe_future(spawner.options_from_form(form_options))
|
options = await maybe_future(spawner.run_options_from_form(form_options))
|
||||||
pending_url = self._get_pending_url(user, server_name)
|
pending_url = self._get_pending_url(user, server_name)
|
||||||
return await self._wrap_spawn_single_user(
|
return await self._wrap_spawn_single_user(
|
||||||
user, server_name, spawner, pending_url, options
|
user, server_name, spawner, pending_url, options
|
||||||
|
@@ -403,6 +403,10 @@ def _token_allowed_role(db, token, role):
|
|||||||
if owner is None:
|
if owner is None:
|
||||||
raise ValueError(f"Owner not found for {token}")
|
raise ValueError(f"Owner not found for {token}")
|
||||||
|
|
||||||
|
if role in owner.roles:
|
||||||
|
# shortcut: token is assigned an exact role the owner has
|
||||||
|
return True
|
||||||
|
|
||||||
expanded_scopes = _get_subscopes(role, owner=owner)
|
expanded_scopes = _get_subscopes(role, owner=owner)
|
||||||
|
|
||||||
implicit_permissions = {'inherit', 'read:inherit'}
|
implicit_permissions = {'inherit', 'read:inherit'}
|
||||||
|
@@ -11,6 +11,7 @@ import shutil
|
|||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import warnings
|
import warnings
|
||||||
|
from inspect import signature
|
||||||
from subprocess import Popen
|
from subprocess import Popen
|
||||||
from tempfile import mkdtemp
|
from tempfile import mkdtemp
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -424,6 +425,13 @@ class Spawner(LoggingConfigurable):
|
|||||||
def _default_options_from_form(self, form_data):
|
def _default_options_from_form(self, form_data):
|
||||||
return form_data
|
return form_data
|
||||||
|
|
||||||
|
def run_options_from_form(self, form_data):
|
||||||
|
sig = signature(self.options_from_form)
|
||||||
|
if 'spawner' in sig.parameters:
|
||||||
|
return self.options_from_form(form_data, spawner=self)
|
||||||
|
else:
|
||||||
|
return self.options_from_form(form_data)
|
||||||
|
|
||||||
def options_from_query(self, query_data):
|
def options_from_query(self, query_data):
|
||||||
"""Interpret query arguments passed to /spawn
|
"""Interpret query arguments passed to /spawn
|
||||||
|
|
||||||
|
@@ -459,3 +459,27 @@ async def test_spawner_oauth_roles_bad(app, user):
|
|||||||
# raises ValueError if we try to assign a role that doesn't exist
|
# raises ValueError if we try to assign a role that doesn't exist
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
await spawner.user.spawn()
|
await spawner.user.spawn()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_spawner_options_from_form(db):
|
||||||
|
def options_from_form(form_data):
|
||||||
|
return form_data
|
||||||
|
|
||||||
|
spawner = new_spawner(db, options_from_form=options_from_form)
|
||||||
|
form_data = {"key": ["value"]}
|
||||||
|
result = spawner.run_options_from_form(form_data)
|
||||||
|
for key, value in form_data.items():
|
||||||
|
assert key in result
|
||||||
|
assert result[key] == value
|
||||||
|
|
||||||
|
|
||||||
|
async def test_spawner_options_from_form_with_spawner(db):
|
||||||
|
def options_from_form(form_data, spawner):
|
||||||
|
return form_data
|
||||||
|
|
||||||
|
spawner = new_spawner(db, options_from_form=options_from_form)
|
||||||
|
form_data = {"key": ["value"]}
|
||||||
|
result = spawner.run_options_from_form(form_data)
|
||||||
|
for key, value in form_data.items():
|
||||||
|
assert key in result
|
||||||
|
assert result[key] == value
|
||||||
|
File diff suppressed because one or more lines are too long
@@ -15,6 +15,11 @@
|
|||||||
{{ custom_html | safe }}
|
{{ custom_html | safe }}
|
||||||
{% elif login_service %}
|
{% elif login_service %}
|
||||||
<div class="service-login">
|
<div class="service-login">
|
||||||
|
<p id='insecure-login-warning' class='hidden'>
|
||||||
|
Warning: JupyterHub seems to be served over an unsecured HTTP connection.
|
||||||
|
We strongly recommend enabling HTTPS for JupyterHub.
|
||||||
|
</p>
|
||||||
|
|
||||||
<a role="button" class='btn btn-jupyter btn-lg' href='{{authenticator_login_url}}'>
|
<a role="button" class='btn btn-jupyter btn-lg' href='{{authenticator_login_url}}'>
|
||||||
Sign in with {{login_service}}
|
Sign in with {{login_service}}
|
||||||
</a>
|
</a>
|
||||||
|
Reference in New Issue
Block a user