sync rbac with main

# Conflicts:
#	docs/rest-api.yml
#	jupyterhub/oauth/provider.py
This commit is contained in:
Min RK
2021-06-14 12:53:39 +02:00
42 changed files with 10366 additions and 221 deletions

View File

@@ -5,6 +5,8 @@ name: Release
# but only publish to PyPI on tags # but only publish to PyPI on tags
on: on:
push: push:
branches:
- "!dependabot/**"
pull_request: pull_request:
jobs: jobs:
@@ -130,6 +132,7 @@ jobs:
githubToken: ${{ secrets.GITHUB_TOKEN }} githubToken: ${{ secrets.GITHUB_TOKEN }}
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub:" prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub:"
defaultTag: "${{ env.REGISTRY }}jupyterhub/jupyterhub:noref" defaultTag: "${{ env.REGISTRY }}jupyterhub/jupyterhub:noref"
branchRegex: ^\w[\w-.]*$
- name: Build and push jupyterhub - name: Build and push jupyterhub
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0 uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
@@ -150,6 +153,7 @@ jobs:
githubToken: ${{ secrets.GITHUB_TOKEN }} githubToken: ${{ secrets.GITHUB_TOKEN }}
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-onbuild:" prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-onbuild:"
defaultTag: "${{ env.REGISTRY }}jupyterhub/jupyterhub-onbuild:noref" defaultTag: "${{ env.REGISTRY }}jupyterhub/jupyterhub-onbuild:noref"
branchRegex: ^\w[\w-.]*$
- name: Build and push jupyterhub-onbuild - name: Build and push jupyterhub-onbuild
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0 uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
@@ -170,6 +174,7 @@ jobs:
githubToken: ${{ secrets.GITHUB_TOKEN }} githubToken: ${{ secrets.GITHUB_TOKEN }}
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-demo:" prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-demo:"
defaultTag: "${{ env.REGISTRY }}jupyterhub/jupyterhub-demo:noref" defaultTag: "${{ env.REGISTRY }}jupyterhub/jupyterhub-demo:noref"
branchRegex: ^\w[\w-.]*$
- name: Build and push jupyterhub-demo - name: Build and push jupyterhub-demo
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0 uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0

View File

@@ -140,6 +140,7 @@ jobs:
run: | run: |
npm install npm install
npm install -g configurable-http-proxy npm install -g configurable-http-proxy
npm install -g yarn
npm list npm list
# NOTE: actions/setup-python@v2 make use of a cache within the GitHub base # NOTE: actions/setup-python@v2 make use of a cache within the GitHub base
@@ -219,6 +220,9 @@ jobs:
# https://github.com/actions/runner/issues/241 # https://github.com/actions/runner/issues/241
run: | run: |
pytest -v --maxfail=2 --color=yes --cov=jupyterhub jupyterhub/tests pytest -v --maxfail=2 --color=yes --cov=jupyterhub jupyterhub/tests
- name: Run yarn jest test
run: |
cd jsx && yarn && yarn test
- name: Submit codecov report - name: Submit codecov report
run: | run: |
codecov codecov

View File

@@ -1 +1,2 @@
share/jupyterhub/templates/ share/jupyterhub/templates/
share/jupyterhub/static/js/admin-react.js

View File

