Add React Admin and modify AdminHandler

This commit is contained in:
Nathan Barber
2021-04-05 16:51:22 -04:00
parent c5bfd28005
commit 11cb9523e8
35 changed files with 18333 additions and 218 deletions

2
jsx/.gitignore vendored Normal file
View File

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

25
jsx/README.md Normal file
View 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

File diff suppressed because one or more lines are too long

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

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

67
jsx/package.json Normal file
View File

@@ -0,0 +1,67 @@
{
"name": "jupyterhub-admin-react",
"version": "1.0.0",
"description": "React application for the Jupyter Hub admin dashboard service",
"main": "index.js",
"author": "nabarber",
"license": "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
View 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
View 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;
}
};

View 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);

View 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>
</>
);
}
}

View 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);
});
});

View 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);

View 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>
</>
);
}
}

View 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();
});
});

View 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);

View 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>
);
}
}

View 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");
});
});

View 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);

View 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>
);
}
}

View 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>");
});
});

View 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>
);
}
}

View 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();
});
});

View 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;
}

View 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);

View 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>
);
}
}

View 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>");
});
});

View 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
View 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
View File

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

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

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

66
jsx/webpack.config.js Normal file
View 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

File diff suppressed because it is too large Load Diff

1
jupyterhub-proxy.pid Normal file
View File

@@ -0,0 +1 @@
9472

View File

@@ -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)

File diff suppressed because one or more lines are too long

View File

@@ -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 %}