diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..84eb9b1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +node_modules/ +nginx/ +doc/ +build/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b014976 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM node:latest + +RUN mkdir /react +RUN mkdir /result + +VOLUME /result + +WORKDIR /react + +CMD npm install && npm run build && cp -R /react/build/* /result/ diff --git a/README.md b/README.md index d05a9a5..172ff40 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ Additional libraries are used, for a complete list see package.json. To start the website locally run `npm start`. This will open a local webserver serving the _frontend_. To make the website work, you still need to start at least the VILLASweb-backend (See repository for information). +The default user and password are configured in the `config.js` file of the _backend_. By default they are: __admin__ / __admin__. + ## Copyright 2017, Institute for Automation of Complex Power Systems, EONERC @@ -55,4 +57,4 @@ For other licensing options please consult [Prof. Antonello Monti](mailto:amonti [Institute for Automation of Complex Power Systems (ACS)](http://www.acs.eonerc.rwth-aachen.de) [EON Energy Research Center (EONERC)](http://www.eonerc.rwth-aachen.de) -[RWTH University Aachen, Germany](http://www.rwth-aachen.de) \ No newline at end of file +[RWTH University Aachen, Germany](http://www.rwth-aachen.de) diff --git a/docker-compose.yml b/docker-compose.yml index afdd7a7..579c30b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,24 +1,39 @@ version: "2" services: - frontend: + webserver: image: nginx:stable volumes: - ./nginx:/etc/nginx/conf.d/ - - ./build:/www + - website-volume:/www links: - backend ports: - "80:80" - "443:443" + restart: always + + frontend: + build: . + volumes: + - ./:/react + - website-volume:/result backend: image: villasweb-backend links: - database + environment: + - NODE_ENV=production + restart: always database: image: mongo:latest volumes: - - /opt/database:/data/db + - data-volume:/data/db + restart: always + user: mongodb +volumes: + data-volume: + website-volume: diff --git a/nginx/villas.conf b/nginx/villas.conf index f3417de..7fd9ee9 100644 --- a/nginx/villas.conf +++ b/nginx/villas.conf @@ -7,10 +7,10 @@ server { proxy_redirect off; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - + # rewrite url to exclude /api on context broker side - rewrite ^/api/?(.*) /api/v1/$1 break; - + rewrite ^/api/?(.*) /api/$1 break; + proxy_pass http://backend:4000/; } diff --git a/package.json b/package.json index 1e6f8b1..8977188 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "villasweb-frontend", "version": "0.1.0", "private": true, + "proxy": "http://localhost:4000", "dependencies": { "bootstrap": "^3.3.7", "classnames": "^2.2.5", diff --git a/src/api/rest-api.js b/src/api/rest-api.js index 66e8211..6a79598 100644 --- a/src/api/rest-api.js +++ b/src/api/rest-api.js @@ -21,6 +21,36 @@ import request from 'superagent/lib/client'; import Promise from 'es6-promise'; +import NotificationsDataManager from '../data-managers/notifications-data-manager'; + + +// TODO: Add this to a central pool of notifications +const SERVER_NOT_REACHABLE_NOTIFICATION = { + title: 'Server not reachable', + message: 'The server could not be reached. Please try again later.', + level: 'error' + }; + +const REQUEST_TIMEOUT_NOTIFICATION = { + title: 'Request timeout', + message: 'Request timed out. Please try again later.', + level: 'error' + }; + +// Check if the error was due to network failure, timeouts, etc. +// Can be used for the rest of requests +function isNetworkError(err) { + let result = false; + + // If not status nor response fields, it is a network error. TODO: Handle timeouts + if (err.status == null || err.response == null) { + result = true; + + let notification = err.timeout? REQUEST_TIMEOUT_NOTIFICATION : SERVER_NOT_REACHABLE_NOTIFICATION; + NotificationsDataManager.addNotification(notification); + } + return result; +} class RestAPI { get(url, token) { @@ -43,14 +73,17 @@ class RestAPI { post(url, body, token) { return new Promise(function (resolve, reject) { - var req = request.post(url).send(body); + var req = request.post(url).send(body).timeout({ response: 5000 }); // Simple response start timeout (3s) if (token != null) { req.set('x-access-token', token); } - + req.end(function (error, res) { if (res == null || res.status !== 200) { + + error.handled = isNetworkError(error); + reject(error); } else { resolve(JSON.parse(res.text)); diff --git a/src/components/dialog/edit-user.js b/src/components/dialog/edit-user.js new file mode 100644 index 0000000..594492a --- /dev/null +++ b/src/components/dialog/edit-user.js @@ -0,0 +1,111 @@ +/** + * File: edit-user.js + * Author: Ricardo Hernandez-Montoya + * Date: 02.05.2017 + * + * This file is part of VILLASweb. + * + * VILLASweb is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * VILLASweb is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with VILLASweb. If not, see . + ******************************************************************************/ + +import React, { Component, PropTypes } from 'react'; +import { FormGroup, FormControl, ControlLabel } from 'react-bootstrap'; + +import Dialog from './dialog'; + +class EditUserDialog extends Component { + static propTypes = { + show: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + user: PropTypes.object.isRequired + }; + + valid: true; + + constructor(props) { + super(props); + + this.state = { + username: '', + mail: '', + role: '', + _id: '' + } + } + + onClose(canceled) { + if (canceled === false) { + this.props.onClose(this.state); + } else { + this.props.onClose(); + } + } + + handleChange(e) { + this.setState({ [e.target.id]: e.target.value }); + } + + resetState() { + this.setState({ + username: this.props.user.username, + mail: this.props.user.mail, + role: this.props.user.role, + _id: this.props.user._id + }); + } + + validateForm(target) { + // check all controls + var username = true; + + if (this.state.username === '') { + username = false; + } + + this.valid = username; + + // return state to control + if (target === 'username') return username ? "success" : "error"; + + return "success"; + } + + render() { + return ( + this.onClose(c)} onReset={() => this.resetState()} valid={this.valid}> +
+ + Username + this.handleChange(e)} /> + + + + E-mail + this.handleChange(e)} /> + + + Role + this.handleChange(e)}> + + + + + +
+
+ ); + } +} + +export default EditUserDialog; diff --git a/src/components/dialog/new-user.js b/src/components/dialog/new-user.js new file mode 100644 index 0000000..fd01f99 --- /dev/null +++ b/src/components/dialog/new-user.js @@ -0,0 +1,119 @@ +/** + * File: new-user.js + * Author: Ricardo Hernandez-Montoya + * Date: 02.05.2017 + * + * This file is part of VILLASweb. + * + * VILLASweb is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * VILLASweb is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with VILLASweb. If not, see . + ******************************************************************************/ + +import React, { Component, PropTypes } from 'react'; +import { FormGroup, FormControl, ControlLabel, HelpBlock } from 'react-bootstrap'; + +import Dialog from './dialog'; + +class NewUserDialog extends Component { + static propTypes = { + show: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired + }; + + valid: false; + + constructor(props) { + super(props); + + this.state = { + username: '', + mail: '', + role: 'admin', + password: '' + }; + } + + onClose(canceled) { + if (canceled === false) { + this.props.onClose(this.state); + } else { + this.props.onClose(); + } + } + + handleChange(e) { + this.setState({ [e.target.id]: e.target.value }); + } + + resetState() { + this.setState({ + username: '', + mail: '', + role: 'admin', + password: '' + }); + } + + validateForm(target) { + // check all controls + let username = this.state.username !== '' && this.state.username.length >= 3; + let password = this.state.password !== ''; + + this.valid = username && password; + + // return state to control + switch(target) { + case 'username': + return username ? "success" : "error"; + case 'password': + return password ? "success" : "error"; + default: + return "success"; + } + } + + render() { + return ( + this.onClose(c)} onReset={() => this.resetState()} valid={this.valid}> +
+ + Username + this.handleChange(e)} /> + + Min 3 characters. + + + E-mail + this.handleChange(e)} /> + + + + Password + this.handleChange(e)} /> + + + + Role + this.handleChange(e)}> + + + + + +
+
+ ); + } +} + +export default NewUserDialog; diff --git a/src/components/menu-sidebar.js b/src/components/menu-sidebar.js index 120065d..152644a 100644 --- a/src/components/menu-sidebar.js +++ b/src/components/menu-sidebar.js @@ -29,11 +29,14 @@ class SidebarMenu extends Component {

Menu

    -
  • Home
  • -
  • Projects
  • -
  • Simulations
  • -
  • Simulators
  • -
  • Logout
  • +
  • Home
  • +
  • Projects
  • +
  • Simulations
  • +
  • Simulators
  • + { this.props.currentRole === 'admin' ? +
  • User Management
  • : '' + } +
  • Logout
); diff --git a/src/components/table.js b/src/components/table.js index f0b94d1..f0798c5 100644 --- a/src/components/table.js +++ b/src/components/table.js @@ -29,6 +29,7 @@ class CustomTable extends Component { constructor(props) { super(props); + this.activeInput = null; this.state = { rows: [], editCell: [ -1, -1 ] @@ -125,6 +126,23 @@ class CustomTable extends Component { this.setState({ rows: rows }); } + componentDidUpdate() { + // A cell will not be selected at initial render, hence no need to call this in 'componentDidMount' + if (this.activeInput) { + this.activeInput.focus(); + } + } + + onCellFocus(index) { + // When a cell focus is detected, update the current state in order to uncover the input element + this.setState({ editCell: [ index.cell, index.row ]}); + } + + cellLostFocus() { + // Reset cell selection state + this.setState({ editCell: [ -1, -1 ] }); + } + render() { // get children var children = this.props.children; @@ -140,23 +158,41 @@ class CustomTable extends Component { - {this.state.rows.map((row, rowIndex) => ( - - {row.map((cell, cellIndex) => ( - this.onClick(event, rowIndex, cellIndex) : () => {}}> - {(this.state.editCell[0] === cellIndex && this.state.editCell[1] === rowIndex ) ? ( - children[cellIndex].props.onInlineChange(event, rowIndex, cellIndex)} /> - ) : ( - - {cell.map((element, elementIndex) => ( - {element} - ))} - - )} - - ))} - - ))} + { + this.state.rows.map((row, rowIndex) => ( + + { + row.map((cell, cellIndex) => { + + let isCellInlineEditable = children[cellIndex].props.inlineEditable === true; + + let tabIndex = isCellInlineEditable? 0 : -1; + + let evtHdls = isCellInlineEditable ? { + onCellClick: (event) => this.onClick(event, rowIndex, cellIndex), + onCellFocus: () => this.onCellFocus({cell: cellIndex, row: rowIndex}), + onCellBlur: () => this.cellLostFocus() + } : { + onCellClick: () => {}, + onCellFocus: () => {}, + onCellBlur: () => {} + }; + + return ( + {(this.state.editCell[0] === cellIndex && this.state.editCell[1] === rowIndex ) ? ( + children[cellIndex].props.onInlineChange(event, rowIndex, cellIndex)} inputRef={ref => { this.activeInput = ref; }} /> + ) : ( + + {cell.map((element, elementIndex) => ( + {element} + ))} + + )} + ) + }) + } + )) + } ); diff --git a/src/containers/app.js b/src/containers/app.js index 4e90442..2af248e 100644 --- a/src/containers/app.js +++ b/src/containers/app.js @@ -76,9 +76,11 @@ class App extends Component { } } + let currentUser = UserStore.getState().currentUser; + return { simulations: SimulationStore.getState(), - currentUser: UserStore.getState().currentUser, + currentRole: currentUser? currentUser.role : '', token: UserStore.getState().token, runningSimulators: simulators @@ -183,10 +185,12 @@ class App extends Component {
- -
- {children} +
+ +
+ {children} +