mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-13 04:53:01 +00:00
Add React Admin and modify AdminHandler
This commit is contained in:
2
jsx/.gitignore
vendored
Normal file
2
jsx/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
build/admin-react.jsx
|
25
jsx/README.md
Normal file
25
jsx/README.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Jupyterhub Admin Dashboard - React Variant
|
||||||
|
This repository contains current updates to the Jupyterhub Admin Dashboard service,
|
||||||
|
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
|
||||||
|
|
||||||
|
### Directory Tree
|
||||||
|
```
|
||||||
|
jhadmin/
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
admin-react-fe/
|
||||||
|
package.json
|
||||||
|
webpack.config.json
|
||||||
|
yarn.lock
|
||||||
|
build/
|
||||||
|
admin.fe.js
|
||||||
|
index.html
|
||||||
|
src/
|
||||||
|
App.jsx
|
||||||
|
```
|
4337
jsx/build/admin-react.js
Normal file
4337
jsx/build/admin-react.js
Normal file
File diff suppressed because one or more lines are too long
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": "MIT",
|
||||||
|
"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",
|
||||||
|
"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",
|
||||||
|
"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",
|
||||||
|
"react-test-renderer": "^17.0.1"
|
||||||
|
}
|
||||||
|
}
|
54
jsx/src/App.jsx
Normal file
54
jsx/src/App.jsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import React, { Component } from "react";
|
||||||
|
import ReactDOM from "react-dom";
|
||||||
|
import { Provider } from "react-redux";
|
||||||
|
import { createStore } from "redux";
|
||||||
|
import { Button } from "react-bootstrap";
|
||||||
|
import { initialState, reducers } from "./Store";
|
||||||
|
import { jhapiRequest } from "./util/jhapiUtil";
|
||||||
|
import { HashRouter, Switch, Route, Link } from "react-router-dom";
|
||||||
|
import { createBrowserHistory } from "history";
|
||||||
|
|
||||||
|
import ServerDashboard from "./components/ServerDashboard/ServerDashboard";
|
||||||
|
import Groups from "./components/Groups/Groups";
|
||||||
|
import GroupEdit from "./components/GroupEdit/GroupEdit";
|
||||||
|
import AddUser from "./components/AddUser/AddUser";
|
||||||
|
import EditUser from "./components/EditUser/EditUser";
|
||||||
|
|
||||||
|
import "./style/root.css";
|
||||||
|
|
||||||
|
const store = createStore(reducers, initialState),
|
||||||
|
routerHistory = createBrowserHistory();
|
||||||
|
|
||||||
|
class App extends Component {
|
||||||
|
componentDidMount() {
|
||||||
|
jhapiRequest("/users", "GET")
|
||||||
|
.then((data) => data.json())
|
||||||
|
.then((data) => store.dispatch({ type: "USER_DATA", value: data }))
|
||||||
|
.catch((err) => console.log(err));
|
||||||
|
|
||||||
|
jhapiRequest("/groups", "GET")
|
||||||
|
.then((data) => data.json())
|
||||||
|
.then((data) => store.dispatch({ type: "GROUPS_DATA", value: data }))
|
||||||
|
.catch((err) => console.log(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div className="resets">
|
||||||
|
<Provider store={store}>
|
||||||
|
<HashRouter>
|
||||||
|
<Switch>
|
||||||
|
<Route exact path="/" component={ServerDashboard} />
|
||||||
|
<Route exact path="/groups" component={Groups} />
|
||||||
|
<Route exact path="/group-edit" component={GroupEdit} />
|
||||||
|
<Route exact path="/add-users" component={AddUser} />
|
||||||
|
<Route exact path="/edit-user" component={EditUser} />
|
||||||
|
</Switch>
|
||||||
|
</HashRouter>
|
||||||
|
</Provider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.render(<App />, document.getElementById("react-admin-hook"));
|
22
jsx/src/Store.js
Normal file
22
jsx/src/Store.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { combineReducers } from "redux";
|
||||||
|
|
||||||
|
export const initialState = {
|
||||||
|
user_data: undefined,
|
||||||
|
groups_data: undefined,
|
||||||
|
manage_groups_modal: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reducers = (state = initialState, action) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "USER_DATA":
|
||||||
|
return Object.assign({}, state, { user_data: action.value });
|
||||||
|
case "GROUPS_DATA":
|
||||||
|
return Object.assign({}, state, { groups_data: action.value });
|
||||||
|
case "TOGGLE_MANAGE_GROUPS_MODAL":
|
||||||
|
return Object.assign({}, state, {
|
||||||
|
manage_groups_modal: !state.manage_groups_modal,
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
21
jsx/src/components/AddUser/AddUser.js
Normal file
21
jsx/src/components/AddUser/AddUser.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { connect } from "react-redux";
|
||||||
|
import { compose, withProps } from "recompose";
|
||||||
|
import { jhapiRequest } from "../../util/jhapiUtil";
|
||||||
|
import { AddUser } from "./AddUser.pre";
|
||||||
|
|
||||||
|
const withUserAPI = withProps((props) => ({
|
||||||
|
addUsers: (usernames, admin) =>
|
||||||
|
jhapiRequest("/users", "POST", { usernames, admin }),
|
||||||
|
failRegexEvent: () =>
|
||||||
|
alert(
|
||||||
|
"Removed " +
|
||||||
|
JSON.stringify(removed_users) +
|
||||||
|
" for either containing special characters or being too short."
|
||||||
|
),
|
||||||
|
refreshUserData: () =>
|
||||||
|
jhapiRequest("/users", "GET")
|
||||||
|
.then((data) => data.json())
|
||||||
|
.then((data) => props.dispatch({ type: "USER_DATA", value: data })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default compose(connect(), withUserAPI)(AddUser);
|
116
jsx/src/components/AddUser/AddUser.pre.jsx
Normal file
116
jsx/src/components/AddUser/AddUser.pre.jsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import React, { Component } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
|
export class AddUser extends Component {
|
||||||
|
static get propTypes() {
|
||||||
|
return {
|
||||||
|
addUsers: PropTypes.func,
|
||||||
|
failRegexEvent: PropTypes.func,
|
||||||
|
refreshUserData: PropTypes.func,
|
||||||
|
dispatch: PropTypes.func,
|
||||||
|
history: PropTypes.shape({
|
||||||
|
push: PropTypes.func,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
users: [],
|
||||||
|
admin: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
var { addUsers, failRegexEvent, refreshUserData, dispatch } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="container">
|
||||||
|
<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");
|
||||||
|
this.setState(
|
||||||
|
Object.assign({}, this.state, {
|
||||||
|
users: split_users,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
></textarea>
|
||||||
|
<br></br>
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
value=""
|
||||||
|
id="admin-check"
|
||||||
|
onChange={(e) =>
|
||||||
|
this.setState(
|
||||||
|
Object.assign({}, this.state, {
|
||||||
|
admin: 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 = this.state.users.filter(
|
||||||
|
(e) =>
|
||||||
|
e.length > 2 &&
|
||||||
|
/[!@#$%^&*(),.?":{}|<>]/g.test(e) == false
|
||||||
|
);
|
||||||
|
if (filtered_users.length < this.state.users.length) {
|
||||||
|
let removed_users = this.state.users.filter(
|
||||||
|
(e) => !filtered_users.includes(e)
|
||||||
|
);
|
||||||
|
this.setState(
|
||||||
|
Object.assign({}, this.state, {
|
||||||
|
users: filtered_users,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
failRegexEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
addUsers(filtered_users, this.state.admin)
|
||||||
|
.then(() => refreshUserData())
|
||||||
|
.then(() => this.props.history.push("/"))
|
||||||
|
.catch((err) => console.log(err));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Users
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
56
jsx/src/components/AddUser/AddUser.test.js
Normal file
56
jsx/src/components/AddUser/AddUser.test.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Enzyme, { shallow } from "enzyme";
|
||||||
|
import { AddUser } from "./AddUser.pre";
|
||||||
|
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
|
||||||
|
|
||||||
|
Enzyme.configure({ adapter: new Adapter() });
|
||||||
|
|
||||||
|
describe("AddUser Component: ", () => {
|
||||||
|
var mockAsync = () =>
|
||||||
|
jest.fn().mockImplementation(() => Promise.resolve({ key: "value" }));
|
||||||
|
|
||||||
|
var addUserJsx = (callbackSpy) => (
|
||||||
|
<AddUser
|
||||||
|
addUsers={callbackSpy}
|
||||||
|
failRegexEvent={callbackSpy}
|
||||||
|
refreshUserData={callbackSpy}
|
||||||
|
history={{ push: (a) => {} }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
it("Updates state.users on textbox blur", () => {
|
||||||
|
let component = shallow(addUserJsx(mockAsync())),
|
||||||
|
textarea = component.find("textarea");
|
||||||
|
textarea.simulate("blur", { target: { value: "foo" } });
|
||||||
|
expect(JSON.stringify(component.state("users"))).toBe('["foo"]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Can separate newline spaced names into an array", () => {
|
||||||
|
let component = shallow(addUserJsx(mockAsync())),
|
||||||
|
textarea = component.find("textarea");
|
||||||
|
textarea.simulate("blur", { target: { value: "foo\nbar\nsoforth" } });
|
||||||
|
expect(JSON.stringify(component.state("users"))).toBe(
|
||||||
|
'["foo","bar","soforth"]'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Deliminates names with special / less than 3 characters", () => {
|
||||||
|
let component = shallow(addUserJsx(mockAsync())),
|
||||||
|
textarea = component.find("textarea"),
|
||||||
|
submit = component.find("#submit");
|
||||||
|
textarea.simulate("blur", {
|
||||||
|
target: { value: "foo\nbar\nb%%%\n$andy\nhalfdecent" },
|
||||||
|
});
|
||||||
|
submit.simulate("click");
|
||||||
|
expect(JSON.stringify(component.state("users"))).toBe(
|
||||||
|
'["foo","bar","halfdecent"]'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Recognizes admin user selection", () => {
|
||||||
|
let component = shallow(addUserJsx(mockAsync())),
|
||||||
|
admin = component.find("#admin-check");
|
||||||
|
admin.simulate("change", { target: { checked: true } });
|
||||||
|
expect(component.state("admin")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
25
jsx/src/components/EditUser/EditUser.js
Normal file
25
jsx/src/components/EditUser/EditUser.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { connect } from "react-redux";
|
||||||
|
import { compose, withProps } from "recompose";
|
||||||
|
import { jhapiRequest } from "../../util/jhapiUtil";
|
||||||
|
import { EditUser } from "./EditUser.pre";
|
||||||
|
|
||||||
|
const withUserAPI = withProps((props) => ({
|
||||||
|
editUser: (username, updated_username, admin) =>
|
||||||
|
jhapiRequest("/users/" + username, "PATCH", {
|
||||||
|
name: updated_username,
|
||||||
|
admin,
|
||||||
|
}),
|
||||||
|
deleteUser: (username) => jhapiRequest("/users/" + username, "DELETE"),
|
||||||
|
failRegexEvent: () =>
|
||||||
|
alert(
|
||||||
|
"Removed " +
|
||||||
|
JSON.stringify(removed_users) +
|
||||||
|
" for either containing special characters or being too short."
|
||||||
|
),
|
||||||
|
refreshUserData: () =>
|
||||||
|
jhapiRequest("/users", "GET")
|
||||||
|
.then((data) => data.json())
|
||||||
|
.then((data) => props.dispatch({ type: "USER_DATA", value: data })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default compose(connect(), withUserAPI)(EditUser);
|
152
jsx/src/components/EditUser/EditUser.pre.jsx
Normal file
152
jsx/src/components/EditUser/EditUser.pre.jsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import React, { Component } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
|
export class EditUser extends Component {
|
||||||
|
static get propTypes() {
|
||||||
|
return {
|
||||||
|
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,
|
||||||
|
refreshUserData: PropTypes.func,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
updated_username: null,
|
||||||
|
admin: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.props.location.state == undefined) {
|
||||||
|
this.props.history.push("/");
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
var { username, has_admin } = this.props.location.state;
|
||||||
|
|
||||||
|
var { editUser, deleteUser, failRegexEvent, refreshUserData } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="container">
|
||||||
|
<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"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
this.setState(
|
||||||
|
Object.assign({}, this.state, {
|
||||||
|
updated_username: e.target.value,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
></textarea>
|
||||||
|
<br></br>
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
checked={has_admin ? true : false}
|
||||||
|
type="checkbox"
|
||||||
|
value=""
|
||||||
|
id="admin-check"
|
||||||
|
onChange={(e) =>
|
||||||
|
this.setState(
|
||||||
|
Object.assign({}, this.state, {
|
||||||
|
admin: e.target.checked,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<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) => {
|
||||||
|
this.props.history.push("/");
|
||||||
|
refreshUserData();
|
||||||
|
})
|
||||||
|
.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={() => {
|
||||||
|
let updated_username = this.state.updated_username,
|
||||||
|
admin = this.state.admin;
|
||||||
|
|
||||||
|
if (updated_username == null && admin == null) return;
|
||||||
|
if (
|
||||||
|
updated_username.length > 2 &&
|
||||||
|
/[!@#$%^&*(),.?":{}|<>]/g.test(updated_username) ==
|
||||||
|
false
|
||||||
|
) {
|
||||||
|
editUser(
|
||||||
|
username,
|
||||||
|
updated_username != null
|
||||||
|
? updated_username
|
||||||
|
: username,
|
||||||
|
admin != null ? admin : has_admin
|
||||||
|
)
|
||||||
|
.then((data) => {
|
||||||
|
this.props.history.push("/");
|
||||||
|
refreshUserData();
|
||||||
|
})
|
||||||
|
.catch((err) => {});
|
||||||
|
} else {
|
||||||
|
this.setState(
|
||||||
|
Object.assign({}, this.state, {
|
||||||
|
updated_username: "",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
failRegexEvent();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
63
jsx/src/components/EditUser/EditUser.test.js
Normal file
63
jsx/src/components/EditUser/EditUser.test.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Enzyme, { shallow } from "enzyme";
|
||||||
|
import { EditUser } from "./EditUser.pre";
|
||||||
|
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
|
||||||
|
|
||||||
|
Enzyme.configure({ adapter: new Adapter() });
|
||||||
|
|
||||||
|
describe("EditUser Component: ", () => {
|
||||||
|
var mockAsync = () =>
|
||||||
|
jest.fn().mockImplementation(() => Promise.resolve({ key: "value" }));
|
||||||
|
var mockSync = () => jest.fn();
|
||||||
|
|
||||||
|
var editUserJsx = (callbackSpy) => (
|
||||||
|
<EditUser
|
||||||
|
location={{ state: { username: "foo", has_admin: false } }}
|
||||||
|
deleteUser={callbackSpy}
|
||||||
|
editUser={callbackSpy}
|
||||||
|
refreshUserData={mockSync()}
|
||||||
|
history={{ push: (a) => {} }}
|
||||||
|
failRegexEvent={callbackSpy}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
it("Updates the state whenever a key is pressed on the textarea", () => {
|
||||||
|
let component = shallow(editUserJsx(mockAsync())),
|
||||||
|
textarea = component.find("textarea");
|
||||||
|
textarea.simulate("keydown", { target: { value: "test" } });
|
||||||
|
expect(component.state("updated_username")).toBe("test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Updates the state whenever the admin box changes", () => {
|
||||||
|
let component = shallow(editUserJsx(mockAsync())),
|
||||||
|
admin = component.find("#admin-check");
|
||||||
|
admin.simulate("change", { target: { checked: true } });
|
||||||
|
expect(component.state("admin")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Delimits the input from the textarea", () => {
|
||||||
|
let component = shallow(editUserJsx(mockAsync())),
|
||||||
|
submit = component.find("#submit");
|
||||||
|
component.setState({ updated_username: "%!@$#&" });
|
||||||
|
submit.simulate("click");
|
||||||
|
expect(component.state("updated_username")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Calls the delete user function when the button is pressed", () => {
|
||||||
|
let callbackSpy = mockAsync(),
|
||||||
|
component = shallow(editUserJsx(callbackSpy)),
|
||||||
|
deleteUser = component.find("#delete-user");
|
||||||
|
deleteUser.simulate("click");
|
||||||
|
expect(callbackSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Submits the edits when the button is pressed", () => {
|
||||||
|
let callbackSpy = mockAsync(),
|
||||||
|
component = shallow(editUserJsx(callbackSpy)),
|
||||||
|
submit = component.find("#submit"),
|
||||||
|
textarea = component.find("textarea");
|
||||||
|
textarea.simulate("keydown", { target: { value: "test" } });
|
||||||
|
submit.simulate("click");
|
||||||
|
expect(callbackSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
13
jsx/src/components/GroupEdit/GroupEdit.js
Normal file
13
jsx/src/components/GroupEdit/GroupEdit.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { connect } from "react-redux";
|
||||||
|
import { compose, withProps } from "recompose";
|
||||||
|
import { jhapiRequest } from "../../util/jhapiUtil";
|
||||||
|
import { GroupEdit } from "./GroupEdit.pre";
|
||||||
|
|
||||||
|
const withGroupsAPI = withProps((props) => ({
|
||||||
|
addToGroup: (users, groupname) =>
|
||||||
|
jhapiRequest("/groups/" + groupname + "/users", "POST", { users }),
|
||||||
|
removeFromGroup: (users, groupname) =>
|
||||||
|
jhapiRequest("/groups/" + groupname + "/users", "DELETE", { users }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default compose(connect(), withGroupsAPI)(GroupEdit);
|
113
jsx/src/components/GroupEdit/GroupEdit.pre.jsx
Normal file
113
jsx/src/components/GroupEdit/GroupEdit.pre.jsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import React, { Component } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import Multiselect from "../Multiselect/Multiselect";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
|
export class GroupEdit extends Component {
|
||||||
|
static get propTypes() {
|
||||||
|
return {
|
||||||
|
location: PropTypes.shape({
|
||||||
|
state: PropTypes.shape({
|
||||||
|
group_data: PropTypes.object,
|
||||||
|
user_data: PropTypes.array,
|
||||||
|
callback: PropTypes.func,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
history: PropTypes.shape({
|
||||||
|
push: PropTypes.func,
|
||||||
|
}),
|
||||||
|
addToGroup: PropTypes.func,
|
||||||
|
removeFromGroup: PropTypes.func,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
selected: [],
|
||||||
|
changed: false,
|
||||||
|
added: undefined,
|
||||||
|
removed: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.props.location.state) {
|
||||||
|
this.props.history.push("/groups");
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
var { group_data, user_data, callback } = this.props.location.state;
|
||||||
|
|
||||||
|
var { addToGroup, removeFromGroup } = this.props;
|
||||||
|
|
||||||
|
if (!(group_data && user_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">Select group members</div>
|
||||||
|
<Multiselect
|
||||||
|
options={user_data.map((e) => e.name)}
|
||||||
|
value={group_data.users}
|
||||||
|
onChange={(selection, options) => {
|
||||||
|
this.setState({ selected: selection, changed: true });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<br></br>
|
||||||
|
<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 (!this.state.changed) {
|
||||||
|
this.props.history.push("/groups");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_users = this.state.selected.filter(
|
||||||
|
(e) => !group_data.users.includes(e)
|
||||||
|
);
|
||||||
|
let removed_users = group_data.users.filter(
|
||||||
|
(e) => !this.state.selected.includes(e)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.setState(
|
||||||
|
Object.assign({}, this.state, {
|
||||||
|
added: new_users,
|
||||||
|
removed: removed_users,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
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((e) => callback())
|
||||||
|
.catch((err) => console.log(err));
|
||||||
|
|
||||||
|
this.props.history.push("/groups");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
<br></br>
|
||||||
|
<br></br>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
32
jsx/src/components/GroupEdit/GroupEdit.test.jsx
Normal file
32
jsx/src/components/GroupEdit/GroupEdit.test.jsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Enzyme, { shallow } from "enzyme";
|
||||||
|
import { GroupEdit } from "./GroupEdit.pre";
|
||||||
|
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
|
||||||
|
|
||||||
|
Enzyme.configure({ adapter: new Adapter() });
|
||||||
|
|
||||||
|
describe("GroupEdit Component: ", () => {
|
||||||
|
var groupEditJsx = () => (
|
||||||
|
<GroupEdit
|
||||||
|
location={{
|
||||||
|
state: {
|
||||||
|
user_data: [{ name: "foo" }, { name: "bar" }],
|
||||||
|
group_data: { users: ["foo"] },
|
||||||
|
callback: () => {},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
addToGroup={() => {}}
|
||||||
|
removeFromGroup={() => {}}
|
||||||
|
history={{ push: (a) => {} }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
it("Can cleanly separate added and removed users", () => {
|
||||||
|
let component = shallow(groupEditJsx()),
|
||||||
|
submit = component.find("#submit");
|
||||||
|
component.setState({ selected: ["bar"], changed: true });
|
||||||
|
submit.simulate("click");
|
||||||
|
expect(component.state("added")[0]).toBe("bar");
|
||||||
|
expect(component.state("removed")[0]).toBe("foo");
|
||||||
|
});
|
||||||
|
});
|
33
jsx/src/components/Groups/Groups.js
Normal file
33
jsx/src/components/Groups/Groups.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { compose, withProps } from "recompose";
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import { jhapiRequest } from "../../util/jhapiUtil";
|
||||||
|
import { Groups } from "./Groups.pre";
|
||||||
|
|
||||||
|
const withGroupsAPI = withProps((props) => ({
|
||||||
|
refreshGroupsData: () =>
|
||||||
|
jhapiRequest("/groups", "GET")
|
||||||
|
.then((data) => data.json())
|
||||||
|
.then((data) => props.dispatch({ type: "GROUPS_DATA", value: data })),
|
||||||
|
refreshUserData: () =>
|
||||||
|
jhapiRequest("/users", "GET")
|
||||||
|
.then((data) => data.json())
|
||||||
|
.then((data) => props.dispatch({ type: "USER_DATA", value: data })),
|
||||||
|
addUsersToGroup: (name, new_users) =>
|
||||||
|
jhapiRequest("/groups/" + name + "/users", "POST", {
|
||||||
|
body: { users: new_users },
|
||||||
|
json: true,
|
||||||
|
}),
|
||||||
|
removeUsersFromGroup: (name, removed_users) =>
|
||||||
|
jhapiRequest("/groups/" + name + "/users", "DELETE", {
|
||||||
|
body: { users: removed_users },
|
||||||
|
json: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default compose(
|
||||||
|
connect((state) => ({
|
||||||
|
user_data: state.user_data,
|
||||||
|
groups_data: state.groups_data,
|
||||||
|
})),
|
||||||
|
withGroupsAPI
|
||||||
|
)(Groups);
|
73
jsx/src/components/Groups/Groups.pre.jsx
Normal file
73
jsx/src/components/Groups/Groups.pre.jsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import React, { Component } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
|
export class Groups extends Component {
|
||||||
|
static get propTypes() {
|
||||||
|
return {
|
||||||
|
user_data: PropTypes.array,
|
||||||
|
groups_data: PropTypes.array,
|
||||||
|
refreshUserData: PropTypes.func,
|
||||||
|
refreshGroupsData: PropTypes.func,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
var {
|
||||||
|
user_data,
|
||||||
|
groups_data,
|
||||||
|
refreshGroupsData,
|
||||||
|
refreshUserData,
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
if (!groups_data || !user_data) {
|
||||||
|
return <div></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
{groups_data.map((e, i) => (
|
||||||
|
<div key={"group-edit" + i} className="group-edit-link">
|
||||||
|
<h4>
|
||||||
|
<Link
|
||||||
|
to={{
|
||||||
|
pathname: "/group-edit",
|
||||||
|
state: {
|
||||||
|
group_data: e,
|
||||||
|
user_data: user_data,
|
||||||
|
callback: () => {
|
||||||
|
refreshGroupsData();
|
||||||
|
refreshUserData();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{e.name}
|
||||||
|
</Link>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="panel-footer">
|
||||||
|
<div className="btn btn-light">
|
||||||
|
<Link to="/">Back</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
30
jsx/src/components/Groups/Groups.test.js
Normal file
30
jsx/src/components/Groups/Groups.test.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Enzyme, { shallow } from "enzyme";
|
||||||
|
import { Groups } from "./Groups.pre";
|
||||||
|
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
|
||||||
|
|
||||||
|
Enzyme.configure({ adapter: new Adapter() });
|
||||||
|
|
||||||
|
describe("Groups Component: ", () => {
|
||||||
|
var groupsJsx = () => (
|
||||||
|
<Groups
|
||||||
|
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"]}]'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
it("Renders groups_data prop into links", () => {
|
||||||
|
let component = shallow(groupsJsx()),
|
||||||
|
links = component.find(".group-edit-link");
|
||||||
|
expect(links.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Renders nothing if required data is not available", () => {
|
||||||
|
let component = shallow(<Groups />);
|
||||||
|
expect(component.html()).toBe("<div></div>");
|
||||||
|
});
|
||||||
|
});
|
69
jsx/src/components/Multiselect/Multiselect.jsx
Normal file
69
jsx/src/components/Multiselect/Multiselect.jsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import React, { Component } from "react";
|
||||||
|
import "./multi-select.css";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
|
export default class Multiselect extends Component {
|
||||||
|
static get propTypes() {
|
||||||
|
return {
|
||||||
|
value: PropTypes.array,
|
||||||
|
onChange: PropTypes.func,
|
||||||
|
options: PropTypes.array,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
selected: props.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
var { onChange, options, value } = this.props;
|
||||||
|
|
||||||
|
if (!options) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="multi-container">
|
||||||
|
<div>
|
||||||
|
{this.state.selected.map((e, i) => (
|
||||||
|
<div
|
||||||
|
key={"selected" + i}
|
||||||
|
className="item selected"
|
||||||
|
onClick={() => {
|
||||||
|
let updated_selection = this.state.selected
|
||||||
|
.slice(0, i)
|
||||||
|
.concat(this.state.selected.slice(i + 1));
|
||||||
|
onChange(updated_selection, options);
|
||||||
|
this.setState(
|
||||||
|
Object.assign({}, this.state, { selected: updated_selection })
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{e}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{options.map((e, i) =>
|
||||||
|
this.state.selected.includes(e) ? undefined : (
|
||||||
|
<div
|
||||||
|
key={"unselected" + i}
|
||||||
|
className="item unselected"
|
||||||
|
onClick={() => {
|
||||||
|
let updated_selection = this.state.selected.concat([e]);
|
||||||
|
onChange(updated_selection, options);
|
||||||
|
this.setState(
|
||||||
|
Object.assign({}, this.state, {
|
||||||
|
selected: updated_selection,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{e}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
50
jsx/src/components/Multiselect/Multiselect.test.js
Normal file
50
jsx/src/components/Multiselect/Multiselect.test.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Enzyme, { shallow } from "enzyme";
|
||||||
|
import Multiselect from "./Multiselect";
|
||||||
|
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
|
||||||
|
|
||||||
|
Enzyme.configure({ adapter: new Adapter() });
|
||||||
|
|
||||||
|
describe("Multiselect Component: ", () => {
|
||||||
|
var multiselectJsx = () => (
|
||||||
|
<Multiselect
|
||||||
|
options={["foo", "bar", "wombat"]}
|
||||||
|
value={["wombat"]}
|
||||||
|
onChange={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
it("Renders with initial value selected", () => {
|
||||||
|
let component = shallow(multiselectJsx()),
|
||||||
|
selected = component.state("selected");
|
||||||
|
expect(selected.length == 1 && selected[0] == "wombat").toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Deselects a value when div.item.selected is clicked", () => {
|
||||||
|
let component = shallow(multiselectJsx()),
|
||||||
|
selected = component.find(".item.selected").first();
|
||||||
|
selected.simulate("click");
|
||||||
|
expect(component.state("selected").length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Selects a an option when div.item.unselected is clicked", () => {
|
||||||
|
let component = shallow(multiselectJsx()),
|
||||||
|
unselected = component.find(".item.unselected").first();
|
||||||
|
unselected.simulate("click");
|
||||||
|
expect(component.state("selected").length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Triggers callback on any sort of change", () => {
|
||||||
|
let callbackSpy = jest.fn(),
|
||||||
|
component = shallow(
|
||||||
|
<Multiselect
|
||||||
|
options={["foo", "bar", "wombat"]}
|
||||||
|
value={["wombat"]}
|
||||||
|
onChange={callbackSpy}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
selected = component.find(".item.selected").first();
|
||||||
|
selected.simulate("click");
|
||||||
|
expect(callbackSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
40
jsx/src/components/Multiselect/multi-select.css
Normal file
40
jsx/src/components/Multiselect/multi-select.css
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
@import url(../../style/root.css);
|
||||||
|
|
||||||
|
.multi-container {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
padding: 5px;
|
||||||
|
overflow-x: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-container div {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-container .item.unselected {
|
||||||
|
background-color: #f7f7f7;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-container .item.selected {
|
||||||
|
background-color: orange;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-container .item:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
21
jsx/src/components/ServerDashboard/ServerDashboard.js
Normal file
21
jsx/src/components/ServerDashboard/ServerDashboard.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import React, { Component } from "react";
|
||||||
|
import { compose, withProps, withHandlers } from "recompose";
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import { jhapiRequest } from "../../util/jhapiUtil";
|
||||||
|
import { ServerDashboard } from "./ServerDashboard.pre";
|
||||||
|
|
||||||
|
const withHubActions = withProps((props) => ({
|
||||||
|
updateUsers: (cb) => jhapiRequest("/users", "GET"),
|
||||||
|
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")),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default compose(
|
||||||
|
withHubActions,
|
||||||
|
connect((state) => ({ user_data: state.user_data }))
|
||||||
|
)(ServerDashboard);
|
316
jsx/src/components/ServerDashboard/ServerDashboard.pre.jsx
Normal file
316
jsx/src/components/ServerDashboard/ServerDashboard.pre.jsx
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
import React, { Component } from "react";
|
||||||
|
import { Table, Button } from "react-bootstrap";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import "./server-dashboard.css";
|
||||||
|
import { timeSince } from "../../util/timeSince";
|
||||||
|
import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
|
export class ServerDashboard extends Component {
|
||||||
|
static get propTypes() {
|
||||||
|
return {
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
(this.usernameDesc = (e) => e.sort((a, b) => (a.name > b.name ? 1 : -1))),
|
||||||
|
(this.usernameAsc = (e) => e.sort((a, b) => (a.name < b.name ? 1 : -1))),
|
||||||
|
(this.adminDesc = (e) => e.sort((a) => (a.admin ? -1 : 1))),
|
||||||
|
(this.adminAsc = (e) => e.sort((a) => (a.admin ? 1 : -1))),
|
||||||
|
(this.dateDesc = (e) =>
|
||||||
|
e.sort((a, b) =>
|
||||||
|
new Date(a.last_activity) - new Date(b.last_activity) > 0 ? -1 : 1
|
||||||
|
)),
|
||||||
|
(this.dateAsc = (e) =>
|
||||||
|
e.sort((a, b) =>
|
||||||
|
new Date(a.last_activity) - new Date(b.last_activity) > 0 ? 1 : -1
|
||||||
|
)),
|
||||||
|
(this.runningAsc = (e) => e.sort((a) => (a.server == null ? -1 : 1))),
|
||||||
|
(this.runningDesc = (e) => e.sort((a) => (a.server == null ? 1 : -1)));
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
addUser: false,
|
||||||
|
sortMethod: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
var {
|
||||||
|
user_data,
|
||||||
|
updateUsers,
|
||||||
|
shutdownHub,
|
||||||
|
startServer,
|
||||||
|
stopServer,
|
||||||
|
startAll,
|
||||||
|
stopAll,
|
||||||
|
dispatch,
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
var dispatchUserUpdate = (data) => {
|
||||||
|
dispatch({
|
||||||
|
type: "USER_DATA",
|
||||||
|
value: data,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!user_data) return <div></div>;
|
||||||
|
|
||||||
|
if (this.state.sortMethod != undefined)
|
||||||
|
user_data = this.state.sortMethod(user_data);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<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: this.usernameAsc, desc: this.usernameDesc }}
|
||||||
|
callback={(e) =>
|
||||||
|
this.setState(
|
||||||
|
Object.assign({}, this.state, { sortMethod: e })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th id="admin-header">
|
||||||
|
Admin{" "}
|
||||||
|
<SortHandler
|
||||||
|
sorts={{ asc: this.adminAsc, desc: this.adminDesc }}
|
||||||
|
callback={(e) =>
|
||||||
|
this.setState(
|
||||||
|
Object.assign({}, this.state, { sortMethod: e })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th id="last-activity-header">
|
||||||
|
Last Activity{" "}
|
||||||
|
<SortHandler
|
||||||
|
sorts={{ asc: this.dateAsc, desc: this.dateDesc }}
|
||||||
|
callback={(e) =>
|
||||||
|
this.setState(
|
||||||
|
Object.assign({}, this.state, { sortMethod: e })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th id="running-status-header">
|
||||||
|
Running{" "}
|
||||||
|
<SortHandler
|
||||||
|
sorts={{ asc: this.runningAsc, desc: this.runningDesc }}
|
||||||
|
callback={(e) =>
|
||||||
|
this.setState(
|
||||||
|
Object.assign({}, this.state, { sortMethod: e })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th id="actions-header">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<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()
|
||||||
|
.then((data) => data.json())
|
||||||
|
.then((data) => {
|
||||||
|
dispatchUserUpdate(data);
|
||||||
|
})
|
||||||
|
.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()
|
||||||
|
.then((data) => data.json())
|
||||||
|
.then((data) => {
|
||||||
|
dispatchUserUpdate(data);
|
||||||
|
})
|
||||||
|
.catch((err) => console.log(err));
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch((err) => console.log(err));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Stop All
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{/* Shutdown Jupyterhub */}
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
className="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()
|
||||||
|
.then((data) => data.json())
|
||||||
|
.then((data) => {
|
||||||
|
dispatchUserUpdate(data);
|
||||||
|
});
|
||||||
|
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()
|
||||||
|
.then((data) => data.json())
|
||||||
|
.then((data) => {
|
||||||
|
dispatchUserUpdate(data);
|
||||||
|
});
|
||||||
|
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={() =>
|
||||||
|
this.props.history.push({
|
||||||
|
pathname: "/edit-user",
|
||||||
|
state: {
|
||||||
|
username: e.name,
|
||||||
|
has_admin: e.admin,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
edit user
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SortHandler extends Component {
|
||||||
|
static get propTypes() {
|
||||||
|
return {
|
||||||
|
sorts: PropTypes.object,
|
||||||
|
callback: PropTypes.func,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
direction: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
let { sorts, callback } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="sort-icon"
|
||||||
|
onClick={() => {
|
||||||
|
if (!this.state.direction) {
|
||||||
|
callback(sorts.desc);
|
||||||
|
this.setState({ direction: "desc" });
|
||||||
|
} else if (this.state.direction == "asc") {
|
||||||
|
callback(sorts.desc);
|
||||||
|
this.setState({ direction: "desc" });
|
||||||
|
} else {
|
||||||
|
callback(sorts.asc);
|
||||||
|
this.setState({ direction: "asc" });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!this.state.direction ? (
|
||||||
|
<FaSort />
|
||||||
|
) : this.state.direction == "asc" ? (
|
||||||
|
<FaSortDown />
|
||||||
|
) : (
|
||||||
|
<FaSortUp />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
152
jsx/src/components/ServerDashboard/ServerDashboard.test.js
Normal file
152
jsx/src/components/ServerDashboard/ServerDashboard.test.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Enzyme, { shallow, mount } from "enzyme";
|
||||||
|
import { ServerDashboard } from "./ServerDashboard.pre";
|
||||||
|
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
|
||||||
|
import { HashRouter, Switch } from "react-router-dom";
|
||||||
|
|
||||||
|
Enzyme.configure({ adapter: new Adapter() });
|
||||||
|
|
||||||
|
describe("ServerDashboard Component: ", () => {
|
||||||
|
var serverDashboardJsx = (callbackSpy) => (
|
||||||
|
<ServerDashboard
|
||||||
|
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":{}}]'
|
||||||
|
)}
|
||||||
|
updateUsers={callbackSpy}
|
||||||
|
shutdownHub={callbackSpy}
|
||||||
|
startServer={callbackSpy}
|
||||||
|
stopServer={callbackSpy}
|
||||||
|
startAll={callbackSpy}
|
||||||
|
stopAll={callbackSpy}
|
||||||
|
dispatch={callbackSpy}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
var deepServerDashboardJsx = (callbackSpy) => (
|
||||||
|
<HashRouter>
|
||||||
|
<Switch>
|
||||||
|
<ServerDashboard
|
||||||
|
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":{}}]'
|
||||||
|
)}
|
||||||
|
updateUsers={callbackSpy}
|
||||||
|
shutdownHub={callbackSpy}
|
||||||
|
startServer={callbackSpy}
|
||||||
|
stopServer={callbackSpy}
|
||||||
|
startAll={callbackSpy}
|
||||||
|
stopAll={callbackSpy}
|
||||||
|
dispatch={callbackSpy}
|
||||||
|
/>
|
||||||
|
</Switch>
|
||||||
|
</HashRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
var mockAsync = () =>
|
||||||
|
jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(() =>
|
||||||
|
Promise.resolve({ json: () => Promise.resolve({ k: "v" }) })
|
||||||
|
);
|
||||||
|
|
||||||
|
it("Renders users from props.user_data into table", () => {
|
||||||
|
let component = shallow(serverDashboardJsx(jest.fn())),
|
||||||
|
userRows = component.find(".user-row");
|
||||||
|
expect(userRows.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Renders correctly the status of a single-user server", () => {
|
||||||
|
let component = shallow(serverDashboardJsx(jest.fn())),
|
||||||
|
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 = shallow(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 = shallow(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 = shallow(serverDashboardJsx(callbackSpy)),
|
||||||
|
shutdownBtn = component.find(".shutdown-button");
|
||||||
|
shutdownBtn.simulate("click");
|
||||||
|
expect(callbackSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Sorts according to username", () => {
|
||||||
|
let component = mount(deepServerDashboardJsx(jest.fn())).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(deepServerDashboardJsx(jest.fn())).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(deepServerDashboardJsx(jest.fn())).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(deepServerDashboardJsx(jest.fn())).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", () => {
|
||||||
|
let component = shallow(<ServerDashboard />);
|
||||||
|
expect(component.html()).toBe("<div></div>");
|
||||||
|
});
|
||||||
|
});
|
28
jsx/src/components/ServerDashboard/server-dashboard.css
Normal file
28
jsx/src/components/ServerDashboard/server-dashboard.css
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
@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: .125em;
|
||||||
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
29
jsx/src/style/root.css
Normal file
29
jsx/src/style/root.css
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
: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
|
||||||
|
}
|
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";
|
||||||
|
}
|
||||||
|
};
|
66
jsx/webpack.config.js
Normal file
66
jsx/webpack.config.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
const webpack = require("webpack")
|
||||||
|
const path = require("path")
|
||||||
|
const express = require("express")
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
entry: path.resolve(__dirname, "src", "App.jsx"),
|
||||||
|
mode: "development",
|
||||||
|
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() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
7949
jsx/yarn.lock
Normal file
7949
jsx/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
1
jupyterhub-proxy.pid
Normal file
1
jupyterhub-proxy.pid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
9472
|
@@ -457,82 +457,15 @@ class AdminHandler(BaseHandler):
|
|||||||
@web.authenticated
|
@web.authenticated
|
||||||
@admin_only
|
@admin_only
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
self.finish(html)
|
self.finish(html)
|
||||||
|
|
||||||
|
4337
share/jupyterhub/static/js/admin-react.js
Normal file
4337
share/jupyterhub/static/js/admin-react.js
Normal file
File diff suppressed because one or more lines are too long
@@ -16,158 +16,9 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
|
<div id="react-admin-hook">
|
||||||
<div class="container">
|
<script src="static/js/admin-react.js"></script>
|
||||||
<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>
|
</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 %}
|
||||||
|
Reference in New Issue
Block a user