@@ -6,6 +6,20 @@
**[License](#license)** | **[License](#license)** |
**[Help and Resources](#help-and-resources)** **[Help and Resources](#help-and-resources)**
---
Please note that this repository is participating in a study into sustainability
of open source projects. Data will be gathered about this repository for
approximately the next 12 months, starting from 2021-06-11.
Data collected will include number of contributors, number of PRs, time taken to
close/merge these PRs, and issues closed.
For more information, please visit
[our informational page](https://sustainable-open-science-and-software.github.io/) or download our [participant information sheet](https://sustainable-open-science-and-software.github.io/assets/PIS_sustainable_software.pdf).
---
# [JupyterHub](https://github.com/jupyterhub/jupyterhub) # [JupyterHub](https://github.com/jupyterhub/jupyterhub)
[![Latest PyPI version](https://img.shields.io/pypi/v/jupyterhub?logo=pypi)](https://pypi.python.org/pypi/jupyterhub) [![Latest PyPI version](https://img.shields.io/pypi/v/jupyterhub?logo=pypi)](https://pypi.python.org/pypi/jupyterhub)

View File

@@ -61,6 +61,13 @@ easy to do with RStudio too.
- [jupyterhub-deploy-teaching](https://github.com/jupyterhub/jupyterhub-deploy-teaching) based on work by Brian Granger for Cal Poly's Data Science 301 Course - [jupyterhub-deploy-teaching](https://github.com/jupyterhub/jupyterhub-deploy-teaching) based on work by Brian Granger for Cal Poly's Data Science 301 Course
### Chameleon
[Chameleon](https://www.chameleoncloud.org) is a NSF-funded configurable experimental environment for large-scale computer science systems research with [bare metal reconfigurability](https://chameleoncloud.readthedocs.io/en/latest/technical/baremetal.html). Chameleon users utilize JupyterHub to document and reproduce their complex CISE and networking experiments.
- [Shared JupyterHub](https://jupyter.chameleoncloud.org): provides a common "workbench" environment for any Chameleon user.
- [Trovi](https://www.chameleoncloud.org/experiment/share): a sharing portal of experiments, tutorials, and examples, which users can launch as a dedicated isolated environments on Chameleon's JupyterHub.
### Clemson University ### Clemson University
- Advanced Computing - Advanced Computing

44
jsx/.eslintrc.json Normal file
View File

@@ -0,0 +1,44 @@
{
"extends": ["plugin:react/recommended"],
"parserOptions": {
"ecmaVersion": 2018,
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
}
},
"settings": {
"react": {
"version": "detect"
}
},
"plugins": ["eslint-plugin-react", "prettier", "unused-imports"],
"env": {
"es6": true,
"browser": true
},
"rules": {
"semi": "off",
"quotes": "off",
"prettier/prettier": "warn",
"no-unused-vars": "off",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": [
"warn",
{
"vars": "all",
"varsIgnorePattern": "^regeneratorRuntime|^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}
]
},
"overrides": [
{
"files": ["**/*.test.js", "**/*.test.jsx"],
"env": {
"jest": true
}
}
]
}

2
jsx/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules
build/admin-react.js

64
jsx/README.md Normal file
View File

@@ -0,0 +1,64 @@
# Jupyterhub Admin Dashboard - React Variant
This repository contains current updates to the Jupyterhub Admin Dashboard,
reducing the complexity from a mass of templated HTML to a simple React web application.
This will integrate with Jupyterhub, speeding up client interactions while simplifying the
admin dashboard codebase.
### Build Commands
- `yarn build`: Installs all dependencies and bundles the application
- `yarn hot`: Bundles the application and runs a mock (serverless) version on port 8000
- `yarn lint`: Lints JSX with ESLint
- `yarn lint --fix`: Lints and fixes errors JSX with ESLint / formats with Prettier
- `yarn place`: Copies the transpiled React bundle to /share/jupyterhub/static/js/admin-react.js for use.
### Good To Know
Just some basics on how the React Admin app is built.
#### General build structure:
This app is written in JSX, and then transpiled into an ES5 bundle with Babel and Webpack. All JSX components are unit tested with a mixture of Jest and Enzyme and can be run both manually and per-commit. Most logic is separated into components under the `/src/components` directory, each directory containing a `.jsx`, `.test.jsx`, and sometimes a `.css` file. These components are all pulled together, given client-side routes, and connected to the Redux store in `/src/App.jsx` which serves as an entrypoint to the application.
#### Centralized state and data management with Redux:
The app use Redux throughout the components via the `useSelector` and `useDispatch` hooks to store and update user and group data from the API. With Redux, this data is available to any connected component. This means that if one component recieves new data, they all do.
#### API functions
All API functions used by the front end are packaged as a library of props within `/src/util/withAPI.js`. This keeps our web service logic separate from our presentational logic, allowing us to connect API functionality to our components at a high level and keep the code more modular. This connection specifically happens in `/src/App.jsx`, within the route assignments.
#### Pagination
Indicies of paginated user and group data is stored in a `page` variable in the query string, as well as the `user_page` / `group_page` state variables in Redux. This allows the app to maintain two sources of truth, as well as protect the admin user's place in the collection on page reload. Limit is constant at this point and is held in the Redux state.
On updates to the paginated data, the app can respond in one of two ways. If a user/group record is either added or deleted, the pagination will reset and data will be pulled back with no offset. Alternatively, if a record is modified, the offset will remain and the change will be shown.
Code examples:
```js
// Pagination limit is pulled in from Redux.
var limit = useSelector((state) => state.limit);
// Page query string is parsed and checked
var page = parseInt(new URLQuerySearch(props.location).get("page"));
page = isNaN(page) ? 0 : page;
// A slice is created representing the records to be returned
var slice = [page * limit, limit];
// A user's notebook server status was changed from stopped to running, user data is being refreshed from the slice.
startServer().then(() => {
updateUsers(...slice)
// After data is fetched, the Redux store is updated with the data and a copy of the page number.
.then((data) => dispatchPageChange(data, page));
});
// Alternatively, a new user was added, user data is being refreshed from offset 0.
addUser().then(() => {
updateUsers(0, limit)
// After data is fetched, the Redux store is updated with the data and asserts page 0.
.then((data) => dispatchPageChange(data, 0));
});
```

View File

@@ -0,0 +1,47 @@
/*
object-assign
(c) Sindre Sorhus
@license MIT
*/
/*!
Copyright (c) 2017 Jed Watson.
Licensed under the MIT License (MIT), see
http://jedwatson.github.io/classnames
*/
/** @license React v0.20.1
* scheduler.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 v16.13.1
* react-is.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.1
* react-dom.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.1
* react.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.
*/

6
jsx/build/index.html Normal file
View File

@@ -0,0 +1,6 @@
<!DOCTYPE html>
<head></head>
<body>
<div id="admin-react-hook"></div>
<script src="admin-react.js"></script>
</body>

67
jsx/package.json Normal file
View File

@@ -0,0 +1,67 @@
{
"name": "jupyterhub-admin-react",
"version": "1.0.0",
"description": "React application for the Jupyter Hub admin dashboard service",
"main": "index.js",
"author": "nabarber",
"license": "BSD-3-Clause",
"scripts": {
"build": "yarn && webpack",
"hot": "webpack && webpack-dev-server",
"place": "cp -r build/admin-react.js ../share/jupyterhub/static/js/admin-react.js",
"test": "jest --verbose",
"snap": "jest --updateSnapshot",
"lint": "eslint --ext .jsx --ext .js src/",
"lint:fix": "eslint --ext .jsx --ext .js src/ --fix"
},
"babel": {
"presets": [
"@babel/preset-env",
"@babel/preset-react"
],
"plugins": []
},
"jest": {
"moduleNameMapper": {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/__mocks__/fileMock.js",
"\\.(css|less)$": "identity-obj-proxy"
}
},
"dependencies": {
"@babel/core": "^7.12.3",
"@babel/preset-env": "^7.12.11",
"@babel/preset-react": "^7.12.10",
"babel-loader": "^8.2.1",
"bootstrap": "^4.5.3",
"css-loader": "^5.0.1",
"eslint-plugin-unused-imports": "^1.1.1",
"file-loader": "^6.2.0",
"history": "^5.0.0",
"prop-types": "^15.7.2",
"react": "^17.0.1",
"react-bootstrap": "^1.4.0",
"react-dom": "^17.0.1",
"react-icons": "^4.1.0",
"react-multi-select-component": "^3.0.7",
"react-redux": "^7.2.2",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"recompose": "^0.30.0",
"redux": "^4.0.5",
"style-loader": "^2.0.0",
"webpack": "^5.6.0",
"webpack-cli": "^3.3.4",
"webpack-dev-server": "^3.11.0"
},
"devDependencies": {
"@wojtekmaj/enzyme-adapter-react-17": "^0.4.1",
"babel-jest": "^26.6.3",
"enzyme": "^3.11.0",
"eslint": "^7.18.0",
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-react": "^7.22.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^26.6.3",
"prettier": "^2.2.1"
}
}

78
jsx/src/App.jsx Normal file
View File

@@ -0,0 +1,78 @@
import React, { useEffect } from "react";
import ReactDOM from "react-dom";
import { Provider } from "react-redux";
import { createStore } from "redux";
import { compose } from "recompose";
import { initialState, reducers } from "./Store";
import { jhapiRequest } from "./util/jhapiUtil";
import withAPI from "./util/withAPI";
import { HashRouter, Switch, Route } from "react-router-dom";
import ServerDashboard from "./components/ServerDashboard/ServerDashboard";
import Groups from "./components/Groups/Groups";
import GroupEdit from "./components/GroupEdit/GroupEdit";
import CreateGroup from "./components/CreateGroup/CreateGroup";
import AddUser from "./components/AddUser/AddUser";
import EditUser from "./components/EditUser/EditUser";
import "./style/root.css";
const store = createStore(reducers, initialState);
const App = () => {
useEffect(() => {
let { limit, user_page, groups_page } = initialState;
jhapiRequest(`/users?offset=${user_page * limit}&limit=${limit}`, "GET")
.then((data) => data.json())
.then((data) =>
store.dispatch({ type: "USER_PAGE", value: { data: data, page: 0 } })
)
.catch((err) => console.log(err));
jhapiRequest(`/groups?offset=${groups_page * limit}&limit=${limit}`, "GET")
.then((data) => data.json())
.then((data) =>
store.dispatch({ type: "GROUPS_PAGE", value: { data: data, page: 0 } })
)
.catch((err) => console.log(err));
});
return (
<div className="resets">
<Provider store={store}>
<HashRouter>
<Switch>
<Route
exact
path="/"
component={compose(withAPI)(ServerDashboard)}
/>
<Route exact path="/groups" component={compose(withAPI)(Groups)} />
<Route
exact
path="/group-edit"
component={compose(withAPI)(GroupEdit)}
/>
<Route
exact
path="/create-group"
component={compose(withAPI)(CreateGroup)}
/>
<Route
exact
path="/add-users"
component={compose(withAPI)(AddUser)}
/>
<Route
exact
path="/edit-user"
component={compose(withAPI)(EditUser)}
/>
</Switch>
</HashRouter>
</Provider>
</div>
);
};
ReactDOM.render(<App />, document.getElementById("react-admin-hook"));

28
jsx/src/Store.js Normal file
View File

@@ -0,0 +1,28 @@
export const initialState = {
user_data: undefined,
user_page: 0,
groups_data: undefined,
groups_page: 0,
limit: window.api_page_limit,
};
export const reducers = (state = initialState, action) => {
switch (action.type) {
// Updates the client user model data and stores the page
case "USER_PAGE":
return Object.assign({}, state, {
user_page: action.value.page,
user_data: action.value.data,
});
// Updates the client group model data and stores the page
case "GROUPS_PAGE":
return Object.assign({}, state, {
groups_page: action.value.page,
groups_data: action.value.data,
});
default:
return state;
}
};

View File

@@ -0,0 +1,125 @@
import React, { useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Link } from "react-router-dom";
import PropTypes from "prop-types";
const AddUser = (props) => {
var [users, setUsers] = useState([]),
[admin, setAdmin] = useState(false),
[errorAlert, setErrorAlert] = useState(null),
limit = useSelector((state) => state.limit);
var dispatch = useDispatch();
var dispatchPageChange = (data, page) => {
dispatch({
type: "USER_PAGE",
value: {
data: data,
page: page,
},
});
};
var { addUsers, failRegexEvent, updateUsers, history } = props;
return (
<>
<div className="container">
{errorAlert != null ? (
<div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<div className="alert alert-danger">{errorAlert}</div>
</div>
</div>
) : (
<></>
)}
<div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<div className="panel panel-default">
<div className="panel-heading">
<h4>Add Users</h4>
</div>
<div className="panel-body">
<form>
<div className="form-group">
<textarea
className="form-control"
id="add-user-textarea"
rows="3"
placeholder="usernames separated by line"
onBlur={(e) => {
let split_users = e.target.value.split("\n");
setUsers(split_users);
}}
></textarea>
<br></br>
<input
className="form-check-input"
type="checkbox"
value=""
id="admin-check"
onChange={(e) => setAdmin(e.target.checked)}
/>
<span> </span>
<label className="form-check-label">Admin</label>
</div>
</form>
</div>
<div className="panel-footer">
<button id="return" className="btn btn-light">
<Link to="/">Back</Link>
</button>
<span> </span>
<button
id="submit"
className="btn btn-primary"
onClick={() => {
let filtered_users = users.filter(
(e) =>
e.length > 2 &&
/[!@#$%^&*(),.?":{}|<>]/g.test(e) == false
);
if (filtered_users.length < users.length) {
setUsers(filtered_users);
failRegexEvent();
}
addUsers(filtered_users, admin)
.then((data) =>
data.status < 300
? updateUsers(0, limit)
.then((data) => dispatchPageChange(data, 0))
.then(() => history.push("/"))
.catch((err) => console.log(err))
: setErrorAlert(
`[${data.status}] Failed to create user. ${
data.status == 409 ? "User already exists." : ""
}`
)
)
.catch((err) => console.log(err));
}}
>
Add Users
</button>
</div>
</div>
</div>
</div>
</div>
</>
);
};
AddUser.propTypes = {
addUsers: PropTypes.func,
failRegexEvent: PropTypes.func,
updateUsers: PropTypes.func,
history: PropTypes.shape({
push: PropTypes.func,
}),
};
export default AddUser;

View File

@@ -0,0 +1,77 @@
import React from "react";
import Enzyme, { mount } from "enzyme";
import AddUser from "./AddUser";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
import { Provider, useDispatch, useSelector } from "react-redux";
import { createStore } from "redux";
import { HashRouter } from "react-router-dom";
Enzyme.configure({ adapter: new Adapter() });
jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
useDispatch: jest.fn(),
useSelector: jest.fn(),
}));
describe("AddUser Component: ", () => {
var mockAsync = () =>
jest
.fn()
.mockImplementation(() => Promise.resolve({ key: "value", status: 200 }));
var addUserJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<AddUser
addUsers={callbackSpy}
failRegexEvent={callbackSpy}
updateUsers={callbackSpy}
history={{ push: () => {} }}
/>
</HashRouter>
</Provider>
);
var mockAppState = () => ({
limit: 3,
});
beforeEach(() => {
useDispatch.mockImplementation(() => {
return () => {};
});
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});
afterEach(() => {
useDispatch.mockClear();
});
it("Renders", () => {
let component = mount(addUserJsx(mockAsync()));
expect(component.find(".container").length).toBe(1);
});
it("Removes users when they fail Regex", () => {
let callbackSpy = mockAsync(),
component = mount(addUserJsx(callbackSpy)),
textarea = component.find("textarea").first();
textarea.simulate("blur", { target: { value: "foo\nbar\n!!*&*" } });
let submit = component.find("#submit");
submit.simulate("click");
expect(callbackSpy).toHaveBeenCalledWith(["foo", "bar"], false);
});
it("Correctly submits admin", () => {
let callbackSpy = mockAsync(),
component = mount(addUserJsx(callbackSpy)),
input = component.find("input").first();
input.simulate("change", { target: { checked: true } });
let submit = component.find("#submit");
submit.simulate("click");
expect(callbackSpy).toHaveBeenCalledWith([], true);
});
});

View File

@@ -0,0 +1,104 @@
import React, { useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Link } from "react-router-dom";
import PropTypes from "prop-types";
const CreateGroup = (props) => {
var [groupName, setGroupName] = useState(""),
[errorAlert, setErrorAlert] = useState(null),
limit = useSelector((state) => state.limit);
var dispatch = useDispatch();
var dispatchPageUpdate = (data, page) => {
dispatch({
type: "GROUPS_PAGE",
value: {
data: data,
page: page,
},
});
};
var { createGroup, updateGroups, history } = props;
return (
<>
<div className="container">
{errorAlert != null ? (
<div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<div className="alert alert-danger">{errorAlert}</div>
</div>
</div>
) : (
<></>
)}
<div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<div className="panel panel-default">
<div className="panel-heading">
<h4>Create Group</h4>
</div>
<div className="panel-body">
<div className="input-group">
<input
className="group-name-input"
type="text"
id="group-name"
value={groupName}
placeholder="group name..."
onChange={(e) => {
setGroupName(e.target.value);
}}
></input>
</div>
</div>
<div className="panel-footer">
<button id="return" className="btn btn-light">
<Link to="/">Back</Link>
</button>
<span> </span>
<button
id="submit"
className="btn btn-primary"
onClick={() => {
createGroup(groupName)
.then((data) => {
return data.status < 300
? updateGroups(0, limit)
.then((data) => dispatchPageUpdate(data, 0))
.then(() => history.push("/groups"))
.catch((err) => console.log(err))
: setErrorAlert(
`[${data.status}] Failed to create group. ${
data.status == 409
? "Group already exists."
: ""
}`
);
})
.catch((err) => console.log(err));
}}
>
Create
</button>
</div>
</div>
</div>
</div>
</div>
</>
);
};
CreateGroup.propTypes = {
createGroup: PropTypes.func,
updateGroups: PropTypes.func,
failRegexEvent: PropTypes.func,
history: PropTypes.shape({
push: PropTypes.func,
}),
};
export default CreateGroup;

View File

@@ -0,0 +1,66 @@
import React from "react";
import Enzyme, { mount } from "enzyme";
import CreateGroup from "./CreateGroup";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
import { Provider, useDispatch, useSelector } from "react-redux";
import { createStore } from "redux";
import { HashRouter } from "react-router-dom";
import regeneratorRuntime from "regenerator-runtime"; // eslint-disable-line
Enzyme.configure({ adapter: new Adapter() });
jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
useDispatch: jest.fn(),
useSelector: jest.fn(),
}));
describe("CreateGroup Component: ", () => {
var mockAsync = (result) =>
jest.fn().mockImplementation(() => Promise.resolve(result));
var createGroupJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<CreateGroup
createGroup={callbackSpy}
updateGroups={callbackSpy}
history={{ push: () => {} }}
/>
</HashRouter>
</Provider>
);
var mockAppState = () => ({
limit: 3,
});
beforeEach(() => {
useDispatch.mockImplementation(() => {
return () => () => {};
});
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});
afterEach(() => {
useDispatch.mockClear();
});
it("Renders", () => {
let component = mount(createGroupJsx());
expect(component.find(".container").length).toBe(1);
});
it("Calls createGroup on submit", () => {
let callbackSpy = mockAsync({ status: 200 }),
component = mount(createGroupJsx(callbackSpy)),
input = component.find("input").first(),
submit = component.find("#submit").first();
input.simulate("change", { target: { value: "" } });
submit.simulate("click");
expect(callbackSpy).toHaveBeenNthCalledWith(1, "");
expect(component.find(".alert.alert-danger").length).toBe(0);
});
});

View File

@@ -0,0 +1,190 @@
import React, { useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import PropTypes from "prop-types";
import { Link } from "react-router-dom";
const EditUser = (props) => {
var limit = useSelector((state) => state.limit),
[errorAlert, setErrorAlert] = useState(null);
var dispatch = useDispatch();
var dispatchPageChange = (data, page) => {
dispatch({
type: "USER_PAGE",
value: {
data: data,
page: page,
},
});
};
var {
editUser,
deleteUser,
failRegexEvent,
noChangeEvent,
updateUsers,
history,
} = props;
if (props.location.state == undefined) {
props.history.push("/");
return <></>;
}
var { username, has_admin } = props.location.state;
var [updatedUsername, setUpdatedUsername] = useState(""),
[admin, setAdmin] = useState(has_admin);
return (
<>
<div className="container">
{errorAlert != null ? (
<div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<div className="alert alert-danger">{errorAlert}</div>
</div>
</div>
) : (
<></>
)}
<div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<div className="panel panel-default">
<div className="panel-heading">
<h4>Editing user {username}</h4>
</div>
<div className="panel-body">
<form>
<div className="form-group">
<textarea
className="form-control"
id="exampleFormControlTextarea1"
rows="3"
placeholder="updated username"
onBlur={(e) => {
setUpdatedUsername(e.target.value);
}}
></textarea>
<br></br>
<input
className="form-check-input"
checked={admin}
type="checkbox"
id="admin-check"
onChange={() => setAdmin(!admin)}
/>
<span> </span>
<label className="form-check-label">Admin</label>
<br></br>
<button
id="delete-user"
className="btn btn-danger btn-sm"
onClick={() => {
deleteUser(username)
.then((data) => {
data.status < 300
? updateUsers(0, limit)
.then((data) => dispatchPageChange(data, 0))
.then(() => history.push("/"))
.catch((err) => console.log(err))
: setErrorAlert(
`[${data.status}] Failed to edit user.`
);
})
.catch((err) => console.log(err));
}}
>
Delete user
</button>
</div>
</form>
</div>
<div className="panel-footer">
<button className="btn btn-light">
<Link to="/">Back</Link>
</button>
<span> </span>
<button
id="submit"
className="btn btn-primary"
onClick={() => {
if (updatedUsername == "" && admin == has_admin) {
noChangeEvent();
return;
} else if (updatedUsername != "") {
if (
updatedUsername.length > 2 &&
/[!@#$%^&*(),.?":{}|<>]/g.test(updatedUsername) == false
) {
editUser(
username,
updatedUsername != "" ? updatedUsername : username,
admin
)
.then((data) => {
data.status < 300
? updateUsers(0, limit)
.then((data) => dispatchPageChange(data, 0))
.then(() => history.push("/"))
.catch((err) => console.log(err))
: setErrorAlert(
`[${data.status}] Failed to edit user.`
);
})
.catch((err) => {
console.log(err);
});
} else {
setUpdatedUsername("");
failRegexEvent();
}
} else {
editUser(username, username, admin)
.then((data) => {
data.status < 300
? updateUsers(0, limit)
.then((data) => dispatchPageChange(data, 0))
.then(() => history.push("/"))
.catch((err) => console.log(err))
: setErrorAlert(
`[${data.status}] Failed to edit user.`
);
})
.catch((err) => {
console.log(err);
});
}
}}
>
Apply
</button>
</div>
</div>
</div>
</div>
</div>
</>
);
};
EditUser.propTypes = {
location: PropTypes.shape({
state: PropTypes.shape({
username: PropTypes.string,
has_admin: PropTypes.bool,
}),
}),
history: PropTypes.shape({
push: PropTypes.func,
}),
editUser: PropTypes.func,
deleteUser: PropTypes.func,
failRegexEvent: PropTypes.func,
noChangeEvent: PropTypes.func,
updateUsers: PropTypes.func,
};
export default EditUser;

View File

@@ -0,0 +1,80 @@
import React from "react";
import Enzyme, { mount } from "enzyme";
import EditUser from "./EditUser";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
import { Provider, useDispatch, useSelector } from "react-redux";
import { createStore } from "redux";
import { HashRouter } from "react-router-dom";
Enzyme.configure({ adapter: new Adapter() });
jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
useDispatch: jest.fn(),
useSelector: jest.fn(),
}));
describe("EditUser Component: ", () => {
var mockAsync = () =>
jest
.fn()
.mockImplementation(() => Promise.resolve({ key: "value", status: 200 }));
var mockSync = () => jest.fn();
var editUserJsx = (callbackSpy, empty) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<EditUser
location={
empty ? {} : { state: { username: "foo", has_admin: false } }
}
deleteUser={callbackSpy}
editUser={callbackSpy}
updateUsers={callbackSpy}
history={{ push: () => {} }}
failRegexEvent={callbackSpy}
noChangeEvent={callbackSpy}
/>
</HashRouter>
</Provider>
);
var mockAppState = () => ({
limit: 3,
});
beforeEach(() => {
useDispatch.mockImplementation(() => {
return () => {};
});
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});
afterEach(() => {
useDispatch.mockClear();
});
it("Calls the delete user function when the button is pressed", () => {
let callbackSpy = mockAsync(),
component = mount(editUserJsx(callbackSpy)),
deleteUser = component.find("#delete-user");
deleteUser.simulate("click");
expect(callbackSpy).toHaveBeenCalled();
});
it("Submits the edits when the button is pressed", () => {
let callbackSpy = mockSync(),
component = mount(editUserJsx(callbackSpy)),
submit = component.find("#submit");
submit.simulate("click");
expect(callbackSpy).toHaveBeenCalled();
});
it("Doesn't render when no data is provided", () => {
let callbackSpy = mockSync(),
component = mount(editUserJsx(callbackSpy, true));
expect(component.find(".container").length).toBe(0);
});
});

