mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-17 23:13:00 +00:00
sync rbac with main
# Conflicts: # docs/rest-api.yml # jupyterhub/oauth/provider.py
This commit is contained in:
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@@ -5,6 +5,8 @@ name: Release
|
||||
# but only publish to PyPI on tags
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "!dependabot/**"
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
@@ -130,6 +132,7 @@ jobs:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub:"
|
||||
defaultTag: "${{ env.REGISTRY }}jupyterhub/jupyterhub:noref"
|
||||
branchRegex: ^\w[\w-.]*$
|
||||
|
||||
- name: Build and push jupyterhub
|
||||
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
|
||||
@@ -150,6 +153,7 @@ jobs:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-onbuild:"
|
||||
defaultTag: "${{ env.REGISTRY }}jupyterhub/jupyterhub-onbuild:noref"
|
||||
branchRegex: ^\w[\w-.]*$
|
||||
|
||||
- name: Build and push jupyterhub-onbuild
|
||||
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
|
||||
@@ -170,6 +174,7 @@ jobs:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-demo:"
|
||||
defaultTag: "${{ env.REGISTRY }}jupyterhub/jupyterhub-demo:noref"
|
||||
branchRegex: ^\w[\w-.]*$
|
||||
|
||||
- name: Build and push jupyterhub-demo
|
||||
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
|
||||
|
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -140,6 +140,7 @@ jobs:
|
||||
run: |
|
||||
npm install
|
||||
npm install -g configurable-http-proxy
|
||||
npm install -g yarn
|
||||
npm list
|
||||
|
||||
# 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
|
||||
run: |
|
||||
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
|
||||
run: |
|
||||
codecov
|
||||
|
@@ -1 +1,2 @@
|
||||
share/jupyterhub/templates/
|
||||
share/jupyterhub/static/js/admin-react.js
|
||||
|
14
README.md
14
README.md
@@ -6,6 +6,20 @@
|
||||
**[License](#license)** |
|
||||
**[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)
|
||||
|
||||
[](https://pypi.python.org/pypi/jupyterhub)
|
||||
|
@@ -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
|
||||
|
||||
### 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
|
||||
|
||||
- Advanced Computing
|
||||
|
44
jsx/.eslintrc.json
Normal file
44
jsx/.eslintrc.json
Normal 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
2
jsx/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
build/admin-react.js
|
64
jsx/README.md
Normal file
64
jsx/README.md
Normal 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));
|
||||
});
|
||||
```
|
47
jsx/build/admin-react.js.LICENSE.txt
Normal file
47
jsx/build/admin-react.js.LICENSE.txt
Normal 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
6
jsx/build/index.html
Normal 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
67
jsx/package.json
Normal 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
78
jsx/src/App.jsx
Normal 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
28
jsx/src/Store.js
Normal 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;
|
||||
}
|
||||
};
|
125
jsx/src/components/AddUser/AddUser.jsx
Normal file
125
jsx/src/components/AddUser/AddUser.jsx
Normal 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;
|
77
jsx/src/components/AddUser/AddUser.test.js
Normal file
77
jsx/src/components/AddUser/AddUser.test.js
Normal 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);
|
||||
});
|
||||
});
|
104
jsx/src/components/CreateGroup/CreateGroup.jsx
Normal file
104
jsx/src/components/CreateGroup/CreateGroup.jsx
Normal 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;
|
66
jsx/src/components/CreateGroup/CreateGroup.test.js
Normal file
66
jsx/src/components/CreateGroup/CreateGroup.test.js
Normal 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);
|
||||
});
|
||||
});
|
190
jsx/src/components/EditUser/EditUser.jsx
Normal file
190
jsx/src/components/EditUser/EditUser.jsx
Normal 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;
|
80
jsx/src/components/EditUser/EditUser.test.js
Normal file
80
jsx/src/components/EditUser/EditUser.test.js
Normal 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);
|
||||
});
|
||||
});
|
144
jsx/src/components/GroupEdit/GroupEdit.jsx
Normal file
144
jsx/src/components/GroupEdit/GroupEdit.jsx
Normal 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;
|
100
jsx/src/components/GroupEdit/GroupEdit.test.jsx
Normal file
100
jsx/src/components/GroupEdit/GroupEdit.test.jsx
Normal 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");
|
||||
});
|
||||
});
|
108
jsx/src/components/GroupSelect/GroupSelect.jsx
Normal file
108
jsx/src/components/GroupSelect/GroupSelect.jsx
Normal 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;
|
40
jsx/src/components/GroupSelect/group-select.css
Normal file
40
jsx/src/components/GroupSelect/group-select.css
Normal 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;
|
||||
}
|
117
jsx/src/components/Groups/Groups.jsx
Normal file
117
jsx/src/components/Groups/Groups.jsx
Normal 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;
|
65
jsx/src/components/Groups/Groups.test.js
Normal file
65
jsx/src/components/Groups/Groups.test.js
Normal 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>");
|
||||
});
|
||||
});
|
50
jsx/src/components/PaginationFooter/PaginationFooter.jsx
Normal file
50
jsx/src/components/PaginationFooter/PaginationFooter.jsx
Normal 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;
|
14
jsx/src/components/PaginationFooter/pagination-footer.css
Normal file
14
jsx/src/components/PaginationFooter/pagination-footer.css
Normal 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);
|
||||
}
|
308
jsx/src/components/ServerDashboard/ServerDashboard.jsx
Normal file
308
jsx/src/components/ServerDashboard/ServerDashboard.jsx
Normal 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;
|
161
jsx/src/components/ServerDashboard/ServerDashboard.test.js
Normal file
161
jsx/src/components/ServerDashboard/ServerDashboard.test.js
Normal 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>");
|
||||
});
|
||||
});
|
32
jsx/src/components/ServerDashboard/server-dashboard.css
Normal file
32
jsx/src/components/ServerDashboard/server-dashboard.css
Normal 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
35
jsx/src/style/root.css
Normal 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
10
jsx/src/util/jhapiUtil.js
Normal 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
23
jsx/src/util/timeSince.js
Normal 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
53
jsx/src/util/withAPI.js
Normal 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
97
jsx/webpack.config.js
Normal 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
7961
jsx/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@@ -198,6 +198,14 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
|
||||
# default: require confirmation
|
||||
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
|
||||
async def get(self):
|
||||
"""GET /oauth/authorization
|
||||
|
@@ -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):
|
||||
"""Override this when registering a custom login handler
|
||||
|
||||
|
@@ -458,82 +458,16 @@ class AdminHandler(BaseHandler):
|
||||
@needs_scope('admin:users')
|
||||
@needs_scope('admin:users:servers')
|
||||
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()
|
||||
html = await self.render_template(
|
||||
'admin.html',
|
||||
current_user=self.current_user,
|
||||
auth_state=auth_state,
|
||||
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,
|
||||
named_server_limit_per_user=self.named_server_limit_per_user,
|
||||
server_version='{} {}'.format(__version__, self.version_hash),
|
||||
pagination=pagination,
|
||||
api_page_limit=self.settings["app"].api_page_default_limit,
|
||||
)
|
||||
self.finish(html)
|
||||
|
||||
|
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.0",
|
||||
"description": "JupyterHub nodejs dependencies",
|
||||
"author": "Jupyter Developers",
|
||||
"license": "BSD",
|
||||
"license": "BSD-3-Clause",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/jupyter/jupyterhub.git"
|
||||
|
2
share/jupyterhub/static/js/admin-react.js
Normal file
2
share/jupyterhub/static/js/admin-react.js
Normal file
File diff suppressed because one or more lines are too long
@@ -16,158 +16,12 @@
|
||||
{% endmacro %}
|
||||
|
||||
{% block main %}
|
||||
|
||||
<div class="container">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
{% 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 id="react-admin-hook">
|
||||
<script id="jupyterhub-admin-config">
|
||||
window.api_page_limit = parseInt("{{ api_page_limit|safe }}")
|
||||
</script>
|
||||
<script src="static/js/admin-react.js"></script>
|
||||
</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 %}
|
||||
|
||||
{% block footer %}
|
||||
|
Reference in New Issue
Block a user