mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-12 20:43:02 +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
|
||||
@admin_only
|
||||
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,
|
||||
)
|
||||
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 %}
|
||||
|
||||
{% 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 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