View File

@@ -0,0 +1,144 @@
import React, { useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { Link } from "react-router-dom";
import PropTypes from "prop-types";
import GroupSelect from "../GroupSelect/GroupSelect";
const GroupEdit = (props) => {
var [selected, setSelected] = useState([]),
[changed, setChanged] = useState(false),
limit = useSelector((state) => state.limit);
var dispatch = useDispatch();
const dispatchPageUpdate = (data, page) => {
dispatch({
type: "GROUPS_PAGE",
value: {
data: data,
page: page,
},
});
};
var {
addToGroup,
removeFromGroup,
deleteGroup,
updateGroups,
validateUser,
history,
location,
} = props;
if (!location.state) {
history.push("/groups");
return <></>;
}
var { group_data } = location.state;
if (!group_data) return <div></div>;
return (
<div className="container">
<div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<h3>Editing Group {group_data.name}</h3>
<br></br>
<div className="alert alert-info">Manage group members</div>
</div>
</div>
<GroupSelect
users={group_data.users}
validateUser={validateUser}
onChange={(selection) => {
setSelected(selection);
setChanged(true);
}}
/>
<div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<button id="return" className="btn btn-light">
<Link to="/groups">Back</Link>
</button>
<span> </span>
<button
id="submit"
className="btn btn-primary"
onClick={() => {
// check for changes
if (!changed) {
history.push("/groups");
return;
}
let new_users = selected.filter(
(e) => !group_data.users.includes(e)
);
let removed_users = group_data.users.filter(
(e) => !selected.includes(e)
);
let promiseQueue = [];
if (new_users.length > 0)
promiseQueue.push(addToGroup(new_users, group_data.name));
if (removed_users.length > 0)
promiseQueue.push(
removeFromGroup(removed_users, group_data.name)
);
Promise.all(promiseQueue)
.then(() => {
updateGroups(0, limit)
.then((data) => dispatchPageUpdate(data, 0))
.then(() => history.push("/groups"));
})
.catch((err) => console.log(err));
}}
>
Apply
</button>
<button
id="delete-group"
className="btn btn-danger"
style={{ float: "right" }}
onClick={() => {
var groupName = group_data.name;
deleteGroup(groupName)
.then(() => {
updateGroups(0, limit)
.then((data) => dispatchPageUpdate(data, 0))
.then(() => history.push("/groups"));
})
.catch((err) => console.log(err));
}}
>
Delete Group
</button>
<br></br>
<br></br>
</div>
</div>
</div>
);
};
GroupEdit.propTypes = {
location: PropTypes.shape({
state: PropTypes.shape({
group_data: PropTypes.object,
callback: PropTypes.func,
}),
}),
history: PropTypes.shape({
push: PropTypes.func,
}),
addToGroup: PropTypes.func,
removeFromGroup: PropTypes.func,
deleteGroup: PropTypes.func,
updateGroups: PropTypes.func,
validateUser: PropTypes.func,
};
export default GroupEdit;

View File

@@ -0,0 +1,100 @@
import React from "react";
import Enzyme, { mount } from "enzyme";
import GroupEdit from "./GroupEdit";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
import { Provider, useSelector } from "react-redux";
import { createStore } from "redux";
import { HashRouter } from "react-router-dom";
import { act } from "react-dom/test-utils";
import regeneratorRuntime from "regenerator-runtime"; // eslint-disable-line
Enzyme.configure({ adapter: new Adapter() });
jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
useSelector: jest.fn(),
}));
describe("GroupEdit Component: ", () => {
var mockAsync = () => jest.fn().mockImplementation(() => Promise.resolve());
var okPacket = new Promise((resolve) => resolve(true));
var groupEditJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<GroupEdit
location={{
state: {
group_data: { users: ["foo"], name: "group" },
callback: () => {},
},
}}
addToGroup={callbackSpy}
removeFromGroup={callbackSpy}
deleteGroup={callbackSpy}
history={{ push: () => callbackSpy }}
updateGroups={callbackSpy}
validateUser={jest.fn().mockImplementation(() => okPacket)}
/>
</HashRouter>
</Provider>
);
var mockAppState = () => ({
limit: 3,
});
beforeEach(() => {
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});
afterEach(() => {
useSelector.mockClear();
});
it("Adds user from input to user selectables on button click", async () => {
let callbackSpy = mockAsync(),
component = mount(groupEditJsx(callbackSpy)),
input = component.find("#username-input"),
validateUser = component.find("#validate-user"),
submit = component.find("#submit");
input.simulate("change", { target: { value: "bar" } });
validateUser.simulate("click");
await act(() => okPacket);
submit.simulate("click");
expect(callbackSpy).toHaveBeenNthCalledWith(1, ["bar"], "group");
});
it("Removes a user recently added from input from the selectables list", () => {
let callbackSpy = mockAsync(),
component = mount(groupEditJsx(callbackSpy)),
unsubmittedUser = component.find(".item.selected").last();
unsubmittedUser.simulate("click");
expect(component.find(".item").length).toBe(1);
});
it("Grays out a user, already in the group, when unselected and calls deleteUser on submit", () => {
let callbackSpy = mockAsync(),
component = mount(groupEditJsx(callbackSpy)),
groupUser = component.find(".item.selected").first();
groupUser.simulate("click");
expect(component.find(".item.unselected").length).toBe(1);
expect(component.find(".item").length).toBe(1);
// test deleteUser call
let submit = component.find("#submit");
submit.simulate("click");
expect(callbackSpy).toHaveBeenNthCalledWith(1, ["foo"], "group");
});
it("Calls deleteGroup on button click", () => {
let callbackSpy = mockAsync(),
component = mount(groupEditJsx(callbackSpy)),
deleteGroup = component.find("#delete-group").first();
deleteGroup.simulate("click");
expect(callbackSpy).toHaveBeenNthCalledWith(1, "group");
});
});

