1
0
Fork 0
mirror of https://git.rwth-aachen.de/acs/public/villas/web/ synced 2025-03-09 00:00:01 +01:00

merge with master and adjust button position

This commit is contained in:
Laura Fuentes Grau 2021-02-01 18:13:26 +01:00
commit 9a8d7b645f
55 changed files with 2956 additions and 2129 deletions

View file

@ -1,6 +1,6 @@
variables:
GIT_SUBMODULE_STRATEGY: normal
DOCKER_TAG: ${CI_COMMIT_SHORT_SHA}
DOCKER_TAG: ${CI_COMMIT_BRANCH}
DOCKER_IMAGE: ${CI_REGISTRY_IMAGE}
cache:
@ -42,8 +42,5 @@ deploy:
--dockerfile ${CI_PROJECT_DIR}/Dockerfile
--destination ${DOCKER_IMAGE}:${DOCKER_TAG}
--snapshotMode=redo
--cache=true
--cache-ttl=12h
only:
refs:
- master
dependencies:
- build

View file

@ -2,39 +2,15 @@
[![pipeline status](https://git.rwth-aachen.de/acs/public/villas/web/badges/master/pipeline.svg)](https://git.rwth-aachen.de/acs/public/villas/web/-/commits/master)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
This is VILLASweb, the website to configure real-time co-simulations and display simulation real-time data in the web browser.
The term **frontend** refers to this project, the actual website.
The frontend connects to **two** backends: [VILLASweb-backend-go](https://git.rwth-aachen.de/acs/public/villas/web-backend-go) and [VILLASnode](https://git.rwth-aachen.de/acs/public/villas/node).
VILLASnode provides actual simulation data via websockets. VILLASweb-backend-go provides any other data such as user accounts, infrastructure components and configurations, dashboards etc.
For more information on the backends see their repositories.
VILLASweb is a tool to configure real-time co-simulations and display simulation real-time data in the web browser.
## Frameworks
The frontend is build upon [ReactJS](https://facebook.github.io/react/) and [Flux](https://facebook.github.io/flux/).
React is responsible for rendering the UI and Flux for handling the data and communication with the backends. For more information also have a look at REACT.md
Additional libraries are used, for a complete list see the file `package.json`.
## Data model
![Datamodel](src/img/datamodel.png)
## Quick start
```bash
$ git clone --recursive https://git.rwth-aachen.de/acs/public/villas/web.git
$ cd web
$ npm install
$ npm start
```
We recommend to start the VILLASweb-backend-go before the frontend.
If you want to use test data (including some test users), you can start the backend with the parameter `-mode=test`.
Please check the repository of the VILLASweb-backend-go to find information on the test user login names and passwords.
The testing mode is NOT intended for production deployments.
## Documentation
More details on the setup and usage of VILLASweb is available here:
- [Requirements](doc/Requirements.md)
- [Structure and datamodel](doc/Structure.md)
- [Development setup](doc/development.md)
- [Production setup](doc/Production.md)
More details on the setup and usage of VILLASweb is available [in the VILLAS documentation](https://villas.fein-aachen.org/doc/web.html).
## Copyright
@ -70,7 +46,7 @@ For other licensing options please consult [Prof. Antonello Monti](mailto:amonti
[![EONERC ACS Logo](doc/pictures/eonerc_logo.png)](http://www.acs.eonerc.rwth-aachen.de)
- Steffen Vogel <stvogel@eonerc.rwth-aachen.de>
- Steffen Vogel <svogel2@eonerc.rwth-aachen.de>
- Sonja Happ <sonja.happ@eonerc.rwth-aachen.de>
[Institute for Automation of Complex Power Systems (ACS)](http://www.acs.eonerc.rwth-aachen.de)

View file

@ -1,82 +0,0 @@
# Production Setup {#web-production}
## Setting up VILLASweb for production
For development setup instructions see @ref web-development.
The production setup is based on docker.
Clone the [frontend](https://git.rwth-aachen.de/acs/public/villas/web) and [backend](https://git.rwth-aachen.de/acs/public/villas/web-backend-go) repositories on your computer and build the Docker images for both:
### Frontend
- `cd VILLASweb`
- `docker build -t villasweb-frontend .`
### Backend
- `cd ..\VILLASweb-backend-go`
- `docker build -t villasweb-backend .`
### WIP Docker compose and/or Kubernetes
Run the production docker-compose file:
- `docker-compose -f docker-compose-production.yml up -d`
## Configure VILLASnode to get data into VILLASweb
### Install VILLASnode
See: @ref node-installation
### Create a VILLASnode demo data source
1. Create a new empty configuration file with the following contents and save it as `webdemo.conf`:
> WIP this example configuration requires revision!
```
nodes = {
sine = {
type = "signal"
signal = "mixed"
values = 5
rate = 25
frequency = 5
}
web = {
type = "websocket"
destinations = [
"TODO"
]
}
}
paths = (
{
in = "sine"
out = "web"
}
)
```
The node `sine` is a software signal generator for 5 signals.
The node `web` is the websocket interface to stream the data generated by the `sine` node to the browser.
> Note: If you do not want to use your local system as the destination for the websocket node,
>change the option `destinations` of the `web` node to the destination of your production environment, for example `https://my.production.environment/ws/webdemo`.
### Start the VILLASnode gateway
Run the following command on your system:
```bash
villas node webdemo.conf
```
> Note: Change the path to the configuration file accordingly. The `villas` command will only work if VILLASnode is installed on your system.
### Visualize real-time data in VILLASweb Dashboards
1. Use the VILLASweb frontend to create a new infrastructure component for the VILLASnode gateway from above (Admin user required).
2. Set the `websocketurl` parameter of the component to the target you used as the `web.destinations` parameter in the configuration from above.
3. Create a new scenario in VILLASweb and within that scenario create a new component configuration that uses the infrastructure component you created under 2.
4. WIP: Use the signal auto-configure function to retrieve the signal configuration of the VILLASnode automatically.
5. Create a new dashboard with widgets of your choice and link these widgets to the signals received from the infrastructure component.
6. Enjoy what you see.

View file

@ -1,12 +0,0 @@
# Requirements {#web-requirements}
## Services and tools required for development
- [NodeJS with npm](https://nodejs.org/en/): Runs VILLASweb frontend
- [Go](https://golang.org/): Runs VILLASweb backend
- [PostgreSQL database](https://www.postgresql.org/) (min version 11): Backend database
- [swag](https://github.com/swaggo/swag): For automated API documentation creation
- [Docker](https://www.docker.com/): Container management system
## Additional requirements for productive use
- [NGinX](https://www.nginx.com/): Webserver and reverse proxy for backends

View file

@ -1,79 +0,0 @@
# VILLASweb data structure {#web-datastructure}
This document describes how data (scenarios, infrastructure components, users etc., not only live data) is structured in VILLASweb.
## Data model
![Datamodel](../src/img/datamodel.png)
VILLASweb features the following data classes:
- Users
- Infrastructure Components
- Scenarios
* Component Configurations and Signals
* Dashboards and Widgets
* Files
### Users
- You need a username and a password to authenticate in VILLASweb
- There exist three categories of users: Guest, User, and Admin
- Guests have only read access and cannot modify anything
- Users are normal users, they have access to their scenarios, can see available infrastructure components, and modify their accounts (except for their role)
- Admin users have full access to everything, they are the only users that can create new users or change the role of existing users. Only admin users can add or modify infrastructure components.
### Infrastructure Components
- Components of research infrastructure
- Category: for example simulator, gateway, amplifier, database, etc.
- Type: for example RTDS, OpalRT, VILLASnode, Cassandra
- Can only be added/ modified by admin users
### Scenarios
- A collection of component configurations, dashboards, and files for a specific experiment
- Users can have access to multiple scenarios
- Users can be added to and removed from scenarios
### Component Configurations and Signals
- Configure an infrastructure component for the use in a specific scenario
- Input signals: Signals that can be modified in VILLASweb
- Output signals: Signals that can be visualized on dashboards of VILLASweb
- Parameters: Additional configuration parameters of the infrastructure component
- Signals are the actual live data that is displayed or modified through VILLASweb dashboards
### Dashboards and Widgets
- Visualize ongoing experiments in real-time
- Interact with ongoing experiments in real-time
- Use widgets to design the dashboard according to the needs
### Files
- Files can be added to scenarios optionally
- Can be images, model files, CIM xml files
- Can be used in widgets or component configurations
## Setup strategy
The easiest way to start from scratch is the following (assuming the infrastructure components are already configured by an admin user, see below):
1. Create a new scenario.
2. Create and configure a new component configuration and link it with an available infrastructure component.
3. Configure the input and output signals of the component configuration according to the signals provided by the selected infrastructure component. The number of signals and their order (index starting at 1) must match.
4. Create a new dashboard and add widgets as desired. Configure the widgets by right-clicking to open the edit menu
5. If needed, files can be added to the scenario and used by component configurations or widgets (models, images, CIM-files, etc.)
6. For collaboration with other users, users can be added to a scenario
### Setup of infrastructure components
In the "Infrastructure Components" menu point admin users can create and edit components to be used in experiments. Normal uses can view the available components, but not edit them.
The components are global at any time and are shared among all users of VILLASweb.
To create a new infrastructure component, you need to provide:
- Name
- Category (see above for examples)
- Type (see above for examples)
- Location
- Host (network address of the component)
At the moment, you need to know the input and output signals of the infrastructure component a priori to be able to create compatible component configurations by hand.
An auto-detection mechanism for signals is planned for future releases.
> Hint: At least one infrastructure component is required to receive data in VILLASweb.

View file

@ -1,70 +0,0 @@
# Development {#web-development}
- @subpage web-datastructure
In order to get started with VILLASweb, you might also want to check our our [demo project](https://git.rwth-aachen.de/acs/public/villas/Demo) which is simple to setup using Docker Compose.
## Frontend
### Description
The website itself based on the [React JavaScript framework](https://reactjs.org/) and the [Flux library](https://facebook.github.io/flux/).
### Required
- [NodeJS with npm](https://nodejs.org/en/)
### Setup
- `git clone git@git.rwth-aachen.de/acs/public/villas/web.git` to copy the project on your computer
- `cd VILLASweb`
- `npm install`
### Running
- `npm start`
This runs the development server for the website on your local computer at port 3000.
The backend must be running to make the website work.
Type `http://localhost:3000/` in the address field of your browser to open the website.
## Backend
### Description
The backend of VILLASweb uses the programming language Go and a PostgreSQL database.
### Required
- [Go](https://golang.org/) (min version 1.11)
- [PostgreSQL database](https://www.postgresql.org/) (min version 11)
- [swag](https://github.com/swaggo/swag)
### Setup and Running
- `git clone git@git.rwth-aachen.de/acs/public/villas/web-backend-go.git` to copy the project on your computer
- `cd VILLASweb-backend-go`
- `go mod tidy`
- `go run start.go [params]`
To obtain a list of available parameters use `go run start.go --help`.
To run the tests use `go test $(go list ./... ) -p 1` in the top-level folder of the repo.
Running the backend will only work if the PostgreSQL database is setup properly. Otherwise, you will get error messages.
### Auto-generate the API documentation
The documentation of the VILLASweb API in the OpenAPI format can be auto-generated from the source code documentation using the tool swag.
To do this run the following in the top-level folder of the repo:
- `go mod tidy`
- `go install github.com/swaggo/swag/cmd/swag`
- `swag init -p pascalcase -g "start.go" -o "./doc/api/"`
The `.yaml` and `.json` files in OpenAPI swagger format are created in the output folder `doc/api`.
### PostgreSQL database setup
Please check the [Readme file in the backend repository](https://git.rwth-aachen.de/acs/public/villas/web-backend-go) for some useful hints on the local setup of the PostreSQL database.

2717
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,10 +5,10 @@
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.32",
"@fortawesome/free-solid-svg-icons": "^5.15.1",
"@fortawesome/react-fontawesome": "^0.1.12",
"@fortawesome/react-fontawesome": "^0.1.13",
"babel-runtime": "^6.26.0",
"bootstrap": "^4.5.3",
"bufferutil": "^4.0.1",
"bufferutil": "^4.0.2",
"canvas": "^2.6.1",
"classnames": "^2.2.6",
"d3-array": "^2.8.0",
@ -20,7 +20,7 @@
"d3-time-format": "^3.0.0",
"es6-promise": "^4.2.8",
"fibers": "^5.0.0",
"file-saver": "^2.0.2",
"file-saver": "^2.0.5",
"flux": "^3.1.3",
"gaugeJS": "^1.3.7",
"handlebars": "^4.7.6",
@ -29,15 +29,15 @@
"libcimsvg": "git+https://git.rwth-aachen.de/acs/public/cim/pintura-npm-package.git",
"lodash": "^4.17.20",
"moment": "^2.29.1",
"multiselect-react-dropdown": "^1.6.1",
"multiselect-react-dropdown": "^1.6.2",
"node-sass": "^4.14.1",
"popper.js": "^1.16.1",
"prop-types": "^15.7.2",
"rc-slider": "^9.6.0",
"rc-slider": "^9.6.4",
"react": "^16.14.0",
"react-bootstrap": "^1.4.0",
"react-bootstrap-time-picker": "^2.0.1",
"react-collapse": "^5.0.1",
"react-collapse": "^5.1.0",
"react-color": "^2.19.3",
"react-contexify": "^4.1.1",
"react-d3": "^0.4.0",
@ -51,17 +51,18 @@
"react-rnd": "^10.2.3",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"react-scripts": "^4.0.0",
"react-scripts": "^4.0.1",
"react-svg-pan-zoom": "^3.8.1",
"sass": "^1.28.0",
"react-trafficlight": "^5.2.1",
"sass": "^1.29.0",
"superagent": "^6.1.0",
"ts-node": "^9.0.0",
"type-fest": "^0.13.1",
"typescript": "^4.0.5",
"utf-8-validate": "^5.0.2",
"typescript": "^4.1.2",
"utf-8-validate": "^5.0.3",
"validator": "^13.1.17",
"webpack-hot-middleware": "^2.25.0",
"webpack-plugin-serve": "^1.2.0"
"webpack-plugin-serve": "^1.2.1"
},
"devDependencies": {
"chai": "^4.2.0"

View file

@ -18,30 +18,22 @@
import request from 'superagent/lib/client';
import Promise from 'es6-promise';
import NotificationsDataManager from '../data-managers/notifications-data-manager';
import NotificationsFactory from "../data-managers/notifications-factory";
// 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) {
function isNetworkError(err, url) {
let result = false;
// If not status nor response fields, it is a network error. TODO: Handle timeouts
if (err.status == null || err.status === 500 || err.response == null) {
result = true;
let notification = err.timeout? REQUEST_TIMEOUT_NOTIFICATION : SERVER_NOT_REACHABLE_NOTIFICATION;
if (err.status === 500 && err.response != null){
let notification = NotificationsFactory.INTERNAL_SERVER_ERROR(err.response)
NotificationsDataManager.addNotification(notification);
} else if (err.status == null || err.status === 500 || err.response == null) {
// If not status nor response fields, it is a network error. TODO: Handle timeouts
result = true;
let notification = err.timeout? NotificationsFactory.REQUEST_TIMEOUT : NotificationsFactory.SERVER_NOT_REACHABLE(url);
NotificationsDataManager.addNotification(notification);
}
return result;
@ -52,7 +44,8 @@ let prevURL = null;
class RestAPI {
get(url, token) {
return new Promise(function (resolve, reject) {
var req = request.get(url);
let req = request.get(url);
if (token != null) {
req.set('Authorization', "Bearer " + token);
@ -60,7 +53,7 @@ class RestAPI {
req.end(function (error, res) {
if (res == null || res.status !== 200) {
if (req.url !== prevURL) error.handled = isNetworkError(error);
if (req.url !== prevURL) error.handled = isNetworkError(error, url);
prevURL = req.url;
reject(error);
} else {
@ -72,7 +65,7 @@ class RestAPI {
post(url, body, token) {
return new Promise(function (resolve, reject) {
var req = request.post(url).send(body).timeout({ response: 5000 }); // Simple response start timeout (3s)
let req = request.post(url).send(body).timeout({ response: 5000 }); // Simple response start timeout (3s)
if (token != null) {
req.set('Authorization', "Bearer " + token);
@ -93,7 +86,7 @@ class RestAPI {
delete(url, token) {
return new Promise(function (resolve, reject) {
var req = request.delete(url);
let req = request.delete(url);
if (token != null) {
req.set('Authorization', "Bearer " + token);
@ -112,7 +105,7 @@ class RestAPI {
put(url, body, token) {
return new Promise(function (resolve, reject) {
var req = request.put(url).send(body);
let req = request.put(url).send(body);
if (token != null) {
req.set('Authorization', "Bearer " + token);
@ -151,11 +144,14 @@ class RestAPI {
download(url, token, fileID) {
return new Promise(function (resolve, reject) {
let req = request.get(url + "/" + fileID).buffer(true).responseType("blob")
// use blob response type and buffer
if (token != null) {
req.set('Authorization', "Bearer " + token);
let completeURL = url + "/" + fileID;
if (token != null){
completeURL = completeURL + "?token=" + token
}
let req = request.get(completeURL).buffer(true).responseType("blob")
// use blob response type and buffer
// Do not use auth header for file download
req.end(function (error, res) {
if (error !== null || res.status !== 200) {
@ -170,6 +166,28 @@ class RestAPI {
});
}
apiDownload(url, token) {
return new Promise(function (resolve, reject) {
let req = request.get(url).buffer(true).responseType("blob");
if (token != null) {
req.set('Authorization', "Bearer " + token);
}
req.end(function (error, res) {
if (res == null || res.status !== 200) {
if (req.url !== prevURL) error.handled = isNetworkError(error);
prevURL = req.url;
reject(error);
} else {
let parts = url.split("/");
resolve({data: res.body, type: res.type, id: parts[parts.length-1]})
}
});
});
}
}
export default new RestAPI();

View file

@ -14,6 +14,9 @@
* You should have received a copy of the GNU General Public License
* along with VILLASweb. If not, see <http://www.gnu.org/licenses/>.
******************************************************************************/
import NotificationsDataManager from "../data-managers/notifications-data-manager";
import NotificationsFactory from "../data-managers/notifications-factory";
import AppDispatcher from '../app-dispatcher';
class WebsocketAPI {
constructor(websocketurl, callbacks) {
@ -65,6 +68,10 @@ class WebsocketAPI {
}
onOpen = e => {
AppDispatcher.dispatch({
type: 'websocket/connected',
data: this.websocketurl,
});
this.wasConnected = true;
if ('onOpen' in this.callbacks)
@ -78,6 +85,12 @@ class WebsocketAPI {
}
else {
if (this.wasConnected) {
AppDispatcher.dispatch({
type: 'websocket/connection-error',
data: this.websocketurl,
});
NotificationsDataManager.addNotification(NotificationsFactory.WEBSOCKET_CONNECTION_WARN(this.websocketurl));
console.log("Connection to " + this.websocketurl + " dropped. Attempt reconnect in 1 sec");
window.setTimeout(() => { this.reconnect(); }, 1000);
}

View file

@ -19,6 +19,7 @@ import { ReduceStore } from 'flux/utils';
import AppDispatcher from './app-dispatcher';
import NotificationsDataManager from '../common/data-managers/notifications-data-manager';
import NotificationsFactory from "./data-managers/notifications-factory";
class ArrayStore extends ReduceStore {
constructor(type, dataManager) {
@ -83,13 +84,7 @@ class ArrayStore extends ReduceStore {
case this.type + '/load-error':
if (action.error && !action.error.handled && action.error.response) {
const USER_LOAD_ERROR_NOTIFICATION = {
title: 'Failed to load',
message: action.error.response.body.message,
level: 'error'
};
NotificationsDataManager.addNotification(USER_LOAD_ERROR_NOTIFICATION);
NotificationsDataManager.addNotification(NotificationsFactory.LOAD_ERROR(action.error.response.body.message));
}
return super.reduce(state, action);
@ -120,17 +115,10 @@ class ArrayStore extends ReduceStore {
return (item.id !== action.data);
});
}
case this.type + '/remove-error':
if (action.error && !action.error.handled && action.error.response) {
const USER_REMOVE_ERROR_NOTIFICATION = {
title: 'Failed to add remove ',
message: action.error.response.body.message,
level: 'error'
};
NotificationsDataManager.addNotification(USER_REMOVE_ERROR_NOTIFICATION);
NotificationsDataManager.addNotification(NotificationsFactory.DELETE_ERROR(action.error.response.body.message));
}
return super.reduce(state, action);

View file

@ -18,13 +18,125 @@
class NotificationsFactory {
// This is an example
static get EXAMPLE_NOTIFICATION() {
return {
title: 'Example notification',
message: 'Write something here that describes what happend.',
level: 'warning'
};
}
static get EXAMPLE_NOTIFICATION() {
return {
title: 'Example notification',
message: 'Write something here that describes what happend.',
level: 'warning'
};
}
static SERVER_NOT_REACHABLE(url) {
return {
title: 'Server not reachable',
message: 'The url ' + url +' could not be reached. Please try again later.',
level: 'error'
};
}
static REQUEST_TIMEOUT(url) {
return {
title: 'Request timeout',
message: 'Request to ' + url + ' timed out. Please try again later.',
level: 'error'
};
}
static INTERNAL_SERVER_ERROR(response) {
return {
title: 'Internal server error',
message: response.message,
level: 'error'
};
}
static ADD_ERROR(message) {
return {
title: "Add Error",
message: message,
level: 'error'
};
}
static UPDATE_ERROR(message) {
return {
title: 'Update Error ',
message: message,
level: 'error'
};
}
static UPDATE_WARNING(message) {
return {
title: 'Update Warning ',
message: message,
level: 'warning'
};
}
static LOAD_ERROR(message) {
return {
title: 'Failed to load',
message: message,
level: 'error'
};
}
static DELETE_ERROR(message) {
return {
title: 'Failed to delete ',
message: message,
level: 'error'
};
}
static WEBSOCKET_CONNECTION_WARN(websocket_url) {
return {
title: 'Websocket connection warning',
message: "Connection to " + websocket_url + " dropped. Attempt reconnect in 1 sec",
level: 'warning'
};
}
static WEBSOCKET_URL_WARN(ic_name, ic_uuid) {
return {
title: 'Websocket connection warning',
message: "Websocket URL parameter not available for IC " + ic_name + "(" + ic_uuid + "), connection not possible",
level: 'warning'
};
}
static SCENARIO_USERS_ERROR(message) {
return {
title: 'Failed to modify scenario users ',
message: message,
level: 'error'
};
}
static AUTOCONF_INFO() {
return {
title: 'Auto-configuration info',
message: 'Signal configuration loaded successfully.',
level: 'info'
};
}
static AUTOCONF_WARN(message) {
return {
title: 'Auto-configuration warning',
message: message,
level: 'warning'
};
}
static AUTOCONF_ERROR(message) {
return {
title: 'Auto-configuration error',
message: message,
level: 'error'
};
}
}

View file

@ -29,7 +29,7 @@ class CustomTable extends Component {
this.state = {
rows: CustomTable.getRows(props),
editCell: [ -1, -1 ]
editCell: [-1, -1]
};
}
@ -38,7 +38,7 @@ class CustomTable extends Component {
};
onClick(event, row, column) {
this.setState({ editCell: [ column, row ]}); // x, y
this.setState({ editCell: [column, row] }); // x, y
}
static addCell(data, index, child) {
@ -71,6 +71,12 @@ class CustomTable extends Component {
cell.push(<Link to={child.props.link + data[linkKey]}>{content}</Link>);
} else if (child.props.clickable) {
cell.push(<Button variant="link" onClick={() => child.props.onClick(index)}>{content}</Button>);
} else if (linkKey == 'filebuttons') {
content.forEach(element => {
cell.push(<OverlayTrigger key={element} placement={'bottom'} overlay={<Tooltip id={`tooltip-${"export"}`}>Download {element}</Tooltip>} >
<Button variant='table-control-button' onClick={() => child.props.onDownload(element)} disabled={child.props.onDownload == null}>{element + ' ' }
<Icon icon='file-download' /></Button></OverlayTrigger>);
});
} else {
cell.push(content);
}
@ -90,8 +96,8 @@ class CustomTable extends Component {
cell.push(<span>
&nbsp;
<FormLabel column={false} className={labelStyle}>
{labelContent}
</FormLabel>
{labelContent}
</FormLabel>
</span>
);
}
@ -103,14 +109,9 @@ class CustomTable extends Component {
// add buttons
if (child.props.editButton) {
let disable = (typeof data.managedexternally !== "undefined" && data.managedexternally);
cell.push(<OverlayTrigger key={0} placement={'bottom'} overlay={<Tooltip id={`tooltip-${"edit"}`}>{disable? "Externally managed ICs cannot be edited" : "edit"} </Tooltip>} >
<Button variant='table-control-button' onClick={() => child.props.onEdit(index)} disabled={disable || child.props.onEdit == null}><Icon icon='edit' /></Button></OverlayTrigger>);
}
if (child.props.deleteButton) {
cell.push(<OverlayTrigger key={1} placement={'bottom'} overlay={<Tooltip id={`tooltip-${"delete"}`}> Delete </Tooltip>} >
<Button variant='table-control-button' onClick={() => child.props.onDelete(index)} disabled={child.props.onDelete == null}><Icon icon='trash' /></Button></OverlayTrigger>);
let disable = (typeof data.managedexternally !== "undefined" && data.managedexternally);
cell.push(<OverlayTrigger key={0} placement={'bottom'} overlay={<Tooltip id={`tooltip-${"edit"}`}>{disable ? "Externally managed ICs cannot be edited" : "edit"} </Tooltip>} >
<Button variant='table-control-button' onClick={() => child.props.onEdit(index)} disabled={disable || child.props.onEdit == null}><Icon icon='edit' /></Button></OverlayTrigger>);
}
if (child.props.checkbox) {
@ -120,19 +121,34 @@ class CustomTable extends Component {
}
if (child.props.exportButton) {
cell.push(<OverlayTrigger key={2} placement={'bottom'} overlay={<Tooltip id={`tooltip-${"export"}`}> Export </Tooltip>} >
<Button variant='table-control-button' onClick={() => child.props.onExport(index)} disabled={child.props.onExport == null}><Icon icon='download' /></Button></OverlayTrigger>);
cell.push(<OverlayTrigger key={1} placement={'bottom'} overlay={<Tooltip id={`tooltip-${"export"}`}> Export </Tooltip>} >
<Button variant='table-control-button' onClick={() => child.props.onExport(index)} disabled={child.props.onExport == null}><Icon icon='download' /></Button></OverlayTrigger>);
}
if (child.props.duplicateButton) {
cell.push(<OverlayTrigger key={3} placement={'bottom'} overlay={<Tooltip id={`tooltip-${"duplicate"}`}> Duplicate </Tooltip>} >
<Button variant='table-control-button' onClick={() => child.props.onDuplicate(index)} disabled={child.props.onDuplicate == null}><Icon icon='copy' /></Button></OverlayTrigger>);
cell.push(<OverlayTrigger key={2} placement={'bottom'} overlay={<Tooltip id={`tooltip-${"duplicate"}`}> Duplicate </Tooltip>} >
<Button variant='table-control-button' onClick={() => child.props.onDuplicate(index)} disabled={child.props.onDuplicate == null}><Icon icon='copy' /></Button></OverlayTrigger>);
}
if (child.props.addRemoveFilesButton) {
cell.push(<OverlayTrigger key={3} placement={'bottom'} overlay={<Tooltip id={`tooltip-${"export"}`}>Add/remove File(s)</Tooltip>} >
<Button variant='table-control-button' onClick={() => child.props.onAddRemove(index)} disabled={child.props.onAddRemove == null}><Icon icon='file' /></Button></OverlayTrigger>);
}
if (child.props.downloadAllButton) {
cell.push(<OverlayTrigger key={4} placement={'bottom'} overlay={<Tooltip id={`tooltip-${"export"}`}>Download All Files</Tooltip>} >
<Button variant='table-control-button' onClick={() => child.props.onDownloadAll(index)} disabled={child.props.onDownloadAll == null}><Icon icon='file-download' /></Button></OverlayTrigger>);
}
if (child.props.deleteButton) {
cell.push(<OverlayTrigger key={5} placement={'bottom'} overlay={<Tooltip id={`tooltip-${"delete"}`}> Delete </Tooltip>} >
<Button variant='table-control-button' onClick={() => child.props.onDelete(index)} disabled={child.props.onDelete == null}><Icon icon='trash' /></Button></OverlayTrigger>);
}
return cell;
} // addCell
static getDerivedStateFromProps(props, state){
static getDerivedStateFromProps(props, state) {
const rows = CustomTable.getRows(props);
return { rows };
@ -147,12 +163,12 @@ class CustomTable extends Component {
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 ]});
this.setState({ editCell: [index.cell, index.row] });
}
cellLostFocus() {
// Reset cell selection state
this.setState({ editCell: [ -1, -1 ] });
this.setState({ editCell: [-1, -1] });
}
static getRows(props) {
@ -164,7 +180,7 @@ class CustomTable extends Component {
// check if multiple columns
if (Array.isArray(props.children) === false) {
// table only has a single column
return [ CustomTable.addCell(data, index, props.children) ];
return [CustomTable.addCell(data, index, props.children)];
}
const row = [];
@ -181,7 +197,7 @@ class CustomTable extends Component {
// get children
let children = this.props.children;
if (Array.isArray(this.props.children) === false) {
children = [ children ];
children = [children];
}
return (
@ -200,28 +216,28 @@ class CustomTable extends Component {
let isCellInlineEditable = children[cellIndex].props.inlineEditable === true;
let tabIndex = isCellInlineEditable? 0 : -1;
let tabIndex = isCellInlineEditable ? 0 : -1;
let evtHdls = isCellInlineEditable ? {
onCellClick: (event) => this.onClick(event, rowIndex, cellIndex),
onCellFocus: () => this.onCellFocus({cell: cellIndex, row: rowIndex}),
onCellFocus: () => this.onCellFocus({ cell: cellIndex, row: rowIndex }),
onCellBlur: () => this.cellLostFocus()
} : {
onCellClick: () => {},
onCellFocus: () => {},
onCellBlur: () => {}
};
onCellClick: () => { },
onCellFocus: () => { },
onCellBlur: () => { }
};
return (<td key={cellIndex} tabIndex={tabIndex} onClick={ evtHdls.onCellClick } onFocus={ evtHdls.onCellFocus } onBlur={ evtHdls.onCellBlur }>
{(this.state.editCell[0] === cellIndex && this.state.editCell[1] === rowIndex ) ? (
return (<td key={cellIndex} tabIndex={tabIndex} onClick={evtHdls.onCellClick} onFocus={evtHdls.onCellFocus} onBlur={evtHdls.onCellBlur}>
{(this.state.editCell[0] === cellIndex && this.state.editCell[1] === rowIndex) ? (
<FormControl as='input' type={children[cellIndex].props.inputType} value={cell} onChange={(event) => children[cellIndex].props.onInlineChange(event, rowIndex, cellIndex)} ref={ref => { this.activeInput = ref; }} />
) : (
<span>
{cell.map((element, elementIndex) => (
<span key={elementIndex}>{element}</span>
))}
</span>
)}
<span>
{cell.map((element, elementIndex) => (
<span key={elementIndex}>{element}</span>
))}
</span>
)}
</td>)
})
}

View file

@ -39,15 +39,15 @@ class DashboardButtonGroup extends React.Component {
const buttons = [];
let key = 0;
if (this.props.fullscreen) {
/*if (this.props.fullscreen) {
return null;
}
}*/
if (this.props.editing) {
buttons.push(
<OverlayTrigger key={key++} placement={'bottom'} overlay={<Tooltip id={`tooltip-${"save"}`}> Save changes </Tooltip>} >
<Button variant= 'light' size="lg" key={key} onClick={this.props.onSave} style={buttonStyle}>
<Icon icon="save" style={iconStyle} />
<Icon icon="save" style={iconStyle} />
</Button>
</OverlayTrigger>,
<OverlayTrigger key={key++} placement={'bottom'} overlay={<Tooltip id={`tooltip-${"cancel"}`}> Discard changes </Tooltip>} >
@ -61,17 +61,25 @@ class DashboardButtonGroup extends React.Component {
buttons.push(
<OverlayTrigger key={key++} placement={'bottom'} overlay={<Tooltip id={`tooltip-${"expand"}`}> Change to fullscreen view </Tooltip>} >
<Button key={key} variant= 'light' size="lg" onClick={this.props.onFullscreen} style={buttonStyle}>
<Icon icon="expand" style={iconStyle}/>
<Icon icon="expand" style={iconStyle}/>
</Button>
</OverlayTrigger>
);
} else {
buttons.push(
<OverlayTrigger key={key++} placement={'bottom'} overlay={<Tooltip id={`tooltip-${"compress"}`}> Back to normal view </Tooltip>} >
<Button key={key} variant= 'light' size="lg" onClick={this.props.onFullscreen} style={buttonStyle}>
<Icon icon="compress" style={iconStyle}/>
</Button>
</OverlayTrigger>
);
}
if (this.props.paused) {
buttons.push(
<OverlayTrigger key={key++} placement={'bottom'} overlay={<Tooltip id={`tooltip-${"play"}`}> Continue simulation </Tooltip>} >
<Button key={key} variant= 'light' size="lg" onClick={this.props.onUnpause} style={buttonStyle}>
<Icon icon="play" style={iconStyle}/>
<Icon icon="play" style={iconStyle}/>
</Button>
</OverlayTrigger>
);
@ -79,44 +87,49 @@ class DashboardButtonGroup extends React.Component {
buttons.push(
<OverlayTrigger key={key++} placement={'bottom'} overlay={<Tooltip id={`tooltip-${"pause"}`}> Pause simulation </Tooltip>} >
<Button key={key} variant= 'light' size="lg" onClick={this.props.onPause} style={buttonStyle}>
<Icon icon="pause" style={iconStyle}/>
<Icon icon="pause" style={iconStyle}/>
</Button>
</OverlayTrigger>
);
}
buttons.push(
<OverlayTrigger key={key++} placement={'bottom'} overlay={<Tooltip id={`tooltip-${"file"}`}> Add, edit or delete files of scenario </Tooltip>} >
<Button key={key} variant= 'light' size="lg" onClick={this.props.onEditFiles} style={buttonStyle}>
<Icon icon="file" style={iconStyle}/>
</Button>
</OverlayTrigger>
);
if (this.props.fullscreen !== true) {
buttons.push(
<OverlayTrigger key={key++} placement={'bottom'}
overlay={<Tooltip id={`tooltip-${"file"}`}> Add, edit or delete files of scenario </Tooltip>}>
<Button key={key} variant='light' size="lg" onClick={this.props.onEditFiles} style={buttonStyle}>
<Icon icon="file" style={iconStyle}/>
</Button>
</OverlayTrigger>
);
buttons.push(
<OverlayTrigger key={key++} placement={'bottom'} overlay={<Tooltip id={`tooltip-${"file"}`}> Add, edit or delete input signals </Tooltip>} >
<Button key={key} variant= 'light' size="lg" onClick={this.props.onEditInputSignals} style={buttonStyle}>
<Icon icon="sign-in-alt" style={iconStyle}/>
</Button>
</OverlayTrigger>
);
buttons.push(
<OverlayTrigger key={key++} placement={'bottom'}
overlay={<Tooltip id={`tooltip-${"file"}`}> Add, edit or delete input signals </Tooltip>}>
<Button key={key} variant='light' size="lg" onClick={this.props.onEditInputSignals} style={buttonStyle}>
<Icon icon="sign-in-alt" style={iconStyle}/>
</Button>
</OverlayTrigger>
);
buttons.push(
<OverlayTrigger key={key++} placement={'bottom'} overlay={<Tooltip id={`tooltip-${"file"}`}> Add, edit or delete output signals </Tooltip>} >
<Button key={key} variant= 'light' size="lg" onClick={this.props.onEditOutputSignals} style={buttonStyle}>
<Icon icon="sign-out-alt" style={iconStyle}/>
</Button>
</OverlayTrigger>
);
buttons.push(
<OverlayTrigger key={key++} placement={'bottom'} overlay={<Tooltip id={`tooltip-${"layout"}`}> Add widgets and edit layout </Tooltip>} >
<Button key={key} variant= 'light' size="lg" onClick={this.props.onEdit} style={buttonStyle}>
<Icon icon="pen" style={iconStyle} />
</Button>
</OverlayTrigger>
);
buttons.push(
<OverlayTrigger key={key++} placement={'bottom'}
overlay={<Tooltip id={`tooltip-${"file"}`}> Add, edit or delete output signals </Tooltip>}>
<Button key={key} variant='light' size="lg" onClick={this.props.onEditOutputSignals} style={buttonStyle}>
<Icon icon="sign-out-alt" style={iconStyle}/>
</Button>
</OverlayTrigger>
);
buttons.push(
<OverlayTrigger key={key++} placement={'bottom'}
overlay={<Tooltip id={`tooltip-${"layout"}`}> Add widgets and edit layout </Tooltip>}>
<Button key={key} variant='light' size="lg" onClick={this.props.onEdit} style={buttonStyle}>
<Icon icon="pen" style={iconStyle}/>
</Button>
</OverlayTrigger>
);
}
}
return <div className='section-buttons-group-right'>

View file

@ -557,6 +557,7 @@ class Dashboard extends Component {
widget={this.state.modalData}
signals={this.state.signals}
files={this.state.files}
ics={this.state.ics}
/>
<EditFiles

View file

@ -40,7 +40,7 @@ class EditFilesDialog extends React.Component {
}
onClose() {
this.props.onClose();
}
@ -66,7 +66,12 @@ class EditFilesDialog extends React.Component {
};
updateUploadProgress = (event) => {
this.setState({ uploadProgress: parseInt(event.percent.toFixed(), 10) });
if (event.hasOwnProperty("percent")){
this.setState({ uploadProgress: parseInt(event.percent.toFixed(), 10) });
} else {
this.setState({ uploadProgress: 0 });
}
};
clearProgress = (newFileID) => {
@ -83,7 +88,7 @@ class EditFilesDialog extends React.Component {
};
closeEditModal(){
this.setState({editModal: false});
}
@ -168,7 +173,7 @@ class EditFilesDialog extends React.Component {
</div>
</Dialog>
);
}
}

48
src/ic/confirm-command.js Normal file
View file

@ -0,0 +1,48 @@
/**
* 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 <http://www.gnu.org/licenses/>.
******************************************************************************/
import React from 'react';
import { Button, Modal} from 'react-bootstrap';
class ConfirmCommand extends React.Component {
onModalKeyPress = (event) => {
if (event.key === 'Enter') {
event.preventDefault();
this.props.onClose(false);
}
}
render() {
return <Modal keyboard show={this.props.show} onHide={() => this.props.onClose(false)} onKeyPress={this.onModalKeyPress}>
<Modal.Header>
<Modal.Title>Confirm {this.props.command}</Modal.Title>
</Modal.Header>
<Modal.Body>
Are you sure you want to {this.props.command} <strong>'{this.props.name}'</strong>?
</Modal.Body>
<Modal.Footer>
<Button onClick={() => this.props.onClose(true)}>Cancel</Button>
<Button onClick={() => this.props.onClose(false)}>Confirm</Button>
</Modal.Footer>
</Modal>;
}
}
export default ConfirmCommand;

View file

@ -50,13 +50,13 @@ class EditICDialog extends React.Component {
data.name = this.state.name;
}
if (this.state.websocketurl != null && this.state.websocketurl !== "" && this.state.websocketurl !== "http://" && this.state.websocketurl !== this.props.ic.websocketurl) {
data.websocketurl = this.state.websocketurl;
}
if (this.state.apiurl != null && this.state.apiurl !== "" && this.state.apiurl !== "http://" && this.state.apiurl !== this.props.ic.apiurl) {
data.apiurl = this.state.apiurl;
}
data.websocketurl = this.state.websocketurl;
data.apiurl = this.state.apiurl;
if (this.state.location != null && this.state.location !== this.props.ic.location) {
data.location = this.state.location;
@ -120,7 +120,7 @@ class EditICDialog extends React.Component {
let typeOptions = [];
switch(this.state.category){
case "simulator":
typeOptions = ["dummy","generic","dpsim","rtlab","rscad"];
typeOptions = ["dummy","generic","dpsim","rtlab","rscad", "opalrt"];
break;
case "controller":
typeOptions = ["kubernetes","villas-controller"];
@ -175,12 +175,12 @@ class EditICDialog extends React.Component {
</FormGroup>
<FormGroup controlId="websocketurl">
<FormLabel column={false}>Websocket URL</FormLabel>
<FormControl type="text" placeholder={this.props.ic.websocketurl} value={this.state.websocketurl || 'http://' } onChange={(e) => this.handleChange(e)} />
<FormControl type="text" placeholder={this.props.ic.websocketurl} value={this.state.websocketurl} onChange={(e) => this.handleChange(e)} />
<FormControl.Feedback />
</FormGroup>
<FormGroup controlId="apiurl">
<FormLabel column={false}>API URL</FormLabel>
<FormControl type="text" placeholder={this.props.ic.apiurl} value={this.state.apiurl || 'http://' } onChange={(e) => this.handleChange(e)} />
<FormControl type="text" placeholder={this.props.ic.apiurl} value={this.state.apiurl} onChange={(e) => this.handleChange(e)} />
<FormControl.Feedback />
</FormGroup>
<FormGroup controlId="location">

View file

@ -17,6 +17,7 @@
import WebsocketAPI from '../common/api/websocket-api';
import AppDispatcher from '../common/app-dispatcher';
import RestAPI from "../common/api/rest-api";
const OFFSET_TYPE = 2;
const OFFSET_VERSION = 4;

View file

@ -1,6 +1,10 @@
import React from 'react';
import {FormLabel} from 'react-bootstrap';
import {Button, Row, Col} from 'react-bootstrap';
import Dialog from '../common/dialogs/dialog';
import Icon from "../common/icon";
import ConfirmCommand from './confirm-command';
import ReactJson from 'react-json-view';
import FileSaver from 'file-saver';
class ICDialog extends React.Component {
@ -10,7 +14,8 @@ class ICDialog extends React.Component {
super(props);
this.state = {
ic: props.ic
confirmCommand: false,
command: '',
};
}
@ -19,25 +24,96 @@ class ICDialog extends React.Component {
}
handleChange(e) {
}
showFurtherInfo(key){
if(typeof this.state[key] === 'undefined') this.setState({[key]: false});
this.setState({[key]: !this.state[key]});
}
closeConfirmModal(canceled){
if(!canceled){
this.props.sendControlCommand(this.state.command,this.props.ic);
}
this.setState({confirmCommand: false, command: ''});
}
async downloadGraph(url) {
let blob = await fetch(url).then(r => r.blob())
FileSaver.saveAs(blob, this.props.ic.name + ".svg");
}
render() {
let graphURL = ""
if (this.props.ic.apiurl !== ""){
graphURL = this.props.ic.apiurl + "/graph.svg"
}
return (
<Dialog
show={this.props.show}
title="Infos and Controls"
buttonTitle="Save"
title={this.props.ic.name + " ( " + this.props.ic.uuid + " )"}
buttonTitle="Close"
onClose={(c) => this.onClose(c)}
valid={true}
size='lg'
size='xl'
blendOutCancel={true}
>
<form>
<FormLabel>Infos and Controls</FormLabel>
<Row>
<Col>
<h5>Status:</h5>
<ReactJson
src={this.props.ic.statusupdateraw}
name={false}
displayDataTypes={false}
displayObjectSize={false}
enableClipboard={false}
collapsed={1}
/>
</Col>
{this.props.ic.type === "villas-node" || this.props.ic.type === "villas-relay" ? (
<Col>
<div className='section-buttons-group-right'>
<Button style={{margin: '5px'}} size='sm' onClick={() => this.downloadGraph(graphURL)}><Icon
icon="download"/></Button>
</div>
<h5>Graph:</h5>
<div>
<img alt={"Graph image download failed and/or incorrect image URL"} src={graphURL}/>
</div>
{this.props.userRole === "Admin" ? (
<div>
<h5>Controls:</h5>
<div>
<Button style={{margin: '5px'}} size='lg'
onClick={() => this.setState({confirmCommand: true, command: 'restart'})}>Restart</Button>
<Button style={{margin: '5px'}} size='lg' onClick={() => this.setState({
confirmCommand: true,
command: 'shutdown'
})}>Shutdown</Button>
</div>
</div>)
: (<div/>)}
<ConfirmCommand show={this.state.confirmCommand} command={this.state.command} name={this.props.ic.name}
onClose={c => this.closeConfirmModal(c)}/>
</Col>
): (<div/>)}
</Row>
</form>
</Dialog>
);
}
}

View file

@ -19,6 +19,8 @@ import ArrayStore from '../common/array-store';
import ICsDataManager from './ics-data-manager';
import ICDataDataManager from './ic-data-data-manager';
import NotificationsDataManager from "../common/data-managers/notifications-data-manager";
import NotificationsFactory from "../common/data-managers/notifications-factory";
import AppDispatcher from '../common/app-dispatcher';
class InfrastructureComponentStore extends ArrayStore {
constructor() {
@ -47,14 +49,7 @@ class InfrastructureComponentStore extends ArrayStore {
if (ic.websocketurl != null && ic.websocketurl !== '') {
ICDataDataManager.open(ic.websocketurl, ic.id);
} else {
// TODO add to pool of notifications
const IC_WEBSOCKET_URL_ERROR = {
title: 'Websocket connection warning',
message: "Websocket URL parameter not available for IC " + ic.name + "(" + ic.uuid + "), connection not possible",
level: 'warning'
};
NotificationsDataManager.addNotification(IC_WEBSOCKET_URL_ERROR);
NotificationsDataManager.addNotification(NotificationsFactory.WEBSOCKET_URL_WARN(ic.name, ic.uuid));
}
}
return super.reduce(state, action);
@ -81,6 +76,51 @@ class InfrastructureComponentStore extends ArrayStore {
console.log(action.error);
return state;
case 'ics/get-status':
ICsDataManager.getStatus(action.url, action.token, action.ic);
return super.reduce(state, action);
case 'ics/status-received':
let tempIC = action.ic;
if(!tempIC.managedexternally){
tempIC.state = action.data.state;
tempIC.statusupdateraw = action.data;
AppDispatcher.dispatch({
type: 'ics/start-edit',
data: tempIC,
token: action.token,
});
}
return super.reduce(state, action);
case 'ics/status-error':
console.log("status error:", action.error);
return super.reduce(state, action);
case 'ics/restart':
ICsDataManager.restart(action.url, action.token);
return super.reduce(state, action);
case 'ics/restart-successful':
console.log("restart response:", action.data);
return super.reduce(state, action);
case 'ics/restart-error':
console.log("restart error:", action.error);
return super.reduce(state, action);
case 'ics/shutdown':
ICsDataManager.shutdown(action.url, action.token);
return super.reduce(state, action);
case 'ics/shutdown-successful':
console.log("shutdown response:", action.data);
return super.reduce(state, action);
case 'ics/shutdown-error':
console.log("shutdown error:", action.error);
return super.reduce(state, action);
default:
return super.reduce(state, action);
}

View file

@ -20,23 +20,69 @@ import RestAPI from '../common/api/rest-api';
import AppDispatcher from '../common/app-dispatcher';
class IcsDataManager extends RestDataManager {
constructor() {
super('ic', '/ic');
}
constructor() {
super('ic', '/ic');
}
doActions(ic, action, token = null) {
RestAPI.post(this.makeURL(this.url + '/' + ic.id + '/action'), action, token).then(response => {
AppDispatcher.dispatch({
type: 'ics/action-started',
data: response
});
}).catch(error => {
AppDispatcher.dispatch({
type: 'ics/action-error',
error
});
});
}
doActions(ic, action, token = null) {
RestAPI.post(this.makeURL(this.url + '/' + ic.id + '/action'), action, token).then(response => {
AppDispatcher.dispatch({
type: 'ics/action-started',
data: response
});
}).catch(error => {
AppDispatcher.dispatch({
type: 'ics/action-error',
error
});
});
}
getStatus(url,token,ic){
RestAPI.get(url, null).then(response => {
AppDispatcher.dispatch({
type: 'ics/status-received',
data: response,
token: token,
ic: ic
});
}).catch(error => {
AppDispatcher.dispatch({
type: 'ics/status-error',
error: error
})
})
}
restart(url,token){
RestAPI.post(url, null).then(response => {
AppDispatcher.dispatch({
type: 'ics/restart-successful',
data: response,
token: token,
});
}).catch(error => {
AppDispatcher.dispatch({
type: 'ics/restart-error',
error: error
})
})
}
shutdown(url,token){
RestAPI.post(url, null).then(response => {
AppDispatcher.dispatch({
type: 'ics/shutdown-successful',
data: response,
token: token,
});
}).catch(error => {
AppDispatcher.dispatch({
type: 'ics/shutdown-error',
error: error
})
})
}
}
export default new IcsDataManager();

View file

@ -38,7 +38,7 @@ import DeleteDialog from '../common/dialogs/delete-dialog';
class InfrastructureComponents extends Component {
static getStores() {
return [ InfrastructureComponentStore ];
return [ InfrastructureComponentStore];
}
static statePrio(state) {
@ -91,7 +91,7 @@ class InfrastructureComponents extends Component {
token: this.state.sessionToken,
});
// Start timer for periodic refresh
// Start timer for periodic refresh
this.timer = window.setInterval(() => this.refresh(), 10000);
}
@ -109,6 +109,20 @@ class InfrastructureComponents extends Component {
type: 'ics/start-load',
token: this.state.sessionToken,
});
// get status of VILLASnode and VILLASrelay ICs
this.state.ics.forEach(ic => {
if ((ic.type === "villas-node" || ic.type === "villas-relay")
&& ic.apiurl !== '' && ic.apiurl !== undefined && ic.apiurl !== null && !ic.managedexternally) {
AppDispatcher.dispatch({
type: 'ics/get-status',
url: ic.apiurl + "/status",
token: this.state.sessionToken,
ic: ic
});
}
})
}
}
@ -218,12 +232,12 @@ class InfrastructureComponents extends Component {
}
static isICOutdated(component) {
if (!component.stateUpdatedAt)
if (!component.stateUpdateAt)
return true;
const fiveMinutes = 5 * 60 * 1000;
return Date.now() - new Date(component.stateUpdatedAt) > fiveMinutes;
return Date.now() - new Date(component.stateUpdateAt) > fiveMinutes;
}
stateLabelStyle(state, component){
@ -292,7 +306,7 @@ class InfrastructureComponents extends Component {
}
stateUpdateModifier(updatedAt) {
let dateFormat = 'DD MMM YYYY HH:mm:ss';
let dateFormat = 'ddd, DD MMM YYYY HH:mm:ss ZZ';
let dateTime = moment(updatedAt, dateFormat);
return dateTime.fromNow()
}
@ -302,7 +316,7 @@ class InfrastructureComponents extends Component {
if(managedExternally){
return <Icon icon='check' />
} else {
return <Icon icon='times' />
return ""
}
}
@ -326,16 +340,58 @@ class InfrastructureComponents extends Component {
}
}
openICStatus(ic){
let index = this.state.ics.indexOf(ic);
this.setState({ icModal: true, modalIC: ic, modalIndex: index })
}
sendControlCommand(command,ic){
if(command === "restart"){
AppDispatcher.dispatch({
type: 'ics/restart',
url: ic.apiurl + "/restart",
token: this.state.sessionToken,
});
}else if(command === "shutdown"){
AppDispatcher.dispatch({
type: 'ics/shutdown',
url: ic.apiurl + "/shutdown",
token: this.state.sessionToken,
});
}
}
render() {
const buttonStyle = {
marginLeft: '10px',
backgroundColor: '#527984',
borderColor: '#527984'
};
backgroundColor: '#ffffff',
borderColor: '#ffffff'
}
const iconStyle = {
color: '#527984',
height: '30px',
width: '30px'
}
return (
<div className='section'>
<h1>Infrastructure Components</h1>
<h1>Infrastructure Components
{this.state.currentUser.role === "Admin" ?
<span>
<Button onClick={() => this.setState({ newModal: true })} style={buttonStyle}><Icon icon="plus" style={iconStyle} /></Button>
<Button onClick={() => this.setState({ importModal: true })} style={buttonStyle}><Icon icon="upload" style={iconStyle} /></Button>
</span>
:
<span> </span>
}
</h1>
<Table data={this.state.ics}>
<TableColumn checkbox onChecked={(index, event) => this.onICChecked(index, event)} width='30' />
@ -384,21 +440,18 @@ class InfrastructureComponents extends Component {
<div> </div>
}
{this.state.currentUser.role === "Admin" ?
<div style={{ float: 'right' }}>
<Button onClick={() => this.setState({ newModal: true })} style={buttonStyle}><Icon icon="plus" /> Infrastructure Component</Button>
<Button onClick={() => this.setState({ importModal: true })} style={buttonStyle}><Icon icon="upload" /> Import</Button>
</div>
:
<div> </div>
}
<div style={{ clear: 'both' }} />
<NewICDialog show={this.state.newModal} onClose={data => this.closeNewModal(data)} />
<EditICDialog show={this.state.editModal} onClose={data => this.closeEditModal(data)} ic={this.state.modalIC} />
<ImportICDialog show={this.state.importModal} onClose={data => this.closeImportModal(data)} />
<ICDialog show={this.state.icModal} onClose={data => this.closeICModal(data)} ic={this.state.modalIC} token={this.state.sessionToken} />
<ICDialog
show={this.state.icModal}
onClose={data => this.closeICModal(data)}
ic={this.state.modalIC}
token={this.state.sessionToken}
userRole={this.state.currentUser.role}
sendControlCommand={(command, ic) => this.sendControlCommand(command, ic)}/>
<DeleteDialog title="infrastructure-component" name={this.state.modalIC.name || 'Unknown'} show={this.state.deleteModal} onClose={(e) => this.closeDeleteModal(e)} />
</div>

View file

@ -28,10 +28,13 @@ class NewICDialog extends React.Component {
this.state = {
name: '',
websocketurl: '',
apiurl: '',
uuid: '',
type: '',
category: '',
managedexternally: false,
description: '',
location: ''
};
}
@ -44,12 +47,18 @@ class NewICDialog extends React.Component {
category: this.state.category,
uuid: this.state.uuid,
managedexternally: this.state.managedexternally,
location: this.state.location,
description: this.state.description
};
if (this.state.websocketurl != null && this.state.websocketurl !== "" && this.state.websocketurl !== 'http://') {
data.websocketurl = this.state.websocketurl;
}
if (this.state.apiurl != null && this.state.apiurl !== "" && this.state.apiurl !== 'http://') {
data.apiurl = this.state.apiurl;
}
this.props.onClose(data);
this.setState({managedexternally: false});
}
@ -69,7 +78,7 @@ class NewICDialog extends React.Component {
}
resetState() {
this.setState({ name: '', websocketurl: 'http://', uuid: this.uuidv4(), type: '', category: '', managedexternally: false});
this.setState({ name: '', websocketurl: 'http://', apiurl: 'http://', uuid: this.uuidv4(), type: '', category: '', managedexternally: false, description: '', location: ''});
}
validateForm(target) {
@ -120,7 +129,7 @@ class NewICDialog extends React.Component {
let typeOptions = [];
switch(this.state.category){
case "simulator":
typeOptions = ["dummy","generic","dpsim","rtlab","rscad"];
typeOptions = ["dummy","generic","dpsim","rtlab","rscad","opalrt"];
break;
case "controller":
typeOptions = ["kubernetes","villas-controller"];

167
src/result/edit-result.js Normal file
View file

@ -0,0 +1,167 @@
/**
* 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 <http://www.gnu.org/licenses/>.
******************************************************************************/
import React from 'react';
import {FormGroup, FormControl, FormLabel, Col, Button, ProgressBar} from 'react-bootstrap';
import AppDispatcher from "../common/app-dispatcher";
import FileStore from "../file/file-store"
import Table from "../common/table";
import TableColumn from "../common/table-column";
import Dialog from '../common/dialogs/dialog';
class EditResultDialog extends React.Component {
valid = true;
constructor(props) {
super(props);
this.state = {
id: 0,
description: '',
uploadFile: null,
uploadProgress: 0,
files: null,
result: null,
resultExists: false,
};
}
onClose() {
if (this.props.onClose != null) {
this.props.onClose();
}
};
handleChange = event => {
this.setState({ [event.target.id]: event.target.value });
};
componentDidUpdate(prevProps, prevState) {
if (this.state.resultExists && this.props.files != prevProps.files) {
this.setState({files: FileStore.getState().filter(file => this.state.result.resultFileIDs.includes(file.id))});
}
if (this.props.result != prevProps.result && Object.keys(this.props.result).length != 0) {
this.setState({
id: this.props.result.id,
description: this.props.result.description,
result: this.props.result,
resultExists: true,
files: FileStore.getState().filter(file => this.props.result.resultFileIDs.includes(file.id)),
})
}
}
selectUploadFile(event) {
this.setState({ uploadFile: event.target.files[0] });
};
startFileUpload(){
const formData = new FormData();
formData.append("file", this.state.uploadFile);
AppDispatcher.dispatch({
type: 'resultfiles/start-upload',
data: formData,
resultID: this.state.id,
token: this.props.sessionToken,
progressCallback: this.updateUploadProgress,
finishedCallback: this.clearProgress,
scenarioID: this.props.scenarioID,
});
this.setState({ uploadFile: null });
};
clearProgress = (newFileID) => {
this.setState({ uploadProgress: 0 });
};
updateUploadProgress = (event) => {
this.setState({ uploadProgress: parseInt(event.percent.toFixed(), 10) });
};
deleteFile(index){
let file = this.state.files[index];
AppDispatcher.dispatch({
type: 'files/start-remove',
data: file,
token: this.props.sessionToken
});
}
render() {
return <Dialog show={this.props.show}
title={'Edit Result No. '+this.state.id}
buttonTitle='Close'
onClose={() => this.onClose()}
blendOutCancel = {true}
valid={true}
size = 'lg'>
<div>
<FormGroup as={Col} controlId='description'>
<FormLabel column={false}>Description</FormLabel>
<FormControl type='text' placeholder='Enter description' value={this.state.description} onChange={this.handleChange} />
<FormControl.Feedback />
</FormGroup>
<Table data={this.state.files}>
<TableColumn title='ID' dataKey='id'/>
<TableColumn title='Name' dataKey='name'/>
<TableColumn title='Size (bytes)' dataKey='size'/>
<TableColumn title='Type' dataKey='type'/>
<TableColumn
title=''
deleteButton
onDelete={(index) => this.deleteFile(index)}
/>
</Table>
<FormGroup controlId='resultfile'>
<FormLabel>Add Result File</FormLabel>
<FormControl type='file' onChange={(event) => this.selectUploadFile(event)} />
</FormGroup>
<FormGroup as={Col} >
<Button
disabled={this.state.uploadFile === null}
onClick={() => this.startFileUpload()}>
Upload
</Button>
</FormGroup>
<FormGroup as={Col} >
<ProgressBar
striped={true}
animated={true}
now={this.state.uploadProgress}
label={this.state.uploadProgress + '%'}
/>
</FormGroup>
</div>
</Dialog>;
}
}
export default EditResultDialog;

70
src/result/new-result.js Normal file
View file

@ -0,0 +1,70 @@
/**
* 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 <http://www.gnu.org/licenses/>.
******************************************************************************/
import React from 'react';
import { FormGroup, FormControl, FormLabel } from 'react-bootstrap';
import Dialog from '../common/dialogs/dialog';
class NewResultDialog extends React.Component {
constructor(props) {
super(props);
this.state = {
ConfigSnapshots: '',
Description: '',
ResultFileIDs: [],
}
}
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({
ConfigSnapshots: '',
Description: '',
ResultFileIDs: [],
});
}
render() {
return (
<Dialog show={this.props.show} title="New Result" buttonTitle="Add" onClose={(c) => this.onClose(c)} onReset={() => this.resetState()} valid={true}>
<form>
<FormGroup controlId="Description">
<FormLabel>Description</FormLabel>
<FormControl type="text" placeholder="Enter description" value={this.state.Description} onChange={(e) => this.handleChange(e)} />
<FormControl.Feedback />
</FormGroup>
</form>
</Dialog>
);
}
}
export default NewResultDialog;

View file

@ -0,0 +1,88 @@
/**
* 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 <http://www.gnu.org/licenses/>.
******************************************************************************/
import ArrayStore from '../common/array-store';
import ResultsDataManager from './results-data-manager';
import FilesDataManager from '../file/files-data-manager'
class ResultStore extends ArrayStore {
constructor() {
super('results', ResultsDataManager);
}
saveFile(state, action){
let fileID = parseInt(action.id)
state.forEach((element, index, array) => {
if (element.id === fileID) {
// save blob object
array[index]["data"] = new Blob([action.data.data], {type: action.data.type});
// update file type
array[index]["type"] = action.data.type;
if (array[index]["objectURL"] !== ''){
// free memory of previously generated object URL
URL.revokeObjectURL(array[index]["objectURL"]);
}
// create an object URL for the file
array[index]["objectURL"] = URL.createObjectURL(array[index]["data"])
}
});
// announce change to listeners
this.__emitChange();
return state
}
simplify(timestamp) {
let parts = timestamp.split("T");
let datestr = parts[0];
let time = parts[1].split(".");
return datestr + ', ' + time[0];;
}
simplifyTimestamps(data) {
data.forEach((result) => {
result.createdAt = this.simplify(result.createdAt);
result.updatedAt = this.simplify(result.updatedAt);
});
}
reduce(state, action) {
switch (action.type) {
case 'results/loaded':
this.simplifyTimestamps(action.data);
return super.reduce(state, action);
case 'results/added':
this.simplifyTimestamps([action.data]);
return super.reduce(state, action);
case 'resultfiles/start-upload':
ResultsDataManager.uploadFile(action.data, action.resultID, action.token, action.progressCallback, action.finishedCallback, action.scenarioID);
return state;
default:
return super.reduce(state, action);
}
}
}
export default new ResultStore();

View file

@ -0,0 +1,62 @@
/**
* 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 <http://www.gnu.org/licenses/>.
******************************************************************************/
import RestDataManager from '../common/data-managers/rest-data-manager';
import RestAPI from '../common/api/rest-api';
import AppDispatcher from '../common/app-dispatcher';
class ResultsDataManager extends RestDataManager{
constructor() {
super('result', '/results');
}
uploadFile(file, resultID, token = null, progressCallback = null, finishedCallback = null, scenarioID) {
RestAPI.upload(this.makeURL(this.url + '/' + resultID + '/file') , file, token, progressCallback, scenarioID).then(response => {
AppDispatcher.dispatch({
type: 'files/uploaded',
});
// Trigger a results reload
AppDispatcher.dispatch({
type: 'results/start-load',
param: '?scenarioID=' + scenarioID,
token: token
});
// Trigger a files reload
AppDispatcher.dispatch({
type: 'files/start-load',
param: '?scenarioID=' + scenarioID,
token: token
});
if (finishedCallback) {
finishedCallback(response.result.id);
}
}).catch(error => {
AppDispatcher.dispatch({
type: 'files/upload-error',
error
});
});
}
}
export default new ResultsDataManager();

View file

@ -19,6 +19,7 @@
import ScenariosDataManager from './scenarios-data-manager';
import ArrayStore from '../common/array-store';
import NotificationsDataManager from "../common/data-managers/notifications-data-manager";
import NotificationsFactory from "../common/data-managers/notifications-factory";
class ScenarioStore extends ArrayStore{
@ -89,14 +90,7 @@ class ScenarioStore extends ArrayStore{
case 'scenarios/users-error':
if (action.error && !action.error.handled && action.error.response) {
const SCENARIO_USERS_ERROR_NOTIFICATION = {
title: 'Failed to modify scenario users ',
message: action.error.response.body.message,
level: 'error'
};
NotificationsDataManager.addNotification(SCENARIO_USERS_ERROR_NOTIFICATION);
NotificationsDataManager.addNotification(NotificationsFactory.SCENARIO_USERS_ERROR(action.error.response.body.message));
}
return super.reduce(state, action);

View file

@ -36,6 +36,9 @@ import ImportDashboardDialog from "../dashboard/import-dashboard";
import NewDashboardDialog from "../dashboard/new-dashboard";
import EditDashboardDialog from '../dashboard/edit-dashboard';
import EditFiles from '../file/edit-files'
import NewResultDialog from '../result/new-result';
import EditResultDialog from '../result/edit-result'
import ICAction from '../ic/ic-action';
import DeleteDialog from '../common/dialogs/delete-dialog';
@ -43,13 +46,16 @@ import EditConfigDialog from "../componentconfig/edit-config";
import EditSignalMapping from "../signal/edit-signal-mapping";
import FileStore from "../file/file-store"
import WidgetStore from "../widget/widget-store";
import ResultStore from "../result/result-store"
import { Redirect } from 'react-router-dom';
import NotificationsDataManager from "../common/data-managers/notifications-data-manager";
var JSZip = require("jszip");
class Scenario extends React.Component {
static getStores() {
return [ScenarioStore, ConfigStore, DashboardStore, ICStore, SignalStore, FileStore, WidgetStore];
return [ScenarioStore, ConfigStore, DashboardStore, ICStore, SignalStore, FileStore, WidgetStore, ResultStore];
}
static calculateState(prevState, props) {
@ -68,7 +74,6 @@ class Scenario extends React.Component {
});
}
// obtain all component configurations of a scenario
let configs = ConfigStore.getState().filter(config => config.scenarioID === parseInt(props.match.params.scenario, 10));
let editConfigModal = prevState.editConfigModal || false;
@ -82,8 +87,11 @@ class Scenario extends React.Component {
modalConfigIndex = index;
}
let results = ResultStore.getState().filter(result => result.scenarioID === parseInt(props.match.params.scenario, 10));
return {
scenario,
results,
sessionToken,
configs,
editConfigModal,
@ -103,6 +111,12 @@ class Scenario extends React.Component {
filesEditModal: prevState.filesEditModal || false,
filesEditSaveState: prevState.filesEditSaveState || [],
editResultsModal: prevState.editResultsModal || false,
modalResultsData: {},
modalResultsIndex: 0,
newResultModal: false,
filesToDownload: [],
editOutputSignalsModal: prevState.editOutputSignalsModal || false,
editInputSignalsModal: prevState.editInputSignalsModal || false,
@ -142,6 +156,32 @@ class Scenario extends React.Component {
});
}
componentDidUpdate(prevProps, prevState) {
// check whether file data has been loaded
if (this.state.filesToDownload.length > 0 ) {
if (this.state.filesToDownload.length === 1) {
let fileToDownload = FileStore.getState().filter(file => file.id === this.state.filesToDownload[0])
if (fileToDownload.length === 1 && fileToDownload[0].data) {
const blob = new Blob([fileToDownload[0].data], {type: fileToDownload[0].type});
FileSaver.saveAs(blob, fileToDownload[0].name);
this.setState({ filesToDownload: [] });
}
} else { // zip and save several files
let filesToDownload = FileStore.getState().filter(file => this.state.filesToDownload.includes(file.id) && file.data);
if (filesToDownload.length === this.state.filesToDownload.length) { // all requested files have been loaded
var zip = new JSZip();
filesToDownload.forEach(file => {
zip.file(file.name, file.data);
});
zip.generateAsync({type: "blob"}).then(function(content) {
saveAs(content, "results.zip");
});
this.setState({ filesToDownload: [] });
}
}
}
}
/* ##############################################
* User modification methods
############################################## */
@ -347,7 +387,7 @@ class Scenario extends React.Component {
}
// Unix time stamp + delay
action.data.when = Date.now() / 1000.0 + delay
action.data.when = Math.round(Date.now() / 1000.0 + delay)
console.log("Sending action: ", action.data)
@ -375,6 +415,7 @@ class Scenario extends React.Component {
closeNewDashboardModal(data) {
this.setState({ newDashboardModal: false });
if (data) {
// TODO: 'newDashboard' not used, check this
let newDashboard = data;
// add default grid value and scenarioID
newDashboard["grid"] = 15;
@ -495,11 +536,11 @@ class Scenario extends React.Component {
// TODO do we need this if the dispatches happen in the dialog?
}
signalsAutoConf(index){
signalsAutoConf(index) {
let componentConfig = this.state.configs[index];
// determine apiurl of infrastructure component
let ic = this.state.ics.find(ic => ic.id === componentConfig.icID)
if(!ic.type.includes("villas-node")){
if (!ic.type.includes("villas-node")) {
let message = "Cannot autoconfigure signals for IC type " + ic.type + " of category " + ic.category + ". This is only possible for gateway ICs of type 'VILLASnode'."
console.warn(message);
@ -516,8 +557,8 @@ class Scenario extends React.Component {
AppDispatcher.dispatch({
type: 'signals/start-autoconfig',
url: ic.apiurl+"/nodes",
socketname: splitWebsocketURL[splitWebsocketURL.length -1],
url: ic.apiurl + "/nodes",
socketname: splitWebsocketURL[splitWebsocketURL.length - 1],
token: this.state.sessionToken,
configID: componentConfig.id
});
@ -525,7 +566,7 @@ class Scenario extends React.Component {
}
uuidv4() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
// eslint-disable-next-line
var r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
@ -554,6 +595,66 @@ class Scenario extends React.Component {
return fileList;
}
/* ##############################################
* Result modification methods
############################################## */
closeNewResultModal(data) {
this.setState({ newResultModal: false });
if (data) {
data["scenarioID"] = this.state.scenario.id;
AppDispatcher.dispatch({
type: 'results/start-add',
data,
token: this.state.sessionToken,
});
}
}
closeEditResultsModal() {
this.setState({ editResultsModal: false });
AppDispatcher.dispatch({
type: 'results/start-load',
token: this.state.sessionToken,
param: '?scenarioID=' + this.state.scenario.id
})
}
downloadResultData(param) {
let toDownload = [];
if (typeof(param) === 'object') {
toDownload = param.resultFileIDs;
} else {
toDownload.push(param);
}
toDownload.forEach(fileid => {
AppDispatcher.dispatch({
type: 'files/start-download',
data: fileid,
token: this.state.sessionToken
});
});
this.setState({ filesToDownload: toDownload });
}
closeDeleteResultsModal(confirmDelete) {
this.setState({ deleteResultsModal: false });
if (confirmDelete === false) {
return;
}
AppDispatcher.dispatch({
type: 'results/start-remove',
data: this.state.modalResultsData,
token: this.state.sessionToken,
});
}
startPintura(configIndex) {
let config = this.state.configs[configIndex];
@ -585,6 +686,7 @@ class Scenario extends React.Component {
}
}
/* ##############################################
* Render method
############################################## */
@ -613,14 +715,52 @@ class Scenario extends React.Component {
const iconStyle = {
color: '#527984',
height: '25px',
width: '25px'
height: '30px',
width: '30px'
}
if (this.state.scenario === undefined) {
return <h1>Loading Scenario...</h1>;
}
let resulttable;
if (this.state.results && this.state.results.length > 0) {
resulttable = <div>
<Table data={this.state.results}>
<TableColumn title='Result No.' dataKey='id' />
<TableColumn title='Description' dataKey='description' />
<TableColumn title='Created at' dataKey='createdAt' />
<TableColumn title='Last update' dataKey='updatedAt' />
<TableColumn
title='Files/Data'
dataKey='resultFileIDs'
linkKey='filebuttons'
width='300'
onDownload={(index) => this.downloadResultData(index)}
/>
<TableColumn
title='Options'
width='300'
editButton
downloadAllButton
deleteButton
onEdit={index => this.setState({ editResultsModal: true, modalResultsData: this.state.results[index], modalResultsIndex: index })}
onDownloadAll={(index) => this.downloadResultData(this.state.results[index])}
onDelete={(index) => this.setState({ deleteResultsModal: true, modalResultsData: this.state.results[index], modalResultsIndex: index })}
/>
</Table>
<EditResultDialog
sessionToken={this.state.sessionToken}
show={this.state.editResultsModal}
files={this.state.files}
result={this.state.modalResultsData}
scenarioID={this.state.scenario.id}
onClose={this.closeEditResultsModal.bind(this)} />
<DeleteDialog title="result" name={this.state.modalResultsData.id} show={this.state.deleteResultsModal} onClose={(e) => this.closeDeleteResultsModal(e)} />
</div>
}
return <div className='section'>
<div className='section-buttons-group-right'>
<OverlayTrigger key={0} placement={'bottom'} overlay={<Tooltip id={`tooltip-${"file"}`}> Add, edit or delete files of scenario </Tooltip>} >
@ -643,7 +783,10 @@ class Scenario extends React.Component {
{/*Component Configurations table*/}
<h2 style={tableHeadingStyle}>Component Configurations</h2>
<h2 style={tableHeadingStyle}>Component Configurations
<Button onClick={() => this.addConfig()} style={altButtonStyle}><Icon icon="plus" style={iconStyle} /></Button>
<Button onClick={() => this.setState({ importConfigModal: true })} style={altButtonStyle}><Icon icon="upload" style={iconStyle}/></Button>
</h2>
<Table data={this.state.configs}>
<TableColumn checkbox onChecked={(index, event) => this.onConfigChecked(index, event)} width='30' />
<TableColumn title='Name' dataKey='name' />
@ -700,11 +843,6 @@ class Scenario extends React.Component {
]} />
</div>
<div style={{ float: 'right' }}>
<Button onClick={() => this.addConfig()} style={buttonStyle}><Icon icon="plus" /> Component Configuration</Button>
<Button onClick={() => this.setState({ importConfigModal: true })} style={buttonStyle}><Icon icon="upload" /> Import</Button>
</div>
<div style={{ clear: 'both' }} />
<EditConfigDialog
@ -737,7 +875,10 @@ class Scenario extends React.Component {
/>
{/*Dashboard table*/}
<h2 style={tableHeadingStyle}>Dashboards</h2>
<h2 style={tableHeadingStyle}>Dashboards
<Button onClick={() => this.setState({ newDashboardModal: true })} style={altButtonStyle}><Icon icon="plus" style={iconStyle} /></Button>
<Button onClick={() => this.setState({ importDashboardModal: true })} style={altButtonStyle}><Icon icon="upload" style={iconStyle} /></Button>
</h2>
<Table data={this.state.dashboards}>
<TableColumn title='Name' dataKey='name' link='/dashboards/' linkKey='id' />
<TableColumn title='Grid' dataKey='grid' />
@ -755,19 +896,18 @@ class Scenario extends React.Component {
/>
</Table>
<div style={{ float: 'right' }}>
<Button onClick={() => this.setState({ newDashboardModal: true })} style={buttonStyle}><Icon icon="plus" /> Dashboard</Button>
<Button onClick={() => this.setState({ importDashboardModal: true })} style={buttonStyle}><Icon icon="upload" /> Import</Button>
</div>
<div style={{ clear: 'both' }} />
<NewDashboardDialog show={this.state.newDashboardModal} onClose={data => this.closeNewDashboardModal(data)} />
<EditDashboardDialog show={this.state.dashboardEditModal} dashboard={this.state.modalDashboardData} onClose={data => this.closeEditDashboardModal(data)} />
<ImportDashboardDialog show={this.state.importDashboardModal} onClose={data => this.closeImportDashboardModal(data)} />
<DeleteDialog title="dashboard" name={this.state.modalDashboardData.name} show={this.state.deleteDashboardModal} onClose={(e) => this.closeDeleteDashboardModal(e)} />
{/*Result table*/}
<h2 style={tableHeadingStyle}>Results
<Button onClick={() => this.setState({ newResultModal: true })} style={altButtonStyle}><Icon icon="plus" style={iconStyle} /></Button>
</h2>
{resulttable}
<NewResultDialog show={this.state.newResultModal} onClose={data => this.closeNewResultModal(data)} />
{/*Scenario Users table*/}
<h2 style={tableHeadingStyle}>Users sharing this scenario</h2>
@ -793,9 +933,9 @@ class Scenario extends React.Component {
<InputGroup.Append>
<Button
type="submit"
style={buttonStyle}
style={altButtonStyle}
onClick={() => this.addUser()}>
Add User
<Icon icon="plus" style={iconStyle} />
</Button>
</InputGroup.Append>
</InputGroup><br /><br />

View file

@ -108,6 +108,12 @@ class ScenariosDataManager extends RestDataManager {
token: token,
param: '?scenarioID=' + scenario.id,
});
AppDispatcher.dispatch({
type: 'results/start-load',
token: token,
param: '?scenarioID=' + scenario.id
})
}
}
}

View file

@ -237,13 +237,22 @@ class Scenarios extends Component {
render() {
const buttonStyle = {
marginLeft: '10px',
backgroundColor: '#527984',
borderColor: '#527984'
backgroundColor: '#ffffff',
borderColor: '#ffffff'
};
const iconStyle = {
color: '#527984',
height: '30px',
width: '30px'
}
return (
<div className='section'>
<h1>Scenarios</h1>
<h1>Scenarios
<Button onClick={() => this.setState({ newModal: true })} style={buttonStyle}><Icon icon="plus" style={iconStyle} /></Button>
<Button onClick={() => this.setState({ importModal: true })} style={buttonStyle}><Icon icon="upload" style={iconStyle} /></Button>
</h1>
<Table data={this.state.scenarios}>
<TableColumn title='Name' dataKey='name' link='/scenarios/' linkKey='id' />
@ -262,13 +271,6 @@ class Scenarios extends Component {
/>
</Table>
<div style={{ float: 'right' }}>
<Button onClick={() => this.setState({ newModal: true })} style={buttonStyle}><Icon icon="plus" /> Scenario</Button>
<Button onClick={() => this.setState({ importModal: true })} style={buttonStyle}><Icon icon="upload" /> Import</Button>
</div>
<div style={{ clear: 'both' }} />
<NewScenarioDialog show={this.state.newModal} onClose={(data) => this.closeNewModal(data)} />
<EditScenarioDialog show={this.state.editModal} onClose={(data) => this.closeEditModal(data)} scenario={this.state.modalScenario} />
<ImportScenarioDialog show={this.state.importModal} onClose={data => this.closeImportModal(data)} nodes={this.state.nodes} />

View file

@ -18,6 +18,7 @@
import ArrayStore from '../common/array-store';
import SignalsDataManager from './signals-data-manager'
import NotificationsDataManager from "../common/data-managers/notifications-data-manager";
import NotificationsFactory from "../common/data-managers/notifications-factory";
class SignalStore extends ArrayStore{
constructor() {
@ -45,14 +46,8 @@ class SignalStore extends ArrayStore{
case 'signals/autoconfig-error':
if (action.error && !action.error.handled && action.error.response) {
const SIGNAL_AUTOCONF_ERROR_NOTIFICATION = {
title: 'Failed to load signal config ',
message: action.error.response.body.message,
level: 'error'
};
NotificationsDataManager.addNotification(SIGNAL_AUTOCONF_ERROR_NOTIFICATION);
NotificationsDataManager.addNotification(
NotificationsFactory.AUTOCONF_ERROR(action.error.response.body.message));
}
return super.reduce(state, action);

View file

@ -19,6 +19,7 @@ import RestDataManager from '../common/data-managers/rest-data-manager';
import RestAPI from "../common/api/rest-api";
import AppDispatcher from "../common/app-dispatcher";
import NotificationsDataManager from "../common/data-managers/notifications-data-manager";
import NotificationsFactory from "../common/data-managers/notifications-factory";
class SignalsDataManager extends RestDataManager{
@ -62,12 +63,8 @@ class SignalsDataManager extends RestDataManager{
saveSignals(nodes, token, configID, socketname){
if(nodes.length === 0){
const SIGNAL_AUTOCONF_ERROR_NOTIFICATION = {
title: 'Failed to load nodes ',
message: 'VILLASnode returned empty response',
level: 'error'
};
NotificationsDataManager.addNotification(SIGNAL_AUTOCONF_ERROR_NOTIFICATION);
NotificationsDataManager.addNotification(
NotificationsFactory.AUTOCONF_ERROR('Failed to load nodes, VILLASnode returned empty response'));
return;
}
@ -79,12 +76,9 @@ class SignalsDataManager extends RestDataManager{
console.warn("Could not parse the following node config because it lacks a name parameter:", nodeConfig);
} else if(nodeConfig.name === socketname){
if(configured){
const SIGNAL_AUTOCONF_WARNING_NOTIFICATION = {
title: 'There might be a problem with the signal auto-config',
message: 'VILLASnode returned multiple node configurations for the websocket ' + socketname + '. This is a problem of the VILLASnode.',
level: 'warning'
};
NotificationsDataManager.addNotification(SIGNAL_AUTOCONF_WARNING_NOTIFICATION);
NotificationsDataManager.addNotification(
NotificationsFactory.AUTOCONF_WARN('VILLASnode returned multiple node configurations for the websocket ' +
socketname + '. This is a problem of the VILLASnode.'));
continue;
}
// signals are not yet configured:
@ -92,12 +86,8 @@ class SignalsDataManager extends RestDataManager{
let index_out = 1
if(!nodeConfig.in.hasOwnProperty("signals")){
const SIGNAL_AUTOCONF_ERROR_NOTIFICATION = {
title: 'Failed to load in signal config ',
message: 'No field for in signals contained in response.',
level: 'error'
};
NotificationsDataManager.addNotification(SIGNAL_AUTOCONF_ERROR_NOTIFICATION);
NotificationsDataManager.addNotification(
NotificationsFactory.AUTOCONF_ERROR('Failed to load in signal config, no field for in signals contained in response.'));
error = true;
} else{
@ -128,12 +118,8 @@ class SignalsDataManager extends RestDataManager{
}
if(!nodeConfig.out.hasOwnProperty("signals")){
const SIGNAL_AUTOCONF_ERROR_NOTIFICATION = {
title: 'Failed to load out signal config ',
message: 'No field for out signals contained in response.',
level: 'error'
};
NotificationsDataManager.addNotification(SIGNAL_AUTOCONF_ERROR_NOTIFICATION);
NotificationsDataManager.addNotification(
NotificationsFactory.AUTOCONF_ERROR('Failed to load out signal config, no field for out signals contained in response.'));
error=true;
}else {
@ -173,12 +159,7 @@ class SignalsDataManager extends RestDataManager{
}
if(!error) {
const SIGNAL_AUTOCONF_INFO_NOTIFICATION = {
title: 'Signal configuration loaded successfully.',
message: '',
level: 'info'
};
NotificationsDataManager.addNotification(SIGNAL_AUTOCONF_INFO_NOTIFICATION);
NotificationsDataManager.addNotification(NotificationsFactory.AUTOCONF_INFO());
}
}

View file

@ -323,6 +323,7 @@ body {
display: -webkit-flex;
display: flex;
flex-direction: column;
background-color: #FFFFFF;
}
.box-header {

View file

@ -394,4 +394,21 @@ div[class*="-widget"] label {
height: 100%;
border: 2px solid lightgray;
}
/* End box widget */
/* End box widget */
/* Begin time offset widget */
.time-offset {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: space-around;
flex-direction: column;
}
.time-offset span {
text-align: center;
font-size: 1em;
font-weight: 600;
}
/* End time offset widget */

View file

@ -25,6 +25,7 @@ import UsersStore from './users-store';
import Icon from '../common/icon';
import EditOwnUserDialog from './edit-own-user'
import NotificationsDataManager from "../common/data-managers/notifications-data-manager"
import NotificationsFactory from "../common/data-managers/notifications-factory";
class User extends Component {
static getStores() {
@ -81,12 +82,7 @@ class User extends Component {
updatedData.password = data.password;
updatedData.oldPassword = data.oldPassword;
} else if (data.password !== '' && data.password !== data.confirmpassword) {
const USER_UPDATE_ERROR_NOTIFICATION = {
title: 'Update Error ',
message: 'New password not correctly confirmed',
level: 'error'
};
NotificationsDataManager.addNotification(USER_UPDATE_ERROR_NOTIFICATION);
NotificationsDataManager.addNotification(NotificationsFactory.UPDATE_ERROR('New password not correctly confirmed'));
return
}
@ -102,12 +98,7 @@ class User extends Component {
token: this.state.token
});
} else {
const USER_UPDATE_WARNING_NOTIFICATION = {
title: 'Update Warning ',
message: 'No update requested, no input data',
level: 'warning'
};
NotificationsDataManager.addNotification(USER_UPDATE_WARNING_NOTIFICATION);
NotificationsDataManager.addNotification(NotificationsFactory.UPDATE_WARNING('No update requested, no input data'));
}
}
}

View file

@ -18,6 +18,7 @@
import ArrayStore from '../common/array-store';
import UsersDataManager from './users-data-manager';
import NotificationsDataManager from '../common/data-managers/notifications-data-manager';
import NotificationsFactory from "../common/data-managers/notifications-factory";
class UsersStore extends ArrayStore {
constructor() {
@ -30,12 +31,8 @@ class UsersStore extends ArrayStore {
case this.type + '/add-error':
if (action.error && !action.error.handled && action.error.response) {
// If it was an error and hasn't been handled, user could not be added
const USER_ADD_ERROR_NOTIFICATION = {
title: 'Failed to add new user',
message: action.error.response.body.message,
level: 'error'
};
NotificationsDataManager.addNotification(USER_ADD_ERROR_NOTIFICATION);
NotificationsDataManager.addNotification(
NotificationsFactory.ADD_ERROR('Failed to add new user: ' + action.error.response.body.message));
}
return super.reduce(state, action);
@ -43,12 +40,8 @@ class UsersStore extends ArrayStore {
case this.type + '/edit-error':
if (action.error && !action.error.handled && action.error.response) {
// If it was an error and hasn't been handled, user couldn't not be updated
const USER_EDIT_ERROR_NOTIFICATION = {
title: 'Failed to edit user',
message: action.error.response.body.message,
level: 'error'
};
NotificationsDataManager.addNotification(USER_EDIT_ERROR_NOTIFICATION);
NotificationsDataManager.addNotification(
NotificationsFactory.UPDATE_ERROR('Failed to edit user: ' + action.error.response.body.message));
}
return super.reduce(state, action);

View file

@ -30,6 +30,7 @@ import EditUserDialog from './edit-user';
import DeleteDialog from '../common/dialogs/delete-dialog';
import NotificationsDataManager from "../common/data-managers/notifications-data-manager";
import NotificationsFactory from "../common/data-managers/notifications-factory";
class Users extends Component {
static getStores() {
@ -97,12 +98,8 @@ class Users extends Component {
token: this.state.token
});
} else{
const USER_UPDATE_ERROR_NOTIFICATION = {
title: 'Update Error ',
message: 'New password not correctly confirmed',
level: 'error'
};
NotificationsDataManager.addNotification(USER_UPDATE_ERROR_NOTIFICATION)
NotificationsDataManager.addNotification(NotificationsFactory.UPDATE_ERROR("New password not correctly confirmed"))
}
}
}
@ -133,9 +130,23 @@ class Users extends Component {
render() {
const buttonStyle = {
marginLeft: '10px',
backgroundColor: '#ffffff',
borderColor: '#ffffff'
}
const iconStyle = {
color: '#527984',
height: '30px',
width: '30px'
}
return (
<div>
<h1>Users</h1>
<h1>Users
<Button style={buttonStyle} onClick={() => this.setState({ newModal: true })}><Icon icon='plus' style={iconStyle} /></Button>
</h1>
<Table data={this.state.users}>
<TableColumn title='Username' width='150' dataKey='username' />
@ -146,8 +157,6 @@ class Users extends Component {
<TableColumn width='200' editButton deleteButton onEdit={index => this.setState({ editModal: true, modalData: this.state.users[index] })} onDelete={index => this.setState({ deleteModal: true, modalData: this.state.users[index] })} />
</Table>
<Button style={{backgroundColor: '#527984', borderColor: '#527984'}} onClick={() => this.setState({ newModal: true })}><Icon icon='plus' /> User</Button>
<NewUserDialog show={this.state.newModal} onClose={(data) => this.closeNewModal(data)} />
<EditUserDialog show={this.state.editModal} onClose={(data) => this.closeEditModal(data)} user={this.state.modalData} />

View file

@ -55,6 +55,9 @@ class ColorPicker extends React.Component {
if(this.props.controlId === 'strokeStyle'){
temp.customProperties.zones[this.props.zoneIndex]['strokeStyle'] = color.hex;
}
else if(this.props.controlId === 'lineColor'){
temp.customProperties.lineColors[this.props.lineIndex] = color.hex;
}
else{
let parts = this.props.controlId.split('.');
let isCustomProperty = true;
@ -85,7 +88,7 @@ class ColorPicker extends React.Component {
};
render() {
let disableOpacity = false;
let hexColor;
let opacity = 1;
let parts = this.props.controlId.split('.');
@ -94,12 +97,14 @@ class ColorPicker extends React.Component {
isCustomProperty = false;
}
if((this.state.widget.type === "Box" && parts[1] === "border_color") || this.props.controlId === 'strokeStyle'){
disableOpacity = true;
}
if(this.props.controlId === 'strokeStyle'){
if(typeof this.state.widget.customProperties.zones[this.props.zoneIndex] !== 'undefined'){
hexColor = this.state.widget.customProperties.zones[this.props.zoneIndex]['strokeStyle'];
hexColor = this.state.widget.customProperties.zones[this.props.zoneIndex]['strokeStyle'];
}
}
else if(this.props.controlId === 'lineColor'){
if(typeof this.state.widget.customProperties.lineColors[this.props.lineIndex] !== 'undefined'){
hexColor = this.state.widget.customProperties.lineColors[this.props.lineIndex];
}
}
else{
@ -117,7 +122,7 @@ class ColorPicker extends React.Component {
<form>
<SketchPicker
color={rgbColor}
disableAlpha={disableOpacity}
disableAlpha={this.props.disableOpacity}
onChangeComplete={ this.handleChangeComplete }
width={"300"}
/>

View file

@ -82,7 +82,7 @@ class EditWidgetColorControl extends Component {
}
let tooltipText = "Change color and opacity";
if(this.state.widget.type === "Box" && parts[1] === "border_color"){
if(this.props.disableOpacity){
tooltipText = "Change border color";
}
@ -99,7 +99,7 @@ class EditWidgetColorControl extends Component {
</OverlayTrigger>
</div>
<ColorPicker show={this.state.showColorPicker} onClose={(data) => this.closeEditModal(data)} widget={this.state.widget} controlId={this.props.controlId} />
<ColorPicker show={this.state.showColorPicker} onClose={(data) => this.closeEditModal(data)} widget={this.state.widget} controlId={this.props.controlId} disableOpacity={this.props.disableOpacity}/>
</FormGroup>
)

View file

@ -217,8 +217,6 @@ class EditWidgetColorZonesControl extends React.Component {
height: '40px'
}
return (<Button
style={style} key={idx} onClick={i => this.editColorZone(idx)} disabled={!this.props.widget.customProperties.colorZones}><Icon icon="pen" /></Button>
)

View file

@ -31,9 +31,11 @@ import EditWidgetCheckboxControl from './edit-widget-checkbox-control';
import EditWidgetColorZonesControl from './edit-widget-color-zones-control';
import EditWidgetMinMaxControl from './edit-widget-min-max-control';
import EditWidgetParametersControl from './edit-widget-parameters-control';
import EditWidgetICControl from './edit-widget-ic-control';
import EditWidgetPlotColorsControl from './edit-widget-plot-colors-control';
//import EditWidgetHTMLContent from './edit-widget-html-content';
export default function CreateControls(widgetType = null, widget = null, sessionToken = null, files = null, signals, handleChange) {
export default function CreateControls(widgetType = null, widget = null, sessionToken = null, files = null,ics = null, signals, handleChange) {
// Use a list to concatenate the controls according to the widget type
var DialogControls = [];
@ -62,16 +64,17 @@ export default function CreateControls(widgetType = null, widget = null, session
DialogControls.push(
<EditWidgetSignalControl key={0} widget={widget} controlId={'signalIDs'} signals={signals} handleChange={(e) => handleChange(e)} direction={'out'}/>,
<EditWidgetTextControl key={1} widget={widget} controlId={'customProperties.threshold'} label={'Threshold'} placeholder={'0.5'} handleChange={e => handleChange(e)} />,
<EditWidgetColorControl key={2} widget={widget} controlId={'customProperties.on_color'} label={'Color On'} handleChange={(e) => handleChange(e)} />,
<EditWidgetColorControl key={3} widget={widget} controlId={'customProperties.off_color'} label={'Color Off'} handleChange={(e) => handleChange(e)} />,
<EditWidgetColorControl key={2} widget={widget} controlId={'customProperties.on_color'} label={'Color On'} handleChange={(e) => handleChange(e)} disableOpacity={false}/>,
<EditWidgetColorControl key={3} widget={widget} controlId={'customProperties.off_color'} label={'Color Off'} handleChange={(e) => handleChange(e)} disableOpacity={false}/>,
);
break;
case 'Plot':
DialogControls.push(
<EditWidgetTimeControl key={0} widget={widget} controlId={'customProperties.time'} handleChange={(e) => handleChange(e)} />,
<EditWidgetSignalsControl key={1} widget={widget} controlId={'signalIDs'} signals={signals} handleChange={(e) => handleChange(e)} direction={'out'}/>,
<EditWidgetTextControl key={2} widget={widget} controlId={'customProperties.ylabel'} label={'Y-Axis name'} placeholder={'Enter a name for the y-axis'} handleChange={(e) => handleChange(e)} />,
<EditWidgetMinMaxControl key={3} widget={widget} controlId="customProperties.y" handleChange={e => handleChange(e)} />
<EditWidgetPlotColorsControl key={2} widget={widget} controlId={'customProperties.lineColors'} signals={signals} handleChange={(e) => handleChange(e)} />,
<EditWidgetTextControl key={3} widget={widget} controlId={'customProperties.ylabel'} label={'Y-Axis name'} placeholder={'Enter a name for the y-axis'} handleChange={(e) => handleChange(e)} />,
<EditWidgetMinMaxControl key={4} widget={widget} controlId="customProperties.y" handleChange={e => handleChange(e)} />
);
break;
case 'Table':
@ -115,20 +118,23 @@ export default function CreateControls(widgetType = null, widget = null, session
<EditWidgetSignalControl key={1} widget={widget} controlId={'signalIDs'} input signals={signals} handleChange={(e) => handleChange(e)} direction={'in'}/>,
<EditWidgetCheckboxControl key={2} widget={widget} controlId={'customProperties.toggle'} input text="Toggle" handleChange={e => handleChange(e)} />,
<EditWidgetNumberControl key={3} widget={widget} controlId={'customProperties.on_value'} label={'On Value'} defaultValue={1} handleChange={(e) => handleChange(e)} />,
<EditWidgetNumberControl key={4} widget={widget} controlId={'customProperties.off_value'} label={'Off Value'} defaultValue={0} handleChange={(e) => handleChange(e)} />
<EditWidgetNumberControl key={4} widget={widget} controlId={'customProperties.off_value'} label={'Off Value'} defaultValue={0} handleChange={(e) => handleChange(e)} />,
<EditWidgetColorControl key={5} widget={widget} controlId={'customProperties.background_color'} label={'Background Color and Opacity'} handleChange={(e) => handleChange(e)} disableOpacity={false}/>,
<EditWidgetColorControl key={6} widget={widget} controlId={'customProperties.border_color'} label={'Border Color'} handleChange={(e) => handleChange(e)} disableOpacity={true}/>,
<EditWidgetColorControl key={7} widget={widget} controlId={'customProperties.font_color'} label={'Font Color'} handleChange={(e) => handleChange(e)} disableOpacity={true}/>,
);
break;
case 'Box':
DialogControls.push(
<EditWidgetColorControl key={0} widget={widget} controlId={'customProperties.border_color'} label={'Border color'} handleChange={(e) => handleChange(e)} />,
<EditWidgetColorControl key={1} widget={widget} controlId={'customProperties.background_color'} label={'Background color'} handleChange={e => handleChange(e)} />,
<EditWidgetColorControl key={0} widget={widget} controlId={'customProperties.background_color'} label={'Background Color and Opacity'} handleChange={e => handleChange(e)} disableOpacity={false}/>,
<EditWidgetColorControl key={1} widget={widget} controlId={'customProperties.border_color'} label={'Border Color'} handleChange={(e) => handleChange(e)} disableOpacity={true}/>,
);
break;
case 'Label':
DialogControls.push(
<EditWidgetTextControl key={0} widget={widget} controlId={'name'} label={'Text'} placeholder={'Enter text'} handleChange={e => handleChange(e)} />,
<EditWidgetTextSizeControl key={1} widget={widget} handleChange={e => handleChange(e)} />,
<EditWidgetColorControl key={2} widget={widget} controlId={'customProperties.fontColor'} label={'Text color'} handleChange={e => handleChange(e)} />
<EditWidgetColorControl key={2} widget={widget} controlId={'customProperties.fontColor'} label={'Text color'} handleChange={e => handleChange(e)} disableOpacity={false}/>
);
break;
/*case 'HTML':
@ -152,12 +158,23 @@ export default function CreateControls(widgetType = null, widget = null, session
break;
case 'Line':
DialogControls.push(
<EditWidgetColorControl key={0} widget={widget} controlId={'customProperties.border_color'} label={'Line color'} handleChange={(e) => handleChange(e)} />,
<EditWidgetColorControl key={0} widget={widget} controlId={'customProperties.border_color'} label={'Line color'} handleChange={(e) => handleChange(e)} disableOpacity={false}/>,
<EditWidgetNumberControl key={1} widget={widget} controlId={'customProperties.rotation'} label={'Rotation (degrees)'} defaultValue={0} handleChange={(e) => handleChange(e)} />,
<EditWidgetNumberControl key={2} widget={widget} controlId={'customProperties.border_width'} label={'Line width'} defaultValue={0} handleChange={(e) => handleChange(e)} />
);
break;
case 'TimeOffset':
DialogControls.push(
<EditWidgetICControl key={0} widget={widget} controlId={'customProperties.icID'} input ics={ics} handleChange={(e) => handleChange(e)}/>,
<EditWidgetNumberControl key={1} widget={widget} controlId={'customProperties.threshold_yellow'} label={'Threshold yellow'} defaultValue={0} handleChange={(e) => handleChange(e)} />,
<EditWidgetNumberControl key={2} widget={widget} controlId={'customProperties.threshold_red'} label={'Threshold red'} defaultValue={0} handleChange={(e) => handleChange(e)} />,
<EditWidgetCheckboxControl key={3} widget={widget} controlId={'customProperties.horizontal'} input text="Horizontal" handleChange={e => handleChange(e)} />,
<EditWidgetCheckboxControl key={4} widget={widget} controlId={'customProperties.showName'} input text="showName" handleChange={e => handleChange(e)} />,
<EditWidgetCheckboxControl key={5} widget={widget} controlId={'customProperties.showOffset'} input text="showOffset" handleChange={e => handleChange(e)} />,
);
break;
default:
console.log('Non-valid widget type: ' + widgetType);
}

View file

@ -0,0 +1,74 @@
/**
* 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 <http://www.gnu.org/licenses/>.
******************************************************************************/
import React from 'react';
import {FormGroup, FormControl, FormLabel} from 'react-bootstrap';
class EditWidgetICControl extends React.Component {
constructor(props) {
super(props);
this.state = {
ics: [],
};
}
static getDerivedStateFromProps(props, state){
return {
ics: props.ics
};
}
handleICChange(e) {
let value = e.target.value === "Select IC" ? (-1) : (e.target.value);
this.props.handleChange({ target: { id: this.props.controlId, value: value } });
}
render() {
let parts = this.props.controlId.split('.');
let isCustomProperty = true;
if(parts.length === 1){
isCustomProperty = false;
}
let icOptions = [];
if (this.state.ics !== null && this.state.ics.length > 0){
icOptions.push(
<option key = {0} default>Select IC</option>
)
icOptions.push(this.state.ics.map((ic, index) => (
<option key={index+1} value={ic.id}>{ic.name}</option>
)))
} else {
icOptions = <option style={{ display: 'none' }}>No ics found</option>
}
return <div>
<FormGroup controlId="ic">
<FormLabel>IC</FormLabel>
<FormControl
as="select"
value={isCustomProperty ? this.props.widget[parts[0]][parts[1]] : this.props.widget[this.props.controlId]}
onChange={(e) => this.handleICChange(e)}>{icOptions} </FormControl>
</FormGroup>
</div>;
}
}
export default EditWidgetICControl;

View file

@ -0,0 +1,115 @@
/**
* 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 <http://www.gnu.org/licenses/>.
******************************************************************************/
import React, { Component } from 'react';
import { FormGroup, OverlayTrigger, Tooltip , FormLabel, Button } from 'react-bootstrap';
import ColorPicker from './color-picker'
import Icon from "../../common/icon";
import {scaleOrdinal} from "d3-scale";
import {schemeCategory10} from "d3-scale-chromatic";
// schemeCategory20 no longer available in d3
class EditWidgetPlotColorsControl extends Component {
constructor(props) {
super(props);
this.state = {
widget: {},
showColorPicker: false,
originalColor: null,
selectedIndex: null
};
}
static getDerivedStateFromProps(props, state){
let widget = props.widget;
if(widget.customProperties.lineColors === undefined || widget.customProperties.lineColors === null){
// for backwards compatibility with old plots
widget.customProperties.lineColors = []
const newLineColor = scaleOrdinal(schemeCategory10);
for (let signalID of widget.signalIDs){
widget.customProperties.lineColors.push(newLineColor(signalID))
}
}
return {
widget: widget
};
}
//same here
closeEditModal = (data) => {
this.setState({showColorPicker: false})
if(typeof data === 'undefined'){
let temp = this.state.widget;
temp.customProperties.lineColors[this.state.selectedIndex] = this.state.originalColor;
this.setState({ widget: temp });
}
}
editLineColor = (index) => {
if(this.state.selectedIndex !== index){
let color = this.state.widget.customProperties.lineColors[index];
this.setState({selectedIndex: index, showColorPicker: true, originalColor: color});
}
else{
this.setState({selectedIndex: null});
}
}
render() {
return (
<FormGroup>
<FormLabel>Line Colors</FormLabel>
<div>
{
this.state.widget.signalIDs.map((signalID, idx) => {
let color = this.state.widget.customProperties.lineColors[signalID];
let width = 260 / this.state.widget.signalIDs.length;
let style = {
backgroundColor: color,
width: width,
height: '40px'
}
let signal = this.props.signals.find(signal => signal.id === signalID);
return (<OverlayTrigger key={idx} placement={'bottom'} overlay={<Tooltip id={'tooltip-${"signal name"}'}>{signal.name}</Tooltip>}>
<Button
style={style} key={idx} onClick={i => this.editLineColor(signalID)} ><Icon icon="pen" /></Button>
</OverlayTrigger>
)
})
}
</div>
<ColorPicker show={this.state.showColorPicker} onClose={(data) => this.closeEditModal(data)} widget={this.state.widget} lineIndex={this.state.selectedIndex} controlId={'lineColor'} disableOpacity={true}/>
</FormGroup>
)
}
}
export default EditWidgetPlotColorsControl;

View file

@ -141,6 +141,11 @@ class EditWidgetDialog extends React.Component {
customProperty ? changeObject[parts[0]][parts[1]]= 'default' : changeObject[e.target.id] = 'default';
}
changeObject = this.setMaxWidth(changeObject);
} else if (parts[1] === 'horizontal'){
customProperty ? changeObject[parts[0]][parts[1]] = e.target.value : changeObject[e.target.id] = e.target.value ;
let tempWidth = changeObject.width;
changeObject.width = changeObject.height;
changeObject.height = tempWidth;
} else {
customProperty ? changeObject[parts[0]][parts[1]] = e.target.value : changeObject[e.target.id] = e.target.value ;
}
@ -163,6 +168,7 @@ class EditWidgetDialog extends React.Component {
this.state.temporal,
this.props.sessionToken,
this.props.files,
this.props.ics,
this.props.signals,
(e) => this.handleChange(e));
}

View file

@ -0,0 +1,59 @@
/**
* 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 <http://www.gnu.org/licenses/>.
******************************************************************************/
import ArrayStore from '../common/array-store';
class WebsocketStore extends ArrayStore {
updateSocketStatus(state, socket) {
let checkInclusion = false;
state.forEach((element) => {
if (element.url === socket.url) {
element.connected = socket.connected;
checkInclusion = true;
}
})
if (!checkInclusion) {
state.push(socket);
}
this.__emitChange();
return state;
}
reduce(state, action) {
let tempSocket = {};
switch (action.type) {
case 'websocket/connected':
tempSocket.url = action.data;
tempSocket.connected = true;
return this.updateSocketStatus(state, tempSocket);
case 'websocket/connection-error':
tempSocket.url = action.data;
tempSocket.connected = false;
return this.updateSocketStatus(state, tempSocket);
default:
return super.reduce(state, action);
}
}
}
export default new WebsocketStore();

View file

@ -89,6 +89,7 @@ class WidgetFactory {
widget.customProperties.yMin = 0;
widget.customProperties.yMax = 10;
widget.customProperties.yUseMinMax = false;
widget.customProperties.lineColors = [];
break;
case 'Table':
widget.minWidth = 200;
@ -122,8 +123,10 @@ class WidgetFactory {
widget.minHeight = 50;
widget.width = 100;
widget.height = 100;
widget.customProperties.background_color = '#4287f5';
widget.customProperties.background_color = '#527984';
widget.customProperties.font_color = '#4287f5';
widget.customProperties.border_color = '#4287f5';
widget.customProperties.background_color_opacity = 1;
widget.customProperties.on_value = 1;
widget.customProperties.off_value = 0;
widget.customProperties.toggle = false;
@ -165,6 +168,7 @@ class WidgetFactory {
widget.customProperties.valueMin = 0;
widget.customProperties.valueMax = 1;
widget.customProperties.valueUseMinMax = false;
widget.customProperties.lockAspect = true;
break;
case 'Box':
widget.minWidth = 50;
@ -195,6 +199,20 @@ class WidgetFactory {
widget.customProperties.lockAspect = true;
break;
case 'TimeOffset':
widget.minWidth = 20;
widget.minHeight = 20;
widget.width = 100;
widget.height = 40;
widget.customProperties.threshold_yellow = 1;
widget.customProperties.threshold_red = 2;
widget.customProperties.icID = -1;
widget.customProperties.horizontal = true;
widget.customProperties.showOffset = true;
widget.customProperties.lockAspect = true;
widget.customProperties.showName = true;
break;
default:
widget.width = 100;
widget.height = 100;

View file

@ -20,13 +20,17 @@ import { scaleOrdinal} from 'd3-scale';
import {schemeCategory10} from 'd3-scale-chromatic'
function Legend(props){
const signal = props.sig;
const hasScalingFactor = (signal.scalingFactor !== 1);
const newLineColor = scaleOrdinal(schemeCategory10);
let color = typeof props.lineColor === "undefined" ? newLineColor(signal.id) : props.lineColor;
if(hasScalingFactor){
return (
<li key={signal.id} className="signal-legend" style={{ color: props.colorScale(signal.id) }}>
<li key={signal.id} className="signal-legend" style={{ color: color }}>
<span className="signal-legend-name">{signal.name}</span>
<span style={{ marginLeft: '0.3em' }} className="signal-unit">{signal.unit}</span>
<span style={{ marginLeft: '0.3em' }} className="signal-scale">{signal.scalingFactor}</span>
@ -34,7 +38,7 @@ function Legend(props){
)
} else {
return (
<li key={signal.id} className="signal-legend" style={{ color: props.colorScale(signal.id) }}>
<li key={signal.id} className="signal-legend" style={{ color: color }}>
<span className="signal-legend-name">{signal.name}</span>
<span style={{ marginLeft: '0.3em' }} className="signal-unit">{signal.unit}</span>
</li>
@ -45,14 +49,18 @@ function Legend(props){
class PlotLegend extends React.Component {
render() {
const colorScale = scaleOrdinal(schemeCategory10);
return <div className="plot-legend">
<ul>
{
{ this.props.lineColors !== undefined && this.props.lineColors != null ? (
this.props.signals.map( signal =>
<Legend key={signal.id} sig={signal} colorScale={colorScale}/>
)}
<Legend key={signal.id} sig={signal} lineColor={this.props.lineColors[signal.id]}/>
)) : (
this.props.signals.map( signal =>
<Legend key={signal.id} sig={signal} lineColor={"undefined"}/>
))
}
</ul>
</div>;
}

View file

@ -203,14 +203,26 @@ class Plot extends React.Component {
// generate paths from data
const sparkLine = line().x(p => xScale(p.x)).y(p => yScale(p.y));
const lineColor = scaleOrdinal(schemeCategory10);
const newLineColor = scaleOrdinal(schemeCategory10);
const lines = this.state.data.map((values, index) => <path d={sparkLine(values)} key={index} style={{ fill: 'none', stroke: lineColor(index) }} />);
const lines = this.state.data.map((values, index) => {
let signalID = this.props.signalIDs[index];
if(this.props.lineColors === undefined || this.props.lineColors === null){
this.props.lineColors = [] // for backwards compatibility
}
if (typeof this.props.lineColors[signalID] === "undefined") {
this.props.lineColors[signalID] = newLineColor(signalID);
}
return <path d={sparkLine(values)} key={index} style={{ fill: 'none', stroke: this.props.lineColors[signalID] }} />
});
this.setState({ lines, xAxis, yAxis });
}
render() {
const yLabelPos = {
x: 12,
y: this.props.height / 2

View file

@ -178,6 +178,7 @@ class WidgetToolbox extends React.Component {
<ToolboxItem name='Lamp' type='widget' icon = 'plus' />
<ToolboxItem name='Gauge' type='widget' icon = 'plus'/>
<ToolboxItem name='Topology' type='widget' disabled={thereIsTopologyWidget} title={topologyItemMsg} icon = 'plus'/>
<ToolboxItem name='TimeOffset' type='widget' icon = 'plus' />
<OverlayTrigger key={0} placement={'bottom'} overlay={<Tooltip id={`tooltip-${"?"}`}> Drag and drop widgets onto the dashboard </Tooltip>} >
<Button disabled={true} variant="light" size="sm" key={0} >
<Icon icon="question" />

View file

@ -23,6 +23,8 @@ import ICDataStore from '../ic/ic-data-store';
import ConfigsStore from '../componentconfig/config-store';
import FileStore from '../file/file-store';
import SignalStore from '../signal/signal-store'
import WebsocketStore from './websocket-store'
import ICStore from '../ic/ic-store';
import WidgetCustomAction from './widgets/custom-action';
import WidgetAction from './widgets/action';
@ -39,6 +41,7 @@ import WidgetGauge from './widgets/gauge';
import WidgetBox from './widgets/box';
import WidgetTopology from './widgets/topology';
import WidgetLine from './widgets/line';
import WidgetTimeOffset from './widgets/time-offset'
//import WidgetHTML from './widgets/html';
@ -46,11 +49,14 @@ import '../styles/widgets.css';
class Widget extends React.Component {
static getStores() {
return [ ICDataStore, ConfigsStore, FileStore, SignalStore];
return [ ICDataStore, ConfigsStore, FileStore, SignalStore, WebsocketStore, ICStore];
}
static calculateState(prevState, props) {
let websockets = WebsocketStore.getState();
let ics = ICStore.getState();
let icData = {};
if (props.paused) {
@ -78,6 +84,8 @@ class Widget extends React.Component {
}
return {
ics: ics,
websockets: websockets,
icData: icData,
signals: signals,
icIDs: icIDs,
@ -223,6 +231,13 @@ class Widget extends React.Component {
widget={widget}
editing={this.props.editing}
/>
} else if (widget.type === 'TimeOffset') {
return <WidgetTimeOffset
widget={widget}
data={this.state.icData}
websockets={this.state.websockets}
ics={this.state.ics}
/>
}
return null;

View file

@ -59,10 +59,19 @@ class WidgetButton extends Component {
}
render() {
const buttonStyle = {
backgroundColor: this.props.widget.customProperties.background_color,
borderColor: this.props.widget.customProperties.border_color,
color: this.props.widget.customProperties.font_color,
opacity: this.props.widget.customProperties.background_color_opacity
};
return (
<div className="button-widget full">
<Button
className="full"
style={buttonStyle}
active={ this.state.pressed }
disabled={ this.props.editing }
onMouseDown={ (e) => this.onPress(e) }

View file

@ -103,9 +103,11 @@ class WidgetPlot extends React.Component {
yUseMinMax={this.props.widget.customProperties.yUseMinMax}
paused={this.props.paused}
yLabel={this.props.widget.customProperties.ylabel}
lineColors={this.props.widget.customProperties.lineColors}
signalIDs={this.props.widget.signalIDs}
/>
</div>
<PlotLegend signals={this.state.signals} />
<PlotLegend signals={this.state.signals} lineColors={this.props.widget.customProperties.lineColors} />
</div>;
}
}

View file

@ -0,0 +1,101 @@
/**
* 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 <http://www.gnu.org/licenses/>.
******************************************************************************/
import React, { Component } from 'react';
import TrafficLight from 'react-trafficlight';
import {OverlayTrigger, Tooltip } from 'react-bootstrap';
class WidgetTimeOffset extends Component {
constructor(props) {
super(props);
this.state = {
timeOffset: '',
icID: '',
icName: '',
websocketOpen: false
};
}
static getDerivedStateFromProps(props, state){
if(typeof props.widget.customProperties.icID !== "undefined" && state.icID !== props.widget.customProperties.icID){
return {icID: props.widget.customProperties.icID};
}
if (props.data == null
|| props.data[state.icID] == null
|| props.data[state.icID].output == null
|| props.data[state.icID].output.timestamp == null) {
return {timeOffset: -1};
}
let ic = props.ics.find(ic => ic.id === parseInt(state.icID, 10));
let websocket = props.websockets.find(ws => ws.url === ic.websocketurl);
let serverTime = props.data[state.icID].output.timestamp;
let localTime = Date.now();
let absoluteOffset = Math.abs(serverTime - localTime);
if(typeof websocket === 'undefined'){
return {timeOffset: Number.parseFloat(absoluteOffset/1000).toPrecision(5)}
}
return {timeOffset: Number.parseFloat(absoluteOffset/1000).toPrecision(5), websocketOpen: websocket.connected, icName: ic.name};
}
render() {
let icSelected = " ";
if(!this.state.websocketOpen){
icSelected = "no connection";
} else if (this.props.widget.customProperties.showOffset){
icSelected = this.state.timeOffset + 's';
}
return (
<div className="time-offset">
{this.props.widget.customProperties.icID !== -1 ?
(<span></span>) : (<span>no IC</span>)
}
{this.props.widget.customProperties.icID !== -1 && this.props.widget.customProperties.showName ?
(<span>{this.state.icName}</span>) : (<span></span>)
}
<OverlayTrigger key={0} placement={'left'} overlay={<Tooltip id={`tooltip-${"traffic-light"}`}>
{this.props.widget.customProperties.icID !== -1 ?
(<span>{this.state.icName}<br></br>Offset: {this.state.timeOffset + "s"}</span>)
:
(<span>Please select Infrastructure Component</span>)}
</Tooltip>}>
<TrafficLight Horizontal={this.props.widget.customProperties.horizontal} width={this.props.widget.width} height={this.props.widget.height}
RedOn={(this.props.widget.customProperties.threshold_red <= this.state.timeOffset) || !this.state.websocketOpen}
YellowOn={(this.props.widget.customProperties.threshold_yellow <= this.state.timeOffset) && (this.state.timeOffset < this.props.widget.customProperties.threshold_red) && this.state.websocketOpen}
GreenOn={(this.state.timeOffset < this.props.widget.customProperties.threshold_yellow) && this.state.websocketOpen}
/>
</OverlayTrigger>
{this.props.widget.customProperties.icID !== -1 ?
(
<span>{icSelected}</span>)
:
(<span>selected</span>)
}
</div>
);
}
}
export default WidgetTimeOffset;