View File

@@ -0,0 +1,108 @@
import React, { useState } from "react";
import PropTypes from "prop-types";
import "./group-select.css";
const GroupSelect = (props) => {
var { onChange, validateUser, users } = props;
var [selected, setSelected] = useState(users);
var [username, setUsername] = useState("");
var [error, setError] = useState(null);
if (!users) return null;
return (
<div className="row">
{error != null ? (
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2 text-left">
<div className="alert alert-danger">{error}</div>
</div>
) : (
<></>
)}
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2 text-left">
<div className="input-group">
<input
id="username-input"
type="text"
className="form-control"
placeholder="Add by username"
value={username}
onChange={(e) => {
setUsername(e.target.value);
}}
/>
<span className="input-group-btn">
<button
id="validate-user"
className="btn btn-default"
type="button"
onClick={() => {
validateUser(username).then((exists) => {
if (exists && !selected.includes(username)) {
let updated_selection = selected.concat([username]);
onChange(updated_selection, users);
setUsername("");
setSelected(updated_selection);
if (error != null) setError(null);
} else if (!exists) {
setError(`"${username}" is not a valid JupyterHub user.`);
}
});
}}
>
Add user
</button>
</span>
</div>
</div>
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2 text-left">
<div className="users-container">
<hr></hr>
<div>
{selected.map((e, i) => (
<div
key={"selected" + i}
className="item selected"
onClick={() => {
let updated_selection = selected
.slice(0, i)
.concat(selected.slice(i + 1));
onChange(updated_selection, users);
setSelected(updated_selection);
}}
>
{e}
</div>
))}
{users.map((e, i) =>
selected.includes(e) ? undefined : (
<div
key={"unselected" + i}
className="item unselected"
onClick={() => {
let updated_selection = selected.concat([e]);
onChange(updated_selection, users);
setSelected(updated_selection);
}}
>
{e}
</div>
)
)}
</div>
</div>
<br></br>
<br></br>
</div>
</div>
);
};
GroupSelect.propTypes = {
onChange: PropTypes.func,
validateUser: PropTypes.func,
users: PropTypes.array,
};
export default GroupSelect;

View File

@@ -0,0 +1,40 @@
@import url(../../style/root.css);
.users-container {
width: 100%;
position: relative;
padding: 5px;
overflow-x: scroll;
}
.users-container div {
display: inline-block;
}
.users-container .item {
padding: 3px;
padding-left: 6px;
padding-right: 6px;
border-radius: 3px;
font-size: 14px;
margin-left: 4px;
margin-right: 4px;
transition: 30ms ease-in all;
cursor: pointer;
user-select: none;
border: solid 1px #dfdfdf;
}
.users-container .item.unselected {
background-color: #f7f7f7;
color: #777;
}
.users-container .item.selected {
background-color: orange;
color: white;
}
.users-container .item:hover {
opacity: 0.7;
}

View File

@@ -0,0 +1,117 @@
import React from "react";
import { useSelector, useDispatch } from "react-redux";
import PropTypes from "prop-types";
import { Link } from "react-router-dom";
import PaginationFooter from "../PaginationFooter/PaginationFooter";
const Groups = (props) => {
var user_data = useSelector((state) => state.user_data),
groups_data = useSelector((state) => state.groups_data),
groups_page = useSelector((state) => state.groups_page),
limit = useSelector((state) => state.limit),
dispatch = useDispatch(),
page = parseInt(new URLSearchParams(props.location.search).get("page"));
page = isNaN(page) ? 0 : page;
var slice = [page * limit, limit];
var { updateGroups, history } = props;
if (!groups_data || !user_data) {
return <div></div>;
}
const dispatchPageChange = (data, page) => {
dispatch({
type: "GROUPS_PAGE",
value: {
data: data,
page: page,
},
});
};
if (groups_page != page) {
updateGroups(...slice).then((data) => {
dispatchPageChange(data, page);
});
}
return (
<div className="container">
<div className="row">
<div className="col-md-12 col-lg-10 col-lg-offset-1">
<div className="panel panel-default">
<div className="panel-heading">
<h4>Groups</h4>
</div>
<div className="panel-body">
<ul className="list-group">
{groups_data.length > 0 ? (
groups_data.map((e, i) => (
<li className="list-group-item" key={"group-item" + i}>
<span className="badge badge-pill badge-success">
{e.users.length + " users"}
</span>
<Link
to={{
pathname: "/group-edit",
state: {
group_data: e,
user_data: user_data,
},
}}
>
{e.name}
</Link>
</li>
))
) : (
<div>
<h4>no groups created...</h4>
</div>
)}
</ul>
<PaginationFooter
endpoint="/groups"
page={page}
limit={limit}
numOffset={slice[0]}
numElements={groups_data.length}
/>
</div>
<div className="panel-footer">
<button className="btn btn-light adjacent-span-spacing">
<Link to="/">Back</Link>
</button>
<button
className="btn btn-primary adjacent-span-spacing"
onClick={() => {
history.push("/create-group");
}}
>
New Group
</button>
</div>
</div>
</div>
</div>
</div>
);
};
Groups.propTypes = {
user_data: PropTypes.array,
groups_data: PropTypes.array,
updateUsers: PropTypes.func,
updateGroups: PropTypes.func,
history: PropTypes.shape({
push: PropTypes.func,
}),
location: PropTypes.shape({
search: PropTypes.string,
}),
};
export default Groups;

View File

@@ -0,0 +1,65 @@
import React from "react";
import Enzyme, { mount } from "enzyme";
import Groups from "./Groups";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
import { Provider, useDispatch, useSelector } from "react-redux";
import { createStore } from "redux";
import { HashRouter } from "react-router-dom";
Enzyme.configure({ adapter: new Adapter() });
jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
useSelector: jest.fn(),
useDispatch: jest.fn(),
}));
describe("Groups Component: ", () => {
var mockAsync = () =>
jest.fn().mockImplementation(() => Promise.resolve({ key: "value" }));
var groupsJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<Groups location={{ search: "0" }} updateGroups={callbackSpy} />
</HashRouter>
</Provider>
);
var mockAppState = () => ({
user_data: JSON.parse(
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]'
),
groups_data: JSON.parse(
'[{"kind":"group","name":"testgroup","users":[]}, {"kind":"group","name":"testgroup2","users":["foo", "bar"]}]'
),
});
beforeEach(() => {
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
useDispatch.mockImplementation(() => {
return () => {};
});
});
afterEach(() => {
useSelector.mockClear();
});
it("Renders groups_data prop into links", () => {
let callbackSpy = mockAsync(),
component = mount(groupsJsx(callbackSpy)),
links = component.find("li");
expect(links.length).toBe(2);
});
it("Renders nothing if required data is not available", () => {
useSelector.mockImplementation((callback) => {
return callback({});
});
let component = mount(groupsJsx());
expect(component.html()).toBe("<div></div>");
});
});

View File

@@ -0,0 +1,50 @@
import React from "react";
import { Link } from "react-router-dom";
import PropTypes from "prop-types";
import "./pagination-footer.css";
const PaginationFooter = (props) => {
let { endpoint, page, limit, numOffset, numElements } = props;
return (
<div className="pagination-footer">
<p>
Displaying {numOffset}-{numOffset + numElements}
<br></br>
<br></br>
{page >= 1 ? (
<button className="btn btn-sm btn-light spaced">
<Link to={`${endpoint}?page=${page - 1}`}>
<span className="active-pagination">Previous</span>
</Link>
</button>
) : (
<button className="btn btn-sm btn-light spaced">
<span className="inactive-pagination">Previous</span>
</button>
)}
{numElements >= limit ? (
<button className="btn btn-sm btn-light spaced">
<Link to={`${endpoint}?page=${page + 1}`}>
<span className="active-pagination">Next</span>
</Link>
</button>
) : (
<button className="btn btn-sm btn-light spaced">
<span className="inactive-pagination">Next</span>
</button>
)}
</p>
</div>
);
};
PaginationFooter.propTypes = {
endpoint: PropTypes.string,
page: PropTypes.number,
limit: PropTypes.number,
numOffset: PropTypes.number,
numElements: PropTypes.number,
};
export default PaginationFooter;

View File

@@ -0,0 +1,14 @@
@import url(../../style/root.css);
.pagination-footer * button {
margin-right: 10px;
}
.pagination-footer * .inactive-pagination {
color: gray;
cursor: not-allowed;
}
.pagination-footer * button.spaced {
color: var(--blue);
}

View File

@@ -0,0 +1,308 @@
import React, { useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import PropTypes from "prop-types";
import { Button } from "react-bootstrap";
import { Link } from "react-router-dom";
import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa";
import "./server-dashboard.css";
import { timeSince } from "../../util/timeSince";
import PaginationFooter from "../PaginationFooter/PaginationFooter";
const ServerDashboard = (props) => {
// sort methods
var usernameDesc = (e) => e.sort((a, b) => (a.name > b.name ? 1 : -1)),
usernameAsc = (e) => e.sort((a, b) => (a.name < b.name ? 1 : -1)),
adminDesc = (e) => e.sort((a) => (a.admin ? -1 : 1)),
adminAsc = (e) => e.sort((a) => (a.admin ? 1 : -1)),
dateDesc = (e) =>
e.sort((a, b) =>
new Date(a.last_activity) - new Date(b.last_activity) > 0 ? -1 : 1
),
dateAsc = (e) =>
e.sort((a, b) =>
new Date(a.last_activity) - new Date(b.last_activity) > 0 ? 1 : -1
),
runningAsc = (e) => e.sort((a) => (a.server == null ? -1 : 1)),
runningDesc = (e) => e.sort((a) => (a.server == null ? 1 : -1));
var [sortMethod, setSortMethod] = useState(null);
var user_data = useSelector((state) => state.user_data),
user_page = useSelector((state) => state.user_page),
limit = useSelector((state) => state.limit),
page = parseInt(new URLSearchParams(props.location.search).get("page"));
page = isNaN(page) ? 0 : page;
var slice = [page * limit, limit];
const dispatch = useDispatch();
var {
updateUsers,
shutdownHub,
startServer,
stopServer,
startAll,
stopAll,
history,
} = props;
var dispatchPageUpdate = (data, page) => {
dispatch({
type: "USER_PAGE",
value: {
data: data,
page: page,
},
});
};
if (!user_data) {
return <div></div>;
}
if (page != user_page) {
updateUsers(...slice).then((data) => dispatchPageUpdate(data, page));
}
if (sortMethod != null) {
user_data = sortMethod(user_data);
}
return (
<div className="container">
<div className="manage-groups" style={{ float: "right", margin: "20px" }}>
<Link to="/groups">{"> Manage Groups"}</Link>
</div>
<div className="server-dashboard-container">
<table className="table table-striped table-bordered table-hover">
<thead className="admin-table-head">
<tr>
<th id="user-header">
User{" "}
<SortHandler
sorts={{ asc: usernameAsc, desc: usernameDesc }}
callback={(method) => setSortMethod(() => method)}
/>
</th>
<th id="admin-header">
Admin{" "}
<SortHandler
sorts={{ asc: adminAsc, desc: adminDesc }}
callback={(method) => setSortMethod(() => method)}
/>
</th>
<th id="last-activity-header">
Last Activity{" "}
<SortHandler
sorts={{ asc: dateAsc, desc: dateDesc }}
callback={(method) => setSortMethod(() => method)}
/>
</th>
<th id="running-status-header">
Running{" "}
<SortHandler
sorts={{ asc: runningAsc, desc: runningDesc }}
callback={(method) => setSortMethod(() => method)}
/>
</th>
<th id="actions-header">Actions</th>
</tr>
</thead>
<tbody>
<tr className="noborder">
<td>
<Button variant="light" className="add-users-button">
<Link to="/add-users">Add Users</Link>
</Button>
</td>
<td></td>
<td></td>
<td>
{/* Start all servers */}
<Button
variant="primary"
className="start-all"
onClick={() => {
Promise.all(startAll(user_data.map((e) => e.name)))
.then((res) => {
updateUsers(...slice)
.then((data) => {
dispatchPageUpdate(data, page);
})
.catch((err) => console.log(err));
return res;
})
.catch((err) => console.log(err));
}}
>
Start All
</Button>
<span> </span>
{/* Stop all servers */}
<Button
variant="danger"
className="stop-all"
onClick={() => {
Promise.all(stopAll(user_data.map((e) => e.name)))
.then((res) => {
updateUsers(...slice)
.then((data) => {
dispatchPageUpdate(data, page);
})
.catch((err) => console.log(err));
return res;
})
.catch((err) => console.log(err));
}}
>
Stop All
</Button>
</td>
<td>
{/* Shutdown Jupyterhub */}
<Button
variant="danger"
id="shutdown-button"
onClick={shutdownHub}
>
Shutdown Hub
</Button>
</td>
</tr>
{user_data.map((e, i) => (
<tr key={i + "row"} className="user-row">
<td>{e.name}</td>
<td>{e.admin ? "admin" : ""}</td>
<td>
{e.last_activity ? timeSince(e.last_activity) : "Never"}
</td>
<td>
{e.server != null ? (
// Stop Single-user server
<button
className="btn btn-danger btn-xs stop-button"
onClick={() =>
stopServer(e.name)
.then((res) => {
updateUsers(...slice).then((data) => {
dispatchPageUpdate(data, page);
});
return res;
})
.catch((err) => console.log(err))
}
>
Stop Server
</button>
) : (
// Start Single-user server
<button
className="btn btn-primary btn-xs start-button"
onClick={() =>
startServer(e.name)
.then((res) => {
updateUsers(...slice).then((data) => {
dispatchPageUpdate(data, page);
});
return res;
})
.catch((err) => console.log(err))
}
>
Start Server
</button>
)}
</td>
<td>
{/* Edit User */}
<button
className="btn btn-primary btn-xs"
style={{ marginRight: 20 }}
onClick={() =>
history.push({
pathname: "/edit-user",
state: {
username: e.name,
has_admin: e.admin,
},
})
}
>
edit user
</button>
</td>
</tr>
))}
</tbody>
</table>
<PaginationFooter
endpoint="/"
page={page}
limit={limit}
numOffset={slice[0]}
numElements={user_data.length}
/>
<br></br>
</div>
</div>
);
};
ServerDashboard.propTypes = {
user_data: PropTypes.array,
updateUsers: PropTypes.func,
shutdownHub: PropTypes.func,
startServer: PropTypes.func,
stopServer: PropTypes.func,
startAll: PropTypes.func,
stopAll: PropTypes.func,
dispatch: PropTypes.func,
history: PropTypes.shape({
push: PropTypes.func,
}),
location: PropTypes.shape({
search: PropTypes.string,
}),
};
const SortHandler = (props) => {
var { sorts, callback } = props;
var [direction, setDirection] = useState(undefined);
return (
<div
className="sort-icon"
onClick={() => {
if (!direction) {
callback(sorts.desc);
setDirection("desc");
} else if (direction == "asc") {
callback(sorts.desc);
setDirection("desc");
} else {
callback(sorts.asc);
setDirection("asc");
}
}}
>
{!direction ? (
<FaSort />
) : direction == "asc" ? (
<FaSortDown />
) : (
<FaSortUp />
)}
</div>
);
};
SortHandler.propTypes = {
sorts: PropTypes.object,
callback: PropTypes.func,
};
export default ServerDashboard;

View File

@@ -0,0 +1,161 @@
import React from "react";
import Enzyme, { mount } from "enzyme";
import ServerDashboard from "./ServerDashboard";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
import { HashRouter, Switch } from "react-router-dom";
import { Provider, useSelector } from "react-redux";
import { createStore } from "redux";
Enzyme.configure({ adapter: new Adapter() });
jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
useSelector: jest.fn(),
}));
describe("ServerDashboard Component: ", () => {
var serverDashboardJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<Switch>
<ServerDashboard
updateUsers={callbackSpy}
shutdownHub={callbackSpy}
startServer={callbackSpy}
stopServer={callbackSpy}
startAll={callbackSpy}
stopAll={callbackSpy}
/>
</Switch>
</HashRouter>
</Provider>
);
var mockAsync = () =>
jest
.fn()
.mockImplementation(() =>
Promise.resolve({ json: () => Promise.resolve({ k: "v" }) })
);
var mockAppState = () => ({
user_data: JSON.parse(
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]'
),
});
beforeEach(() => {
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});
afterEach(() => {
useSelector.mockClear();
});
it("Renders users from props.user_data into table", () => {
let component = mount(serverDashboardJsx(mockAsync())),
userRows = component.find(".user-row");
expect(userRows.length).toBe(2);
});
it("Renders correctly the status of a single-user server", () => {
let component = mount(serverDashboardJsx(mockAsync())),
userRows = component.find(".user-row");
// Renders .stop-button when server is started
// Should be 1 since user foo is started
expect(userRows.at(0).find(".stop-button").length).toBe(1);
// Renders .start-button when server is stopped
// Should be 1 since user bar is stopped
expect(userRows.at(1).find(".start-button").length).toBe(1);
});
it("Invokes the startServer event on button click", () => {
let callbackSpy = mockAsync(),
component = mount(serverDashboardJsx(callbackSpy)),
startBtn = component.find(".start-button");
startBtn.simulate("click");
expect(callbackSpy).toHaveBeenCalled();
});
it("Invokes the stopServer event on button click", () => {
let callbackSpy = mockAsync(),
component = mount(serverDashboardJsx(callbackSpy)),
stopBtn = component.find(".stop-button");
stopBtn.simulate("click");
expect(callbackSpy).toHaveBeenCalled();
});
it("Invokes the shutdownHub event on button click", () => {
let callbackSpy = mockAsync(),
component = mount(serverDashboardJsx(callbackSpy)),
shutdownBtn = component.find("#shutdown-button").first();
shutdownBtn.simulate("click");
expect(callbackSpy).toHaveBeenCalled();
});
it("Sorts according to username", () => {
let component = mount(serverDashboardJsx(mockAsync())).find(
"ServerDashboard"
),
handler = component.find("SortHandler").first();
handler.simulate("click");
let first = component.find(".user-row").first();
expect(first.html().includes("bar")).toBe(true);
handler.simulate("click");
first = component.find(".user-row").first();
expect(first.html().includes("foo")).toBe(true);
});
it("Sorts according to admin", () => {
let component = mount(serverDashboardJsx(mockAsync())).find(
"ServerDashboard"
),
handler = component.find("SortHandler").at(1);
handler.simulate("click");
let first = component.find(".user-row").first();
expect(first.html().includes("admin")).toBe(true);
handler.simulate("click");
first = component.find(".user-row").first();
expect(first.html().includes("admin")).toBe(false);
});
it("Sorts according to last activity", () => {
let component = mount(serverDashboardJsx(mockAsync())).find(
"ServerDashboard"
),
handler = component.find("SortHandler").at(2);
handler.simulate("click");
let first = component.find(".user-row").first();
// foo used most recently
expect(first.html().includes("foo")).toBe(true);
handler.simulate("click");
first = component.find(".user-row").first();
// invert sort - bar used least recently
expect(first.html().includes("bar")).toBe(true);
});
it("Sorts according to server status (running/not running)", () => {
let component = mount(serverDashboardJsx(mockAsync())).find(
"ServerDashboard"
),
handler = component.find("SortHandler").at(3);
handler.simulate("click");
let first = component.find(".user-row").first();
// foo running
expect(first.html().includes("foo")).toBe(true);
handler.simulate("click");
first = component.find(".user-row").first();
// invert sort - bar not running
expect(first.html().includes("bar")).toBe(true);
});
it("Renders nothing if required data is not available", () => {
useSelector.mockImplementation((callback) => {
return callback({});
});
let component = mount(serverDashboardJsx(jest.fn()));
expect(component.html()).toBe("<div></div>");
});
});

View File

@@ -0,0 +1,32 @@
@import url(../../style/root.css);
.server-dashboard-container {
padding-right: 15px;
padding-left: 15px;
margin-right: auto;
margin-left: auto;
}
.server-dashboard-container .add-users-button {
border: 1px solid #ddd;
}
.server-dashboard-container tbody {
color: #626262;
}
.admin-table-head {
user-select: none;
}
.sort-icon {
display: inline-block;
top: 0.125em;
position: relative;
user-select: none;
cursor: pointer;
}
tr.noborder > td {
border: none !important;
}

35
jsx/src/style/root.css Normal file
View File

@@ -0,0 +1,35 @@
:root {
--red: #d7191e;
--orange: #f1ad4e;
--blue: #2e7ab6;
--white: #ffffff;
--gray: #f7f7f7;
}
/* Color Classes */
.red {
background-color: var(--red);
}
.orange {
background-color: var(--orange);
}
.blue {
background-color: var(--blue);
}
.white {
background-color: var(--white);
}
/* Resets */
.resets .modal {
display: block;
visibility: visible;
z-index: 2000;
}
/* Global Util Classes */
.adjacent-span-spacing {
margin-right: 5px;
margin-left: 5px;
}

10
jsx/src/util/jhapiUtil.js Normal file
View File

@@ -0,0 +1,10 @@
export const jhapiRequest = (endpoint, method, data) => {
return fetch("/hub/api" + endpoint, {
method: method,
json: true,
headers: {
"Content-Type": "application/json",
},
body: data ? JSON.stringify(data) : null,
});
};

23
jsx/src/util/timeSince.js Normal file
View File

@@ -0,0 +1,23 @@
export const timeSince = (time) => {
var msPerMinute = 60 * 1000;
var msPerHour = msPerMinute * 60;
var msPerDay = msPerHour * 24;
var msPerMonth = msPerDay * 30;
var msPerYear = msPerDay * 365;
var elapsed = Date.now() - Date.parse(time);
if (elapsed < msPerMinute) {
return Math.round(elapsed / 1000) + " seconds ago";
} else if (elapsed < msPerHour) {
return Math.round(elapsed / msPerMinute) + " minutes ago";
} else if (elapsed < msPerDay) {
return Math.round(elapsed / msPerHour) + " hours ago";
} else if (elapsed < msPerMonth) {
return Math.round(elapsed / msPerDay) + " days ago";
} else if (elapsed < msPerYear) {
return Math.round(elapsed / msPerMonth) + " months ago";
} else {
return Math.round(elapsed / msPerYear) + " years ago";
}
};

53
jsx/src/util/withAPI.js Normal file
View File

@@ -0,0 +1,53 @@
import { withProps } from "recompose";
import { jhapiRequest } from "./jhapiUtil";
const withAPI = withProps(() => ({
updateUsers: (offset, limit) =>
jhapiRequest(`/users?offset=${offset}&limit=${limit}`, "GET").then((data) =>
data.json()
),
updateGroups: (offset, limit) =>
jhapiRequest(
`/groups?offset=${offset}&limit=${limit}`,
"GET"
).then((data) => data.json()),
shutdownHub: () => jhapiRequest("/shutdown", "POST"),
startServer: (name) => jhapiRequest("/users/" + name + "/server", "POST"),
stopServer: (name) => jhapiRequest("/users/" + name + "/server", "DELETE"),
startAll: (names) =>
names.map((e) => jhapiRequest("/users/" + e + "/server", "POST")),
stopAll: (names) =>
names.map((e) => jhapiRequest("/users/" + e + "/server", "DELETE")),
addToGroup: (users, groupname) =>
jhapiRequest("/groups/" + groupname + "/users", "POST", { users }),
removeFromGroup: (users, groupname) =>
jhapiRequest("/groups/" + groupname + "/users", "DELETE", { users }),
createGroup: (groupName) => jhapiRequest("/groups/" + groupName, "POST"),
deleteGroup: (name) => jhapiRequest("/groups/" + name, "DELETE"),
addUsers: (usernames, admin) =>
jhapiRequest("/users", "POST", { usernames, admin }),
editUser: (username, updated_username, admin) =>
jhapiRequest("/users/" + username, "PATCH", {
name: updated_username,
admin,
}),
deleteUser: (username) => jhapiRequest("/users/" + username, "DELETE"),
findUser: (username) => jhapiRequest("/users/" + username, "GET"),
validateUser: (username) =>
jhapiRequest("/users/" + username, "GET")
.then((data) => data.status)
.then((data) => (data > 200 ? false : true)),
failRegexEvent: () =>
alert(
"Cannot change username - either contains special characters or is too short."
),
noChangeEvent: () => {
returns;
},
refreshGroupsData: () =>
jhapiRequest("/groups", "GET").then((data) => data.json()),
refreshUserData: () =>
jhapiRequest("/users", "GET").then((data) => data.json()),
}));
export default withAPI;

97
jsx/webpack.config.js Normal file
View File

@@ -0,0 +1,97 @@
const webpack = require("webpack");
const path = require("path");
const express = require("express");
module.exports = {
entry: path.resolve(__dirname, "src", "App.jsx"),
mode: "production",
module: {
rules: [
{
test: /\.(js|jsx)/,
exclude: /node_modules/,
use: "babel-loader",
},
{
test: /\.(css)/,
exclude: /node_modules/,
use: ["style-loader", "css-loader"],
},
{
test: /\.(png|jpe?g|gif|svg|woff2?|ttf)$/i,
exclude: /node_modules/,
use: "file-loader",
},
],
},
output: {
publicPath: "/",
filename: "admin-react.js",
path: path.resolve(__dirname, "build"),
},
resolve: {
extensions: [".css", ".js", ".jsx"],
},
plugins: [new webpack.HotModuleReplacementPlugin()],
devServer: {
contentBase: path.resolve(__dirname, "build"),
port: 9000,
before: (app, server) => {
var user_data = JSON.parse(
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]'
);
var group_data = JSON.parse(
'[{"kind":"group","name":"testgroup","users":[]}, {"kind":"group","name":"testgroup2","users":["foo", "bar"]}]'
);
app.use(express.json());
// get user_data
app.get("/hub/api/users", (req, res) => {
res
.set("Content-Type", "application/json")
.send(JSON.stringify(user_data));
});
// get group_data
app.get("/hub/api/groups", (req, res) => {
res
.set("Content-Type", "application/json")
.send(JSON.stringify(group_data));
});
// add users to group
app.post("/hub/api/groups/*/users", (req, res) => {
console.log(req.url, req.body);
res.status(200).end();
});
// remove users from group
app.delete("/hub/api/groups/*", (req, res) => {
console.log(req.url, req.body);
res.status(200).end();
});
// add users
app.post("/hub/api/users", (req, res) => {
console.log(req.url, req.body);
res.status(200).end();
});
// delete user
app.delete("/hub/api/users", (req, res) => {
console.log(req.url, req.body);
res.status(200).end();
});
// start user server
app.post("/hub/api/users/*/server", (req, res) => {
console.log(req.url, req.body);
res.status(200).end();
});
// stop user server
app.delete("/hub/api/users/*/server", (req, res) => {
console.log(req.url, req.body);
res.status(200).end();
});
// shutdown hub
app.post("/hub/api/shutdown", (req, res) => {
console.log(req.url, req.body);
res.status(200).end();
});
},
},
};

7961
jsx/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -198,6 +198,14 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
# default: require confirmation # default: require confirmation
return True return True
def get_login_url(self):
"""
Support automatically logging in when JupyterHub is used as auth provider
"""
if self.authenticator.auto_login_oauth2_authorize:
return self.authenticator.login_url(self.hub.base_url)
return super().get_login_url()
@web.authenticated @web.authenticated
async def get(self): async def get(self):
"""GET /oauth/authorization """GET /oauth/authorization

View File

@@ -646,6 +646,26 @@ class Authenticator(LoggingConfigurable):
""", """,
) )
auto_login_oauth2_authorize = Bool(
False,
config=True,
help="""
Automatically begin login process for OAuth2 authorization requests
When another application is using JupyterHub as OAuth2 provider, it
sends users to `/hub/api/oauth2/authorize`. If the user isn't logged
in already, and auto_login is not set, the user will be dumped on the
hub's home page, without any context on what to do next.
Setting this to true will automatically redirect users to login if
they aren't logged in *only* on the `/hub/api/oauth2/authorize`
endpoint.
.. versionadded:: 1.5
""",
)
def login_url(self, base_url): def login_url(self, base_url):
"""Override this when registering a custom login handler """Override this when registering a custom login handler
@@ -952,8 +972,8 @@ class PAMAuthenticator(LocalAuthenticator):
help=""" help="""
Whether to check the user's account status via PAM during authentication. Whether to check the user's account status via PAM during authentication.
The PAM account stack performs non-authentication based account The PAM account stack performs non-authentication based account
management. It is typically used to restrict/permit access to a management. It is typically used to restrict/permit access to a
service and this step is needed to access the host's user access control. service and this step is needed to access the host's user access control.
Disabling this can be dangerous as authenticated but unauthorized users may Disabling this can be dangerous as authenticated but unauthorized users may

View File

@@ -458,82 +458,16 @@ class AdminHandler(BaseHandler):
@needs_scope('admin:users') @needs_scope('admin:users')
@needs_scope('admin:users:servers') @needs_scope('admin:users:servers')
async def get(self): async def get(self):
pagination = Pagination(url=self.request.uri, config=self.config)
page, per_page, offset = pagination.get_page_args(self)
available = {'name', 'admin', 'running', 'last_activity'}
default_sort = ['admin', 'name']
mapping = {'running': orm.Spawner.server_id}
for name in available:
if name not in mapping:
table = orm.User if name != "last_activity" else orm.Spawner
mapping[name] = getattr(table, name)
default_order = {
'name': 'asc',
'last_activity': 'desc',
'admin': 'desc',
'running': 'desc',
}
sorts = self.get_arguments('sort') or default_sort
orders = self.get_arguments('order')
for bad in set(sorts).difference(available):
self.log.warning("ignoring invalid sort: %r", bad)
sorts.remove(bad)
for bad in set(orders).difference({'asc', 'desc'}):
self.log.warning("ignoring invalid order: %r", bad)
orders.remove(bad)
# add default sort as secondary
for s in default_sort:
if s not in sorts:
sorts.append(s)
if len(orders) < len(sorts):
for col in sorts[len(orders) :]:
orders.append(default_order[col])
else:
orders = orders[: len(sorts)]
# this could be one incomprehensible nested list comprehension
# get User columns
cols = [mapping[c] for c in sorts]
# get User.col.desc() order objects
ordered = [getattr(c, o)() for c, o in zip(cols, orders)]
query = self.db.query(orm.User).outerjoin(orm.Spawner).distinct(orm.User.id)
subquery = query.subquery("users")
users = (
self.db.query(orm.User)
.select_entity_from(subquery)
.outerjoin(orm.Spawner)
.order_by(*ordered)
.limit(per_page)
.offset(offset)
)
users = [self._user_from_orm(u) for u in users]
running = []
for u in users:
running.extend(s for s in u.spawners.values() if s.active)
pagination.total = query.count()
auth_state = await self.current_user.get_auth_state() auth_state = await self.current_user.get_auth_state()
html = await self.render_template( html = await self.render_template(
'admin.html', 'admin.html',
current_user=self.current_user, current_user=self.current_user,
auth_state=auth_state, auth_state=auth_state,
admin_access=self.settings.get('admin_access', False), admin_access=self.settings.get('admin_access', False),
users=users,
running=running,
sort={s: o for s, o in zip(sorts, orders)},
allow_named_servers=self.allow_named_servers, allow_named_servers=self.allow_named_servers,
named_server_limit_per_user=self.named_server_limit_per_user, named_server_limit_per_user=self.named_server_limit_per_user,
server_version='{} {}'.format(__version__, self.version_hash), server_version='{} {}'.format(__version__, self.version_hash),
pagination=pagination, api_page_limit=self.settings["app"].api_page_default_limit,
) )
self.finish(html) self.finish(html)

View File

@@ -3,7 +3,7 @@
"version": "0.0.0", "version": "0.0.0",
"description": "JupyterHub nodejs dependencies", "description": "JupyterHub nodejs dependencies",
"author": "Jupyter Developers", "author": "Jupyter Developers",
"license": "BSD", "license": "BSD-3-Clause",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/jupyter/jupyterhub.git" "url": "https://github.com/jupyter/jupyterhub.git"

File diff suppressed because one or more lines are too long

View File

@@ -16,158 +16,12 @@
{% endmacro %} {% endmacro %}
{% block main %} {% block main %}
<div id="react-admin-hook">
<div class="container"> <script id="jupyterhub-admin-config">
<table class="table table-striped"> window.api_page_limit = parseInt("{{ api_page_limit|safe }}")
<thead> </script>
<tr> <script src="static/js/admin-react.js"></script>
{% block thead %}
{{ th("User", 'name') }}
{{ th("Admin", 'admin') }}
{{ th("Last Activity", 'last_activity') }}
{{ th("Running (%i)" % running|length, 'running', colspan=2) }}
{% endblock thead %}
</tr>
</thead>
<tbody>
<tr class="user-row add-user-row">
<td colspan="12">
<a id="add-users" role="button" class="col-xs-2 btn btn-default">Add Users</a>
<span class="col-xs-offset-4 col-xs-3">
<a id="start-all-servers" role="button" class="btn btn-primary col-xs-5 col-xs-offset-1">Start All</a>
<a id="stop-all-servers" role="button" class="btn btn-danger col-xs-5 col-xs-offset-1">Stop All</a>
</span>
<a id="shutdown-hub" role="button" class="col-xs-2 col-xs-offset-1 btn btn-danger">Shutdown Hub</a>
</td>
</tr>
{% for user in users %}
{% for spawner in user.all_spawners() %}
<tr class="user-row server-row" id="user-{{user.name}}" data-user="{{ user.name }}" data-server-name="{{spawner.name}}" data-admin="{{user.admin}}">
{% block user_row scoped %}
<td class="name-col col-sm-2">{{user.name}}
{%- if spawner.name -%}
/{{ spawner.name }}
{%- endif -%}
</td>
<td class="admin-col col-sm-2">
{%- if spawner.name == '' -%}
{% if user.admin %}admin{% endif %}
{%- endif -%}
</td>
<td class="time-col col-sm-3">
{%- if spawner.last_activity -%}
{{ spawner.last_activity.isoformat() + 'Z' }}
{%- else -%}
Never
{%- endif -%}
</td>
<td class="server-col col-sm-2 text-center">
<a role="button" class="stop-server btn btn-xs btn-danger{% if not spawner.active %} hidden{% endif %}">
stop server
</a>
<a role="button" class="start-server btn btn-xs btn-primary{% if spawner.active %} hidden{% endif %}">
start server
</a>
</td>
<td class="server-col col-sm-1 text-center">
{%- if admin_access %}
<a role="button" class="access-server btn btn-xs btn-primary{% if not spawner.active %} hidden{% endif %}">
access server
</a>
{%- endif %}
</td>
<td class="edit-col col-sm-1 text-center">
{%- if spawner.name == '' -%}
<a role="button" class="edit-user btn btn-xs btn-primary">edit user</a>
{%- endif -%}
</td>
<td class="edit-col col-sm-1 text-center">
{%- if spawner.name == '' -%}
{#- user row -#}
{%- if user.name != current_user.name -%}
<a role="button" class="delete-user btn btn-xs btn-danger">delete user</a>
{%- endif -%}
{%- else -%}
{#- named spawner row -#}
<a role="button" class="delete-server btn btn-xs btn-warning">delete server</a>
{%- endif -%}
</td>
{% endblock user_row %}
</tr>
{% endfor %}
{% endfor %}
</tbody>
<tfoot>
<tr class="pagination-row">
<td colspan="3">
{% if pagination.links %}
<div class="pagination menu">{{ pagination.links|safe }}</div>
{% endif %}
</td>
<td colspan="2" class="pagination-page-info">
Displaying users {{ pagination.info.start|safe }} - {{ pagination.info.end|safe }} of {{ pagination.info.total|safe }}
</td>
</tr>
</tfoot>
</table>
</div> </div>
{% call modal('Delete User', btn_class='btn-danger delete-button') %}
Are you sure you want to delete user <span class="delete-username">USER</span>?
This operation cannot be undone.
{% endcall %}
{% call modal('Stop All Servers', btn_label='Stop All', btn_class='btn-danger stop-all-button') %}
Are you sure you want to stop all your users' servers? Kernels will be shutdown and unsaved data may be lost.
{% endcall %}
{% call modal('Start All Servers', btn_label='Start All', btn_class='btn-primary start-all-button') %}
Are you sure you want to start all servers? This can slam your server resources.
{% endcall %}
{% call modal('Shutdown Hub', btn_label='Shutdown', btn_class='btn-danger shutdown-button') %}
Are you sure you want to shutdown the Hub?
You can choose to leave the proxy and/or single-user servers running by unchecking the boxes below:
<div class="checkbox">
<label>
<input type="checkbox" class="shutdown-proxy-checkbox">Shutdown proxy
</label>
</div>
<div class="checkbox">
<label>
<input type="checkbox" class="shutdown-servers-checkbox">Shutdown single-user-servers
</label>
</div>
{% endcall %}
{% macro user_modal(name, multi=False) %}
{% call modal(name, btn_class='btn-primary save-button') %}
<div class="form-group">
<{%- if multi -%}
textarea
{%- else -%}
input type="text"
{%- endif %}
class="form-control username-input"
placeholder="{%- if multi -%} usernames separated by lines{%- else -%} username {%-endif-%}">
{%- if multi -%}</textarea>{%- endif -%}
</div>
<div class="checkbox">
<label>
<input type="checkbox" class="admin-checkbox">Admin
</label>
</div>
{% endcall %}
{% endmacro %}
{{ user_modal('Edit User') }}
{{ user_modal('Add Users', multi=True) }}
{% endblock %} {% endblock %}
{% block footer %} {% block footer %}