diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d619f5c2..52d37378 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -30,6 +30,18 @@ build.opalrt: DOCKER_TAG: ${CI_COMMIT_BRANCH}-${BRANDING} extends: build +build.kopernikus: + variables: + BRANDING: kopernikus + DOCKER_TAG: ${CI_COMMIT_BRANCH}-${BRANDING} + extends: build + +build.enershare: + variables: + BRANDING: enershare + DOCKER_TAG: ${CI_COMMIT_BRANCH}-${BRANDING} + extends: build + deploy: stage: deploy variables: @@ -56,6 +68,18 @@ deploy.opalrt: BRANDING: opalrt DOCKER_TAG: ${CI_COMMIT_BRANCH}-${BRANDING} +deploy.kopernikus: + extends: deploy + variables: + BRANDING: kopernikus + DOCKER_TAG: ${CI_COMMIT_BRANCH}-${BRANDING} + +deploy.enershare: + variables: + BRANDING: enershare + DOCKER_TAG: ${CI_COMMIT_BRANCH}-${BRANDING} + extends: deploy + deploy.latest: extends: deploy variables: diff --git a/package.json b/package.json index 7429f334..1e8cd18a 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "test": "react-scripts test --env=jsdom", "eject": "react-scripts eject" }, - "proxy": "https://villas.k8s.eonerc.rwth-aachen.de", + "proxy": "https://villas.k8s.eonerc.rwth-aachen.de/", "browserslist": { "production": [ ">0.2%", diff --git a/src/app.js b/src/app.js index 79405053..ab9c4a71 100644 --- a/src/app.js +++ b/src/app.js @@ -15,102 +15,139 @@ * along with VILLASweb. If not, see . ******************************************************************************/ -import React from 'react'; -import { DndProvider } from 'react-dnd'; -import { HTML5Backend }from 'react-dnd-html5-backend'; -import NotificationSystem from 'react-notification-system'; -import { Redirect, Route } from 'react-router-dom'; -import jwt from 'jsonwebtoken'; -import NotificationsDataManager from './common/data-managers/notifications-data-manager'; -import Home from './common/home'; -import Header from './common/header'; -import Menu from './common/menu'; -import InfrastructureComponent from './pages/infrastructure/ic'; -import Scenarios from './pages/scenarios/scenarios'; -import APIBrowser from './common/api-browser'; -import Scenario from './pages/scenarios/scenario'; -import Users from './pages/users/users'; -import Dashboard from './pages/dashboards/dashboard'; -import Account from './pages/account/account'; -import './styles/app.css'; -import './styles/login.css'; -import branding from './branding/branding'; -import Logout from './pages/login/logout'; -import Infrastructure from './pages/infrastructure/infrastructure'; -import { useSelector } from 'react-redux'; +import React from "react"; +import { useRef, useEffect } from "react"; +import { DndProvider } from "react-dnd"; +import { HTML5Backend } from "react-dnd-html5-backend"; +import NotificationSystem from "react-notification-system"; +import { Redirect, Route } from "react-router-dom"; +import jwt from "jsonwebtoken"; +import NotificationsDataManager from "./common/data-managers/notifications-data-manager"; +import Home from "./common/home"; +import Header from "./common/header"; +import Menu from "./common/menu"; +import InfrastructureComponent from "./pages/infrastructure/ic"; +import Scenarios from "./pages/scenarios/scenarios"; +import APIBrowser from "./common/api-browser"; +import Scenario from "./pages/scenarios/scenario"; +import Users from "./pages/users/users"; +import Dashboard from "./pages/dashboards/dashboard.jsx"; +import Account from "./pages/account/account"; +import "./styles/app.css"; +import "./styles/login.css"; +import branding from "./branding/branding"; +import Logout from "./pages/login/logout"; +import Infrastructure from "./pages/infrastructure/infrastructure"; +import Usergroup from "./pages/usergroups/usergroup"; +import { useSelector } from "react-redux"; +import DashboardErrorBoundary from "./pages/dashboards/dashboard-error-boundry"; const App = () => { - const isTokenExpired = (token) => { let decodedToken = jwt.decode(token); let timeNow = (new Date().getTime() + 1) / 1000; return decodedToken.exp < timeNow; - } + }; + + const notificationSystem = useRef(null); + + useEffect(() => { + NotificationsDataManager.setSystem(notificationSystem.current); + + return () => { + NotificationsDataManager.setSystem(null); + }; + }, []); const { isAuthenticated, token, user } = useSelector((state) => state.auth); if (!isAuthenticated || isTokenExpired(token)) { console.log("APP redirecting to logout/login"); - return (); + return ; } else { console.log("APP rendering app"); const pages = branding.values.pages; - return ( -
- -
+ return ( + +
+ +
-
- +
+ -
- - { pages.home ? : '' } - { pages.scenarios ? <> - - - - - - - - - - - - - - - - - : '' } - { user.role === "Admin" || pages.infrastructure ? <> - - - - - - - - : '' } - { pages.account ? : '' } - { user.role === "Admin" ? - - - - : '' } - { user.role === "Admin" || pages.api ? - - : '' } +
+ + {pages.home ? : ""} + {pages.scenarios ? ( + <> + + + + + + + + + + + + + + + + + + + ) : ( + "" + )} + {user.role === "Admin" || pages.infrastructure ? ( + <> + + + + + + + + ) : ( + "" + )} + {pages.account ? ( + + + + ) : ( + "" + )} + {user.role === "Admin" ? ( + <> + + + + + + + + ) : ( + "" + )} + {user.role === "Admin" || pages.api ? ( + + ) : ( + "" + )} +
-
- {branding.getFooter()} -
- ) + {branding.getFooter()} +
+
+ ); } -} +}; export default App; diff --git a/src/branding/branding.js b/src/branding/branding.js index 2d3bf535..82cf8dc8 100644 --- a/src/branding/branding.js +++ b/src/branding/branding.js @@ -15,17 +15,29 @@ * along with VILLASweb. If not, see . ******************************************************************************/ -import { villasweb_footer, villasweb_home, villasweb_welcome } from './villasweb/villasweb-functions'; -import villasweb_values from './villasweb/villasweb-values'; - -import { slew_home, slew_welcome } from './slew/slew-functions'; -import slew_values from './slew/slew-values'; - -import { opalrt_footer, opalrt_home, opalrt_welcome } from './opalrt/opalrt-functions'; -import opalrt_values from './opalrt/opalrt-values'; - -import { template_welcome, template_home, template_footer } from './template/template-functions'; -import template_values from './template/template-values'; +import { + villasweb_footer, + villasweb_home, + villasweb_welcome, +} from "./villasweb/villasweb-functions"; +import villasweb_values from "./villasweb/villasweb-values"; + +import { slew_home, slew_welcome } from "./slew/slew-functions"; +import slew_values from "./slew/slew-values"; + +import { + opalrt_footer, + opalrt_home, + opalrt_welcome, +} from "./opalrt/opalrt-functions"; +import opalrt_values from "./opalrt/opalrt-values"; + +import { + template_welcome, + template_home, + template_footer, +} from "./template/template-functions"; +import template_values from "./template/template-values"; class Branding { constructor(brand) { @@ -40,39 +52,43 @@ class Branding { setValues() { switch (this.brand) { - case 'villasweb': + case "villasweb": this.values = villasweb_values; break; - case 'slew': + case "slew": this.values = slew_values; break; - case 'opalrt': + case "opalrt": this.values = opalrt_values; break; - case 'template': + case "template": this.values = template_values; break; default: - console.error("Branding '" + this.brand + "' not available, will use 'villasweb' branding"); - this.brand = 'villasweb'; + console.error( + "Branding '" + + this.brand + + "' not available, will use 'villasweb' branding" + ); + this.brand = "villasweb"; this.values = villasweb_values; break; } } - getHome(username = '', userid = '', role = '') { - var homepage = ''; + getHome(username = "", userid = "", role = "") { + var homepage = ""; switch (this.brand) { - case 'villasweb': + case "villasweb": homepage = villasweb_home(this.getTitle(), username, userid, role); break; - case 'slew': + case "slew": homepage = slew_home(); break; - case 'opalrt': + case "opalrt": homepage = opalrt_home(this.getTitle(), username, userid, role); break; - case 'template': + case "template": homepage = template_home(); break; default: @@ -83,14 +99,17 @@ class Branding { } getFooter() { - var footer = ''; + var footer = ""; switch (this.brand) { - case 'template': + case "template": footer = template_footer(); break; - case 'opalrt': + case "opalrt": footer = opalrt_footer(); break; + case 'enershare': + footer = enershare_footer(); + break; default: footer = villasweb_footer(); break; @@ -99,18 +118,18 @@ class Branding { } getWelcome() { - var welcome = ''; + var welcome = ""; switch (this.brand) { - case 'villasweb': + case "villasweb": welcome = villasweb_welcome(); break; - case 'slew': + case "slew": welcome = slew_welcome(); break; - case 'opalrt': + case "opalrt": welcome = opalrt_welcome(); break; - case 'template': + case "template": welcome = template_welcome(); break; default: @@ -121,7 +140,12 @@ class Branding { } defaultWelcome() { - return (

Welcome!

This is the welcome page and you are very welcome here.

); + return ( +
+

Welcome!

+

This is the welcome page and you are very welcome here.

+
+ ); } // if icon cannot be found, the default favicon will be used @@ -137,12 +161,12 @@ class Branding { if (!this.values.icon) { return; } - var oldlink = document.getElementById('dynamic-favicon'); + var oldlink = document.getElementById("dynamic-favicon"); - var link = document.createElement('link'); - link.id = 'dynamic-favicon'; - link.rel = 'shortcut icon' - link.href = '/' + this.values.icon; + var link = document.createElement("link"); + link.id = "dynamic-favicon"; + link.rel = "shortcut icon"; + link.href = "/" + this.values.icon; if (oldlink) { document.head.removeChild(oldlink); @@ -151,7 +175,7 @@ class Branding { } checkValues() { - if (!this.values.hasOwnProperty('pages')) { + if (!this.values.hasOwnProperty("pages")) { let pages = {}; pages.home = true; pages.scenarios = true; @@ -162,23 +186,23 @@ class Branding { this.values.pages = pages; } else { - if (!this.values.pages.hasOwnProperty('home')) { - this.values.pages['home'] = false; + if (!this.values.pages.hasOwnProperty("home")) { + this.values.pages["home"] = false; } - if (!this.values.pages.hasOwnProperty('scenarios')) { - this.values.pages['scenarios'] = false; + if (!this.values.pages.hasOwnProperty("scenarios")) { + this.values.pages["scenarios"] = false; } - if (!this.values.pages.hasOwnProperty('infrastructure')) { - this.values.pages['infrastructure'] = false; + if (!this.values.pages.hasOwnProperty("infrastructure")) { + this.values.pages["infrastructure"] = false; } - if (!this.values.pages.hasOwnProperty('users')) { - this.values.pages['users'] = false; + if (!this.values.pages.hasOwnProperty("users")) { + this.values.pages["users"] = false; } - if (!this.values.pages.hasOwnProperty('account')) { - this.values.pages['account'] = false; + if (!this.values.pages.hasOwnProperty("account")) { + this.values.pages["account"] = false; } - if (!this.values.pages.hasOwnProperty('api')) { - this.values.pages['api'] = false; + if (!this.values.pages.hasOwnProperty("api")) { + this.values.pages["api"] = false; } } } @@ -186,10 +210,10 @@ class Branding { applyStyle() { this.changeHead(); - const rootEl = document.querySelector(':root'); + const rootEl = document.querySelector(":root"); for (const [key, value] of Object.entries(this.values.style)) { - rootEl.style.setProperty('--' + key, value); + rootEl.style.setProperty("--" + key, value); } } @@ -197,9 +221,17 @@ class Branding { let image = null; try { - image = {'Logo + image = ( + {"Logo + ); } catch (err) { - console.error("cannot find './" + this.brand + '/img/' + this.values.logo + "'"); + console.error( + "cannot find './" + this.brand + "/img/" + this.values.logo + "'" + ); } return image; @@ -212,7 +244,7 @@ class Branding { getSubtitle() { return this.values.subtitle ? this.values.subtitle : null; } -}; +} var branding = new Branding(process.env.REACT_APP_BRAND); diff --git a/src/branding/enershare/enershare-functions.js b/src/branding/enershare/enershare-functions.js new file mode 100644 index 00000000..aee263d8 --- /dev/null +++ b/src/branding/enershare/enershare-functions.js @@ -0,0 +1,57 @@ +/** + * This file is part of VILLASweb. + * + * VILLASweb is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * VILLASweb is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with VILLASweb. If not, see . + ******************************************************************************/ +import React from 'react'; + + +export function enershare_home() { + return ( +
+ EnerShare Logo +

Home

+

+ Welcome to DT-FPEN! +

+

The DT concept for electrical networks is based on the simulation tool DPSim, and allows the interconnection + to data acquisition devices (measurement devices and status indicators) and the interaction with other systems.

+ +

It is possibile to interconnect with other systems, like the service for Data-driven management of surplus RES generation + where forecasts for the consumers in low voltage are calculated, or that can be used for predicting the consumption + of the water pumps in the circuit of the pilot. This will allow the pilot to improve their usage of the flexibility capabilities.

+
) +} + +export function enershare_welcome() { + return ( +
+

Welcome!

+

This is the Digital Twin for flexible energy networks, a system designed to facilitate the integration and flexibility on electrical networks with renewable energy sources.

+
) +} + +export function enershare_footer() { + return ( + + ); +} diff --git a/src/branding/enershare/enershare-values.js b/src/branding/enershare/enershare-values.js new file mode 100644 index 00000000..3df93bd8 --- /dev/null +++ b/src/branding/enershare/enershare-values.js @@ -0,0 +1,45 @@ +/** + * 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 . + ******************************************************************************/ + +const enershare_values = { + title: 'Digital Twin for flexible energy networks', + subtitle: '', + icon: "logo_Enershare_Icon.svg", + logo: "logo_Enershare.svg", + pages: { + home: true, + scenarios: true, + infrastructure: true, + users: true, + account: true, + api: true + }, + links: { + "AppStore": "https://store.haslab-dataspace.pt/gui/", + "The Project": "https://enershare.eu/" + }, + style: { + background: 'rgba(24,229,176, 0.6)', + highlights: 'rgba(153,102,255, 0.75)', + main: 'rgba(0,0,0, 1)', + secondaryText: 'rgba(0,0,100, 0.8)', + fontFamily: "Manrope, sans-serif", + borderRadius: "60px" + } +} + +export default enershare_values; diff --git a/src/branding/enershare/img/logo_Enershare.svg b/src/branding/enershare/img/logo_Enershare.svg new file mode 100644 index 00000000..439ca4b0 --- /dev/null +++ b/src/branding/enershare/img/logo_Enershare.svg @@ -0,0 +1,33 @@ + + \ No newline at end of file diff --git a/src/branding/enershare/img/logo_Enershare_Icon.svg b/src/branding/enershare/img/logo_Enershare_Icon.svg new file mode 100644 index 00000000..05622f43 --- /dev/null +++ b/src/branding/enershare/img/logo_Enershare_Icon.svg @@ -0,0 +1,20 @@ + + \ No newline at end of file diff --git a/src/common/api/websocket-api.js b/src/common/api/websocket-api.js index 210181af..6c33b642 100644 --- a/src/common/api/websocket-api.js +++ b/src/common/api/websocket-api.js @@ -24,14 +24,14 @@ class WebSocketManager { } connect(id, url, onMessage, onOpen, onClose) { - const existingSocket = this.sockets.find(s => s.id === id); + const existingSocket = this.sockets.find((s) => s.id === id); if (existingSocket && existingSocket.socket.readyState === WebSocket.OPEN) { - console.log('Already connected to:', url); + console.log("Already connected to:", url); return; } - const socket = new WebSocket(url, 'live'); - socket.binaryType = 'arraybuffer'; + const socket = new WebSocket(url, "live"); + socket.binaryType = "arraybuffer"; socket.onopen = onOpen; socket.onmessage = (event) => { @@ -45,21 +45,21 @@ class WebSocketManager { } disconnect(id) { - const socket = this.sockets.find(s => s.id === id); + const socket = this.sockets.find((s) => s.id === id); if (socket) { socket.socket.close(); - this.sockets = this.sockets.filter(s => s.id !== id); - console.log('WebSocket connection closed for id:', id); + this.sockets = this.sockets.filter((s) => s.id !== id); + console.log("WebSocket connection closed for id:", id); } } send(id, message) { - const socket = this.sockets.find(s => s.id === id); + const socket = this.sockets.find((s) => s.id === id); if (socket == null) { return false; } - console.log("Sending to IC", id, "message: ", message); const data = this.messageToBuffer(message); + console.log("📤 Sending binary buffer to server:", message.values); socket.socket.send(data); return true; @@ -70,11 +70,11 @@ class WebSocketManager { const view = new DataView(buffer); let bits = 0; - bits |= (message.version & 0xF) << OFFSET_VERSION; + bits |= (message.version & 0xf) << OFFSET_VERSION; bits |= (message.type & 0x3) << OFFSET_TYPE; let source_index = 0; - source_index |= (message.source_index & 0xFF); + source_index |= message.source_index & 0xff; const sec = Math.floor(message.timestamp / 1e3); const nsec = (message.timestamp - sec * 1e3) * 1e6; @@ -84,7 +84,7 @@ class WebSocketManager { view.setUint16(0x02, message.length, true); view.setUint32(0x04, message.sequence, true); view.setUint32(0x08, sec, true); - view.setUint32(0x0C, nsec, true); + view.setUint32(0x0c, nsec, true); const data = new Float32Array(buffer, 0x10, message.length); data.set(message.values); @@ -119,19 +119,19 @@ class WebSocketManager { const bytes = length * 4 + 16; return { - version: (bits >> OFFSET_VERSION) & 0xF, + version: (bits >> OFFSET_VERSION) & 0xf, type: (bits >> OFFSET_TYPE) & 0x3, source_index: source_index, length: length, sequence: data.getUint32(0x04, 1), - timestamp: data.getUint32(0x08, 1) * 1e3 + data.getUint32(0x0C, 1) * 1e-6, + timestamp: data.getUint32(0x08, 1) * 1e3 + data.getUint32(0x0c, 1) * 1e-6, values: new Float32Array(data.buffer, data.byteOffset + 0x10, length), blob: new DataView(data.buffer, data.byteOffset + 0x00, bytes), }; } getSocketById(id) { - const socketEntry = this.sockets.find(s => s.id === id); + const socketEntry = this.sockets.find((s) => s.id === id); return socketEntry ? socketEntry.socket : null; } diff --git a/src/pages/dashboards/dashboard-button-group.js b/src/pages/dashboards/dashboard-button-group.js deleted file mode 100644 index 04a1500d..00000000 --- a/src/pages/dashboards/dashboard-button-group.js +++ /dev/null @@ -1,112 +0,0 @@ -/** - * This file is part of VILLASweb. - * - * VILLASweb is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * VILLASweb is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with VILLASweb. If not, see . - ******************************************************************************/ - -import React from 'react'; -import PropTypes from 'prop-types'; -import IconButton from '../../common/buttons/icon-button'; - -const buttonStyle = { - marginLeft: '12px', - height: '44px', - width: '35px', -}; - -const iconStyle = { - height: '25px', - width: '25px' -}; - -let buttonkey = 0; - -class DashboardButtonGroup extends React.Component { - - getBtn(icon, tooltip, clickFn, locked = false) { - if (locked) { - return - } else { - return - } - } - - render() { - const buttons = []; - buttonkey = 0; - - if (this.props.editing) { - buttons.push(this.getBtn("save", "Save changes", this.props.onSave)); - buttons.push(this.getBtn("times", "Discard changes", this.props.onCancel)); - } else { - if (this.props.fullscreen !== true) { - buttons.push(this.getBtn("expand", "Change to fullscreen view", this.props.onFullscreen)); - } else { - buttons.push(this.getBtn("compress", "Back to normal view", this.props.onFullscreen)); - } - - if (this.props.paused) { - buttons.push(this.getBtn("play", "Continue simulation", this.props.onUnpause)); - } else { - buttons.push(this.getBtn("pause", "Pause simulation", this.props.onPause)); - } - - if (this.props.fullscreen !== true) { - let tooltip = this.props.locked ? "View files of scenario" : "Add, edit or delete files of scenario"; - buttons.push(this.getBtn("file", tooltip, this.props.onEditFiles)); - buttons.push(this.getBtn("sign-in-alt", "Add, edit or delete input signals", this.props.onEditInputSignals, this.props.locked)); - buttons.push(this.getBtn("sign-out-alt", "Add, edit or delete output signals", this.props.onEditOutputSignals, this.props.locked)); - buttons.push(this.getBtn("pen", "Add widgets and edit layout", this.props.onEdit, this.props.locked)); - } - } - - return
- {buttons} -
; - } -} - -DashboardButtonGroup.propTypes = { - editing: PropTypes.bool, - fullscreen: PropTypes.bool, - paused: PropTypes.bool, - onEdit: PropTypes.func, - onSave: PropTypes.func, - onCancel: PropTypes.func, - onFullscreen: PropTypes.func, - onPause: PropTypes.func, - onUnpause: PropTypes.func -}; - -export default DashboardButtonGroup; diff --git a/src/pages/dashboards/dashboard-error-boundry.js b/src/pages/dashboards/dashboard-error-boundry.js new file mode 100644 index 00000000..42e881ac --- /dev/null +++ b/src/pages/dashboards/dashboard-error-boundry.js @@ -0,0 +1,30 @@ +import React from "react"; + +class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error) { + return { hasError: true }; + } + + componentDidCatch(error, errorInfo) { + console.error("Error caught by Error Boundary:", error, errorInfo); + } + + render() { + if (this.state.hasError) { + return ( + <> +

Something went wrong!

+ + ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/src/pages/dashboards/dashboard-layout.js b/src/pages/dashboards/dashboard-layout.js new file mode 100644 index 00000000..69e5a2c5 --- /dev/null +++ b/src/pages/dashboards/dashboard-layout.js @@ -0,0 +1,454 @@ +/** + * This file is part of VILLASweb. + * + * VILLASweb is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * VILLASweb is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with VILLASweb. If not, see . + ******************************************************************************/ + +import { useCallback, useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { + useGetDashboardQuery, + useGetICSQuery, + useGetWidgetsQuery, + useAddWidgetMutation, + useLazyGetSignalsQuery, + useLazyGetConfigsQuery, + useUpdateWidgetMutation, +} from "../../store/apiSlice"; +import { sessionToken } from "../../localStorage"; +import Fullscreenable from "react-fullscreenable"; +import DashboardButtonGroup from "./grid/dashboard-button-group"; +import Widget from "./widget/widget"; +import WidgetContainer from "./widget/widget-container"; +import WidgetArea from "./grid/widget-area"; +import WidgetToolbox from "./widget/widget-toolbox"; +import IconToggleButton from "../../common/buttons/icon-toggle-button"; +import EditWidgetDialog from "./widget/edit-widget/edit-widget"; +import EditSignalMappingDialog from "../scenarios/dialogs/edit-signal-mapping"; +import classNames from "classnames"; +import { Spinner } from "react-bootstrap"; + +//this component handles the UI of the dashboard +const DashboardLayout = ({ isFullscreen, toggleFullscreen }) => { + const params = useParams(); + const { + data: { dashboard } = {}, + isFetching: isFetchingDashboard, + refetch: refetchDashboard, + } = useGetDashboardQuery(params.dashboard); + const { + data: { widgets } = [], + isFetching: isFetchingWidgets, + refetch: refetchWidgets, + } = useGetWidgetsQuery(params.dashboard); + + const [updateWidget] = useUpdateWidgetMutation(); + + const [triggerGetSignals] = useLazyGetSignalsQuery(); + const [triggerGetConfigs] = useLazyGetConfigsQuery(); + + const [signals, setSignals] = useState([]); + const [configs, setConfigs] = useState([]); + + const [addWidget] = useAddWidgetMutation(); + + const { data: ics } = useGetICSQuery(); + + const [editing, setEditing] = useState(false); + const [paused, setPaused] = useState(false); + const [locked, setLocked] = useState(false); + const [editModal, setEditModal] = useState(false); + const [editModalData, setEditModalData] = useState(null); + + const [editInputSignalsModal, setEditingInputSignalsModal] = useState(false); + + //these are used to handle widget updates separately from RTK Query + const [localWidgets, setLocalWidgets] = useState([]); + const [widgetsToUpdate, setWidgetsToUpdate] = useState([]); + + useEffect(() => { + if (widgets?.length > 0) { + setLocalWidgets(widgets); + } + }, [widgets]); + + const fetchWidgetData = async (scenarioID) => { + try { + const configsRes = await triggerGetConfigs(scenarioID).unwrap(); + if (configsRes.configs) { + setConfigs(configsRes.configs); + //load signals if there are any configs + + if (configsRes.configs.length > 0) { + for (const config of configsRes.configs) { + const signalsInRes = await triggerGetSignals({ + configID: config.id, + direction: "in", + }).unwrap(); + const signalsOutRes = await triggerGetSignals({ + configID: config.id, + direction: "out", + }).unwrap(); + setSignals((prevState) => [ + ...signalsInRes.signals, + ...signalsOutRes.signals, + ...prevState, + ]); + } + } + } + } catch (err) { + console.log("error fetching data", err); + } + }; + + const [gridParameters, setGridParameters] = useState({ + height: 10, + grid: 50, + }); + + useEffect(() => { + if (!isFetchingDashboard) { + setGridParameters({ height: dashboard.height, grid: dashboard.grid }); + fetchWidgetData(dashboard.scenarioID); + } + }, [isFetchingDashboard]); + + const boxClasses = classNames("section", "box", { + "fullscreen-padding": isFullscreen, + }); + + const handleKeydown = useCallback( + (e) => { + const keyMap = { + " ": () => setPaused((prevPaused) => !prevPaused), + p: () => setPaused((prevPaused) => !prevPaused), + e: () => setEditing((prevEditing) => !prevEditing), + f: toggleFullscreen, + }; + if (keyMap[e.key]) { + keyMap[e.key](); + } + }, + [toggleFullscreen] + ); + + useEffect(() => { + window.addEventListener("keydown", handleKeydown); + return () => { + window.removeEventListener("keydown", handleKeydown); + }; + }, [handleKeydown]); + + const startEditing = () => { + let originalIDs = widgets.map((widget) => widget.id); + widgets.forEach(async (widget) => { + if ( + widget.type === "Slider" || + widget.type === "NumberInput" || + widget.type === "Button" + ) { + try { + await updateWidget({ + widgetID: widget.id, + updatedWidget: { widget }, + }).unwrap(); + } catch (err) { + console.log("error", err); + } + } + }); + setEditing(true); + }; + + const handleSaveEdit = async () => { + if ( + gridParameters.height !== dashboard?.height || + gridParameters.grid !== dashboard?.grid + ) { + try { + const { height: oldHeight, grid: oldGrid, ...rest } = dashboard; + await updateDashboard({ + dashboardID: dashboard?.id, + dashboard: { + height: gridParameters.height, + grid: gridParameters.grid, + ...rest, + }, + }).unwrap(); + } catch (err) { + console.log("error", err); + } + } + + if (widgetsToUpdate.length > 0) { + try { + for (const index in widgetsToUpdate) { + await updateWidget({ + widgetID: widgetsToUpdate[index], + updatedWidget: { + widget: { + ...widgets.find((w) => w.id == widgetsToUpdate[index]), + }, + }, + }).unwrap(); + } + refetchWidgets(dashboard?.id); + } catch (err) { + console.log("error", err); + } + } + + setEditing(false); + setWidgetChangeData([]); + }; + + const handleWidgetChange = async (widget) => { + setWidgetsToUpdate((prevWidgetsToUpdate) => [ + ...prevWidgetsToUpdate, + widget.id, + ]); + setLocalWidgets((prevWidgets) => + prevWidgets.map((w) => (w.id === widget.id ? { ...widget } : w)) + ); + }; + + const handleCloseEditModal = async (data) => { + if (!data) { + setEditModal(false); + setEditModalData(null); + return; + } + + if (data.type === "Image") { + data.customProperties.update = true; + } + + try { + await updateWidget({ + widgetID: data.id, + updatedWidget: { widget: data }, + }).unwrap(); + refetchWidgets(dashboard?.id); + } catch (err) { + console.log("error", err); + } + + setEditModal(false); + setEditModalData(null); + }; + + const handleCancelEdit = () => { + refetchWidgets(); + setEditing(false); + setWidgetChangeData([]); + setGridParameters({ height: dashboard?.height, grid: dashboard?.grid }); + }; + + const handleNewWidgetAdded = async (widget) => { + widget.dashboardID = dashboard?.id; + + if (widget.type === "ICstatus") { + let allICids = ics.map((ic) => ic.id); + widget.customProperties.checkedIDs = allICids; + } + + try { + const res = await addWidget(widget).unwrap(); + if (res) { + refetchWidgets(dashboard?.id); + } + } catch (err) { + console.log("error", err); + } + }; + + const editWidget = (widget, index) => { + setEditModal(true); + setEditModalData({ ...widget }); + }; + + const duplicateWidget = async (widget) => { + let widgetCopy = { + ...widget, + id: undefined, + x: widget.x + 50, + y: widget.y + 50, + }; + try { + const res = await addWidget({ widget: widgetCopy }).unwrap(); + if (res) { + refetchWidgets(dashboard?.id); + } + } catch (err) { + console.log("error", err); + } + }; + + const onChange = async (widget) => { + try { + await updateWidget({ + widgetID: widget.id, + updatedWidget: { widget: widget }, + }).unwrap(); + refetchWidgets(dashboard?.id); + } catch (err) { + console.log("error", err); + } + }; + + const handleCloseEditSignalsModal = (direction) => { + if (direction === "in") { + setEditInputSignalsModal(false); + } else if (direction === "out") { + setEditOutputSignalsModal(false); + } + }; + + return !isFetchingWidgets && !isFetchingDashboard ? ( +
+
+
+

+ {dashboard?.name} + + + +

+
+ + setPaused(true)} + onUnpause={() => setPaused(false)} + onEditFiles={() => {}} + onEditOutputSignals={() => setEditInputSignalsModal(true)} + onEditInputSignals={() => setEditInputSignalsModal(true)} + /> +
+ +
e.preventDefault()} + > + {editing && ( + + setGridParameters((prevState) => ({ ...prevState, grid: val })) + } + dashboard={dashboard} + onDashboardSizeChange={(val) => + setGridParameters((prevState) => ({ ...prevState, height: val })) + } + widgets={localWidgets} + /> + )} + + + {localWidgets != null && + Object.keys(localWidgets).map((widgetKey) => ( +
+ deleteWidget(widget.id)} + onChange={editing ? handleWidgetChange : onChange} + > + + +
+ ))} +
+ + + + {/* */} + + +
+
+ ) : ( + + ); +}; + +export default Fullscreenable()(DashboardLayout); diff --git a/src/pages/dashboards/dashboard.js b/src/pages/dashboards/dashboard.js deleted file mode 100644 index 1cb7efe6..00000000 --- a/src/pages/dashboards/dashboard.js +++ /dev/null @@ -1,561 +0,0 @@ -/** - * This file is part of VILLASweb. - * - * VILLASweb is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * VILLASweb is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with VILLASweb. If not, see . - ******************************************************************************/ - -import React, { useState, useEffect, useCallback, useRef, act } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { useParams } from 'react-router-dom'; -import Fullscreenable from 'react-fullscreenable'; -import classNames from 'classnames'; -import 'react-contexify/dist/ReactContexify.min.css'; -import EditWidget from './widget/edit-widget/edit-widget'; -import EditSignalMappingDialog from '../scenarios/dialogs/edit-signal-mapping' -import WidgetToolbox from './widget/widget-toolbox'; -import WidgetArea from './widget-area'; -import DashboardButtonGroup from './dashboard-button-group'; -import IconToggleButton from '../../common/buttons/icon-toggle-button'; -import WidgetContainer from "./widget/widget-container"; -import Widget from "./widget/widget"; - -import { connectWebSocket, disconnect } from '../../store/websocketSlice'; - -import { - useGetDashboardQuery, - useLazyGetWidgetsQuery, - useLazyGetConfigsQuery, - useAddWidgetMutation, - useUpdateWidgetMutation, - useDeleteWidgetMutation, - useLazyGetFilesQuery, - useUpdateDashboardMutation, - useGetICSQuery, - useLazyGetSignalsQuery -} from '../../store/apiSlice'; - -const startUpdaterWidgets = new Set(['Slider', 'Button', 'NumberInput']); - -const Dashboard = ({ isFullscreen, toggleFullscreen }) => { - const dispatch = useDispatch(); - const params = useParams(); - const { data: dashboardRes, error: dashboardError, isLoading: isDashboardLoading } = useGetDashboardQuery(params.dashboard); - const dashboard = dashboardRes ? dashboardRes.dashboard : {}; - const {data: icsRes} = useGetICSQuery(); - const ics = icsRes ? icsRes.ics : []; - - const [triggerGetWidgets] = useLazyGetWidgetsQuery(); - const [triggerGetConfigs] = useLazyGetConfigsQuery(); - const [triggerGetFiles] = useLazyGetFilesQuery(); - const [triggerGetSignals] = useLazyGetSignalsQuery(); - const [addWidget] = useAddWidgetMutation(); - const [updateWidget] = useUpdateWidgetMutation(); - const [deleteWidgetMutation] = useDeleteWidgetMutation(); - const [updateDashboard] = useUpdateDashboardMutation(); - - const [widgets, setWidgets] = useState([]); - const [widgetsToUpdate, setWidgetsToUpdate] = useState([]); - const [configs, setConfigs] = useState([]); - const [signals, setSignals] = useState([]); - const [sessionToken, setSessionToken] = useState(localStorage.getItem("token")); - const [files, setFiles] = useState([]); - const [editing, setEditing] = useState(false); - const [paused, setPaused] = useState(false); - const [editModal, setEditModal] = useState(false); - const [editOutputSignalsModal, setEditOutputSignalsModal] = useState(false); - const [editInputSignalsModal, setEditInputSignalsModal] = useState(false); - const [filesEditModal, setFilesEditModal] = useState(false); - const [filesEditSaveState, setFilesEditSaveState] = useState([]); - const [modalData, setModalData] = useState(null); - const [modalIndex, setModalIndex] = useState(null); - const [widgetChangeData, setWidgetChangeData] = useState([]); - const [widgetOrigIDs, setWidgetOrigIDs] = useState([]); - const [maxWidgetHeight, setMaxWidgetHeight] = useState(null); - const [locked, setLocked] = useState(false); - - const [height, setHeight] = useState(10); - const [grid, setGrid] = useState(50); - const [newHeightValue, setNewHeightValue] = useState(0); - - //ics that are included in configurations - const [activeICS, setActiveICS] = useState([]); - - useEffect(() => { - let usedICS = []; - for(const config of configs){ - usedICS.push(config.icID); - } - setActiveICS(ics.filter((i) => usedICS.includes(i.id))); - }, [configs]) - - const activeSocketURLs = useSelector((state) => state.websocket.activeSocketURLs); - - //connect to websockets - useEffect(() => { - activeICS.forEach((i) => { - if(i.websocketurl){ - if(!activeSocketURLs.includes(i.websocketurl)) - dispatch(connectWebSocket({ url: i.websocketurl, id: i.id })); - } - }) - - return () => { - activeICS.forEach((i) => { - dispatch(disconnect({ id: i.id })); - }); - }; - - }, [activeICS]); - - - //as soon as dashboard is loaded, load widgets, configs, signals and files for this dashboard - useEffect(() => { - if (dashboard.id) { - fetchWidgets(dashboard.id); - fetchWidgetData(dashboard.scenarioID); - setHeight(dashboard.height); - setGrid(dashboard.grid); - - console.log('widgets', widgets); - } - }, [dashboard]); - - const fetchWidgets = async (dashboardID) => { - try { - const widgetsRes = await triggerGetWidgets(dashboardID).unwrap(); - if (widgetsRes.widgets) { - setWidgets(widgetsRes.widgets); - } - } catch (err) { - console.log('error fetching data', err); - } - } - - const fetchWidgetData = async (scenarioID) => { - try { - const filesRes = await triggerGetFiles(scenarioID).unwrap(); - if (filesRes.files) { - setFiles(filesRes.files); - } - const configsRes = await triggerGetConfigs(scenarioID).unwrap(); - if (configsRes.configs) { - setConfigs(configsRes.configs); - //load signals if there are any configs - - if(configsRes.configs.length > 0){ - for(const config of configsRes.configs){ - const signalsInRes = await triggerGetSignals({configID: config.id, direction: "in"}).unwrap(); - const signalsOutRes = await triggerGetSignals({configID: config.id, direction: "out"}).unwrap(); - setSignals(prevState => ([...signalsInRes.signals, ...signalsOutRes.signals, ...prevState])); - } - } - - } - } catch (err) { - console.log('error fetching data', err); - } - } - - const handleKeydown = useCallback((e) => { - switch (e.key) { - case ' ': - case 'p': - setPaused(prevPaused => !prevPaused); - break; - case 'e': - setEditing(prevEditing => !prevEditing); - break; - case 'f': - toggleFullscreen(); - break; - default: - } - }, [toggleFullscreen]); - - useEffect(() => { - window.addEventListener('keydown', handleKeydown); - return () => { - window.removeEventListener('keydown', handleKeydown); - }; - }, [handleKeydown]); - - const handleDrop = async (widget) => { - widget.dashboardID = dashboard.id; - - if (widget.type === 'ICstatus') { - let allICids = ics.map(ic => ic.id); - widget.customProperties.checkedIDs = allICids; - } - - try { - const res = await addWidget(widget).unwrap(); - if (res) { - fetchWidgets(dashboard.id); - } - } catch (err) { - console.log('error', err); - } - }; - - const widgetChange = async (widget) => { - setWidgetsToUpdate(prevWidgetsToUpdate => [...prevWidgetsToUpdate, widget.id]); - setWidgets(prevWidgets => prevWidgets.map(w => w.id === widget.id ? {...widget} : w)); - - // try { - // await updateWidget({ widgetID: widget.id, updatedWidget: { widget } }).unwrap(); - // fetchWidgets(dashboard.id); - // } catch (err) { - // console.log('error', err); - // } - }; - - const onChange = async (widget) => { - try { - await updateWidget({ widgetID: widget.id, updatedWidget: { widget: widget } }).unwrap(); - fetchWidgets(dashboard.id); - } catch (err) { - console.log('error', err); - } - }; - - const onSimulationStarted = () => { - widgets.forEach(async (widget) => { - if (startUpdaterWidgets.has(widget.type)) { - widget.customProperties.simStartedSendValue = true; - try { - await updateWidget({ widgetID: widget.id, updatedWidget: { widget } }).unwrap(); - } catch (err) { - console.log('error', err); - } - } - }); - }; - - const editWidget = (widget, index) => { - setEditModal(true); - setModalData({...widget}); - setModalIndex(index); - }; - - const duplicateWidget = async (widget) => { - let widgetCopy = { ...widget, id: undefined, x: widget.x + 50, y: widget.y + 50 }; - try { - const res = await addWidget({ widget: widgetCopy }).unwrap(); - if (res) { - fetchWidgets(dashboard.id); - } - } catch (err) { - console.log('error', err); - } - }; - - const startEditFiles = () => { - let tempFiles = files.map(file => ({ id: file.id, name: file.name })); - setFilesEditModal(true); - setFilesEditSaveState(tempFiles); - }; - - const closeEditFiles = () => { - widgets.forEach(widget => { - if (widget.type === "Image") { - //widget.customProperties.update = true; - } - }); - setFilesEditModal(false); - }; - - const closeEdit = async (data) => { - if (!data) { - setEditModal(false); - setModalData(null); - setModalIndex(null); - return; - } - - if (data.type === "Image") { - data.customProperties.update = true; - } - - try { - await updateWidget({ widgetID: data.id, updatedWidget: { widget: data } }).unwrap(); - fetchWidgets(dashboard.id); - } catch (err) { - console.log('error', err); - } - - setEditModal(false); - setModalData(null); - setModalIndex(null); - }; - - const deleteWidget = async (widgetID) => { - try { - await deleteWidgetMutation(widgetID).unwrap(); - fetchWidgets(dashboard.id); - } catch (err) { - console.log('error', err); - } - }; - - const startEditing = () => { - let originalIDs = widgets.map(widget => widget.id); - widgets.forEach(async (widget) => { - if (widget.type === 'Slider' || widget.type === 'NumberInput' || widget.type === 'Button') { - try { - await updateWidget({ widgetID: widget.id, updatedWidget: { widget } }).unwrap(); - } catch (err) { - console.log('error', err); - } - } else if (widget.type === 'Image') { - //widget.customProperties.update = true; - } - }); - setEditing(true); - setWidgetOrigIDs(originalIDs); - }; - - const saveEditing = async () => { - // widgets.forEach(async (widget) => { - // if (widget.type === 'Image') { - // widget.customProperties.update = true; - // } - // try { - // await updateWidget({ widgetID: widget.id, updatedWidget: { widget } }).unwrap(); - // } catch (err) { - // console.log('error', err); - // } - // }); - - - if(height !== dashboard.height || grid !== dashboard.grid) { - try { - const {height: oldHeight, grid: oldGrid, ...rest} = dashboard; - await updateDashboard({dashboardID: dashboard.id, dashboard:{height: height, grid: grid, ...rest}}).unwrap(); - } catch (err) { - console.log('error', err); - } - } - - - if(widgetsToUpdate.length > 0){ - try { - for(const index in widgetsToUpdate){ - await updateWidget({ widgetID: widgetsToUpdate[index], updatedWidget: { widget: {...widgets.find(w => w.id == widgetsToUpdate[index])} } }).unwrap(); - } - fetchWidgets(dashboard.id); - } catch (err) { - console.log('error', err); - } - } - - setEditing(false); - setWidgetChangeData([]); - }; - - const cancelEditing = () => { - // widgets.forEach(async (widget) => { - // if (widget.type === 'Image') { - // widget.customProperties.update = true; - // } - // if (!widgetOrigIDs.includes(widget.id)) { - // try { - // await deleteWidget(widget.id).unwrap(); - // } catch (err) { - // console.log('error', err); - // } - // } - // }); - fetchWidgets(dashboard.id); - setEditing(false); - setWidgetChangeData([]); - setHeight(dashboard.height); - setGrid(dashboard.grid); - }; - - const updateGrid = (value) => { - setGrid(value); - }; - - const updateHeight = (value) => { - const maxHeight = Object.values(widgets).reduce((currentHeight, widget) => { - const absolutHeight = widget.y + widget.height; - return absolutHeight > currentHeight ? absolutHeight : currentHeight; - }, 0); - - if (value === -1) { - if (dashboard.height >= 450 && dashboard.height >= (maxHeight + 80)) { - setHeight(prevState => (prevState - 50)); - } - } else { - setHeight( prevState => ( prevState + 50)); - } - }; - - const pauseData = () => setPaused(true); - const unpauseData = () => setPaused(false); - const editInputSignals = () => setEditInputSignalsModal(true); - const editOutputSignals = () => setEditOutputSignalsModal(true); - - const closeEditSignalsModal = (direction) => { - if (direction === "in") { - setEditInputSignalsModal(false); - } else if (direction === "out") { - setEditOutputSignalsModal(false); - } - }; - - const buttonStyle = { marginLeft: '10px' }; - const iconStyle = { height: '25px', width: '25px' }; - const boxClasses = classNames('section', 'box', { 'fullscreen-padding': isFullscreen }); - - if (isDashboardLoading) { - return
Loading...
; - } - - if (dashboardError) { - return
Error. Dashboard not found
; - } - - return ( -
-
-
-

- {dashboard.name} - - - -

-
- - -
- -
e.preventDefault()}> - {editing && - - } - - - {widgets != null && Object.keys(widgets).map(widgetKey => ( -
- deleteWidget(widget.id)} - onChange={editing ? widgetChange : onChange} - > - onSimulationStarted()} - /> - -
- ))} -
- - - - {/* */} - - -
-
- ); -}; - -export default Fullscreenable()(Dashboard); diff --git a/src/pages/dashboards/dashboard.jsx b/src/pages/dashboards/dashboard.jsx new file mode 100644 index 00000000..8daafbf1 --- /dev/null +++ b/src/pages/dashboards/dashboard.jsx @@ -0,0 +1,612 @@ +/** + * This file is part of VILLASweb. + * + * VILLASweb is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * VILLASweb is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with VILLASweb. If not, see . + ******************************************************************************/ + +import React, { useState, useEffect, useCallback, useRef } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { useParams } from "react-router-dom"; +import Fullscreenable from "react-fullscreenable"; +import "react-contexify/dist/ReactContexify.min.css"; +import EditWidget from "./widget/edit-widget/edit-widget"; +import EditSignalMappingDialog from "./dialogs/edit-signal-mapping.jsx"; +import WidgetToolbox from "./widget/widget-toolbox"; +import WidgetArea from "./grid/widget-area"; +import DashboardButtonGroup from "./grid/dashboard-button-group"; +import IconToggleButton from "../../common/buttons/icon-toggle-button"; +import WidgetContainer from "./widget/widget-container"; +import Widget from "./widget/widget.jsx"; +import EditFilesDialog from "./dialogs/edit-files-dialog.jsx"; + +import { disconnect } from "../../store/websocketSlice"; +import { useDashboardData } from "./hooks/use-dashboard-data.js"; +import useWebSocketConnection from "./hooks/use-websocket-connection.js"; + +import { + useGetDashboardQuery, + useLazyGetWidgetsQuery, + useLazyGetFilesQuery, + useAddWidgetMutation, + useUpdateWidgetMutation, + useDeleteWidgetMutation, + useUpdateDashboardMutation, + useGetICSQuery, + useDeleteFileMutation, + useAddFileMutation, + useUpdateFileMutation, +} from "../../store/apiSlice"; +import { Spinner } from "react-bootstrap"; + +const startUpdaterWidgets = new Set(["Slider", "Button", "NumberInput"]); + +const Dashboard = ({ isFullscreen, toggleFullscreen }) => { + const dispatch = useDispatch(); + const { token: sessionToken } = useSelector((state) => state.auth); + const params = useParams(); + + const { + data: { dashboard } = { dashboard: {} }, + error: dashboardError, + isLoading: isDashboardLoading, + } = useGetDashboardQuery(params.dashboard); + const { data: { ics } = {} } = useGetICSQuery(); + + const [triggerGetWidgets] = useLazyGetWidgetsQuery(); + const [triggerDeleteFile] = useDeleteFileMutation(); + const [triggerUploadFile] = useAddFileMutation(); + const [addWidget] = useAddWidgetMutation(); + const [updateWidget] = useUpdateWidgetMutation(); + const [deleteWidgetMutation] = useDeleteWidgetMutation(); + const [updateDashboard] = useUpdateDashboardMutation(); + const [triggerUpdateFile] = useUpdateFileMutation(); + const [triggerGetFiles] = useLazyGetFilesQuery(); + + const [widgets, setWidgets] = useState([]); + const [widgetsToUpdate, setWidgetsToUpdate] = useState([]); + const [editing, setEditing] = useState(false); + const [paused, setPaused] = useState(false); + const [locked, setLocked] = useState(false); + const [editModal, setEditModal] = useState(false); + const [editOutputSignalsModal, setEditOutputSignalsModal] = useState(false); + const [editInputSignalsModal, setEditInputSignalsModal] = useState(false); + const [filesEditModal, setFilesEditModal] = useState(false); + const [modalData, setModalData] = useState(null); + const [widgetOrigIDs, setWidgetOrigIDs] = useState([]); + const [height, setHeight] = useState(10); + const [grid, setGrid] = useState(50); + + const { files, configs, signals, activeICS, refetchDashboardData } = + useDashboardData(dashboard.scenarioID); + + //as soon as dashboard is loaded, load widgets, configs, signals and files for this dashboard + useEffect(() => { + if (dashboard.id) { + fetchWidgets(dashboard.id); + setHeight(dashboard.height); + setGrid(dashboard.grid); + } + }, [dashboard]); + + useWebSocketConnection(activeICS, signals, widgets); + + //disconnect from the websocket on component unmount + useEffect(() => { + return () => { + activeICS.forEach((ic) => { + dispatch(disconnect({ id: ic.id })); + }); + }; + }, []); + + const fetchWidgets = async (dashboardID) => { + try { + const widgetsRes = await triggerGetWidgets(dashboardID).unwrap(); + if (widgetsRes.widgets) { + setWidgets(widgetsRes.widgets); + } + } catch (err) { + console.log("error fetching data", err); + } + }; + + const handleKeydown = useCallback( + ({ key }) => { + const actions = { + " ": () => setPaused((prev) => !prev), + p: () => setPaused((prev) => !prev), + e: () => setEditing((prev) => !prev), + f: toggleFullscreen, + }; + if (actions[key]) actions[key](); + }, + [toggleFullscreen] + ); + + useEffect(() => { + window.addEventListener("keydown", handleKeydown); + return () => { + window.removeEventListener("keydown", handleKeydown); + }; + }, [handleKeydown]); + + const handleDrop = async (widget) => { + widget.dashboardID = dashboard.id; + + if (widget.type === "ICstatus") { + let allICids = ics.map((ic) => ic.id); + widget.customProperties.checkedIDs = allICids; + } + + try { + const res = await addWidget(widget).unwrap(); + if (res) { + fetchWidgets(dashboard.id); + } + } catch (err) { + console.log("error", err); + } + }; + + const widgetChange = async (widget) => { + setWidgetsToUpdate((prevWidgetsToUpdate) => [ + ...prevWidgetsToUpdate, + widget.id, + ]); + setWidgets((prevWidgets) => + prevWidgets.map((w) => (w.id === widget.id ? { ...widget } : w)) + ); + + try { + await updateWidget({ + widgetID: widget.id, + updatedWidget: { widget }, + }).unwrap(); + fetchWidgets(dashboard.id); + } catch (err) { + console.log("error", err); + } + }; + + const onChange = async (widget) => { + try { + await updateWidget({ + widgetID: widget.id, + updatedWidget: { widget: widget }, + }).unwrap(); + fetchWidgets(dashboard.id); + } catch (err) { + console.log("error", err); + } + }; + + const onSimulationStarted = () => { + widgets.forEach(async (widget) => { + if (startUpdaterWidgets.has(widget.type)) { + const updatedWidget = { + ...widget, + customProperties: { + ...widget.customProperties, + simStartedSendValue: true, + }, + }; + try { + await updateWidget({ + widgetID: widget.id, + updatedWidget: { widget: updatedWidget }, + }).unwrap(); + } catch (err) { + console.log("error", err); + } + } + }); + + fetchWidgets(dashboard.id); + }; + + const editWidget = (widget, index) => { + setEditModal(true); + setModalData({ ...widget }); + }; + + const duplicateWidget = async (widget) => { + let widgetCopy = { + ...widget, + id: undefined, + x: widget.x + 50, + y: widget.y + 50, + }; + try { + const res = await addWidget({ widget: widgetCopy }).unwrap(); + if (res) { + fetchWidgets(dashboard.id); + } + } catch (err) { + console.log("error", err); + } + }; + + const closeEditFiles = () => { + widgets.forEach((widget) => { + if (widget.type === "Image") { + //widget.customProperties.update = true; + } + }); + setFilesEditModal(false); + }; + + const closeEdit = async (data) => { + if (!data) { + setEditModal(false); + setModalData(null); + return; + } + + if (data.type === "Image") { + data.customProperties.update = true; + } + + console.log("UPDATING WIDGET", data); + + try { + await updateWidget({ + widgetID: data.id, + updatedWidget: { widget: data }, + }).unwrap(); + fetchWidgets(dashboard.id); + } catch (err) { + console.log("error", err); + } + + setEditModal(false); + setModalData(null); + }; + + const deleteWidget = async (widgetID) => { + try { + await deleteWidgetMutation(widgetID).unwrap(); + fetchWidgets(dashboard.id); + } catch (err) { + console.log("error", err); + } + }; + + const startEditing = () => { + let originalIDs = widgets.map((widget) => widget.id); + widgets.forEach(async (widget) => { + if ( + widget.type === "Slider" || + widget.type === "NumberInput" || + widget.type === "Button" + ) { + try { + await updateWidget({ + widgetID: widget.id, + updatedWidget: { widget }, + }).unwrap(); + } catch (err) { + console.log("error", err); + } + } else if (widget.type === "Image") { + //widget.customProperties.update = true; + } + }); + setEditing(true); + setWidgetOrigIDs(originalIDs); + }; + + const saveEditing = async () => { + widgets.forEach(async (widget) => { + if (widget.type === "Image") { + widget.customProperties.update = true; + } + try { + await updateWidget({ + widgetID: widget.id, + updatedWidget: { widget }, + }).unwrap(); + } catch (err) { + console.log("error", err); + } + }); + + if (height !== dashboard.height || grid !== dashboard.grid) { + try { + const { height: oldHeight, grid: oldGrid, ...rest } = dashboard; + await updateDashboard({ + dashboardID: dashboard.id, + dashboard: { height: height, grid: grid, ...rest }, + }).unwrap(); + } catch (err) { + console.log("error", err); + } + } + + if (widgetsToUpdate.length > 0) { + try { + for (const index in widgetsToUpdate) { + await updateWidget({ + widgetID: widgetsToUpdate[index], + updatedWidget: { + widget: { + ...widgets.find((w) => w.id == widgetsToUpdate[index]), + }, + }, + }).unwrap(); + } + fetchWidgets(dashboard.id); + } catch (err) { + console.log("error", err); + } + } + + setEditing(false); + }; + + const cancelEditing = () => { + widgets.forEach(async (widget) => { + if (widget.type === "Image") { + widget.customProperties.update = true; + } + if (!widgetOrigIDs.includes(widget.id)) { + try { + await deleteWidget(widget.id).unwrap(); + } catch (err) { + console.log("error", err); + } + } + }); + fetchWidgets(dashboard.id); + setEditing(false); + setHeight(dashboard.height); + setGrid(dashboard.grid); + }; + + const uploadFile = async (file) => { + await triggerUploadFile({ + scenarioID: dashboard.scenarioID, + file: file, + }) + .then(async () => { + await triggerGetFiles(dashboard.scenarioID).then((fs) => { + setFiles(fs.data.files); + }); + }) + .catch((e) => { + console.log(`File upload failed: ${e}`); + }); + }; + + const deleteFile = async (index) => { + let file = files[index]; + if (file !== undefined) { + await triggerDeleteFile(file.id) + .then(async () => { + await triggerGetFiles(dashboard.scenarioID) + .then((fs) => { + setFiles(fs.data.files); + }) + .catch((e) => { + console.log(`Error fetching files: ${e}`); + }); + }) + .catch((e) => { + console.log(`Error deleting: ${e}`); + }); + } + }; + + const updateFile = async (fileId, file) => { + try { + await triggerUpdateFile({ fileID: fileId, file: file }) + .then(async () => { + await triggerGetFiles(dashboard.scenarioID) + .then((fs) => { + setFiles(fs.data.files); + }) + .catch((e) => { + console.log(`Error fetching files: ${e}`); + }); + }) + .catch((e) => { + console.log(`Error deleting: ${e}`); + }); + } catch (error) { + console.error("Error updating file:", error); + } + }; + + const updateGrid = (value) => { + setGrid(value); + }; + + const updateHeight = (value) => { + const maxHeight = Object.values(widgets).reduce((currentHeight, widget) => { + const absolutHeight = widget.y + widget.height; + return absolutHeight > currentHeight ? absolutHeight : currentHeight; + }, 0); + + if (value === -1) { + if (dashboard.height >= 450 && dashboard.height >= maxHeight + 80) { + setHeight((prevState) => prevState - 50); + } + } else { + setHeight((prevState) => prevState + 50); + } + }; + + if (isDashboardLoading) { + return ; + } + + if (dashboardError) { + return
Error. Dashboard not found!
; + } + + return ( +
+
+
+

+ {dashboard.name} + + setLocked(!locked)} + buttonStyle={{ marginLeft: "10px" }} + iconStyle={{ height: "25px", width: "25px" }} + /> + +

+
+ + setPaused(true)} + onUnpause={() => setPaused(false)} + onEditFiles={() => setFilesEditModal(true)} + onEditOutputSignals={() => setEditOutputSignalsModal(true)} + onEditInputSignals={() => setEditInputSignalsModal(true)} + /> +
+ +
e.preventDefault()} + > + {editing && ( + + )} + + + {widgets != null && + Object.keys(widgets).map((widgetKey) => ( +
+ deleteWidget(widget.id)} + onChange={editing ? widgetChange : onChange} + > + onSimulationStarted()} + /> + +
+ ))} +
+ + + + setEditInputSignalsModal(false)} + direction="in" + signals={[...signals].filter((s) => s.direction == "in")} + configID={null} + configs={configs} + sessionToken={sessionToken} + refetch={refetchDashboardData} + /> + + setEditOutputSignalsModal(false)} + direction="out" + signals={[...signals].filter((s) => s.direction == "out")} + configID={null} + configs={configs} + sessionToken={sessionToken} + refetch={refetchDashboardData} + /> + + +
+
+ ); +}; + +export default Fullscreenable()(Dashboard); diff --git a/src/pages/dashboards/dialogs/edit-file-content.js b/src/pages/dashboards/dialogs/edit-file-content.js deleted file mode 100644 index c14570da..00000000 --- a/src/pages/dashboards/dialogs/edit-file-content.js +++ /dev/null @@ -1,82 +0,0 @@ -/** - * This file is part of VILLASweb. - * - * VILLASweb is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * VILLASweb is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with VILLASweb. If not, see . - ******************************************************************************/ - -import React from 'react'; -import { Form, Button, Col } from 'react-bootstrap'; -import Dialog from '../../../common/dialogs/dialog'; - -class EditFileContent extends React.Component { - valid = true; - - constructor(props) { - super(props); - - this.state = { - uploadFile: null, - }; - } - - selectUploadFile(event) { - this.setState({ uploadFile: event.target.files[0] }); - }; - - startEditContent(){ - const formData = new FormData(); - formData.append("file", this.state.uploadFile); - - AppDispatcher.dispatch({ - type: 'files/start-edit', - data: formData, - token: this.props.sessionToken, - id: this.props.file.id - }); - - this.setState({ uploadFile: null }); - }; - - onClose = () => { - this.props.onClose(); - }; - - render() { - return this.onClose()} - blendOutCancel = {true} - valid={true} - > - - this.selectUploadFile(event)} /> - - - - - - ; - } -} - -export default EditFileContent; \ No newline at end of file diff --git a/src/pages/dashboards/dialogs/edit-file-content.jsx b/src/pages/dashboards/dialogs/edit-file-content.jsx new file mode 100644 index 00000000..d99857bb --- /dev/null +++ b/src/pages/dashboards/dialogs/edit-file-content.jsx @@ -0,0 +1,57 @@ +/** + * This file is part of VILLASweb. + * + * VILLASweb is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * VILLASweb is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with VILLASweb. If not, see . + ******************************************************************************/ +import React, { useState } from "react"; +import { Form, Button, Col } from "react-bootstrap"; +import Dialog from "../../../common/dialogs/dialog"; + +const EditFileContent = ({ show, onClose, file, updateFile }) => { + const [uploadFile, setUploadFile] = useState(null); + + const selectUploadFile = (event) => { + const selectedFile = event.target.files[0]; + setUploadFile(selectedFile); + }; + + const handleUploadFile = () => { + updateFile(file.id, uploadFile); + setUploadFile(null); + onClose(); + }; + + return ( + + + + + + + + + + ); +}; + +export default EditFileContent; diff --git a/src/pages/dashboards/dialogs/edit-files-dialog.js b/src/pages/dashboards/dialogs/edit-files-dialog.js deleted file mode 100644 index 3eacda4e..00000000 --- a/src/pages/dashboards/dialogs/edit-files-dialog.js +++ /dev/null @@ -1,192 +0,0 @@ -/** - * This file is part of VILLASweb. - * - * VILLASweb is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * VILLASweb is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with VILLASweb. If not, see . - ******************************************************************************/ - -import React from 'react'; -import {Form, Button, Col, ProgressBar, Row} from 'react-bootstrap'; -import Dialog from '../../../common/dialogs/dialog'; -import { Table, ButtonColumn, DataColumn } from "../../../common/table"; -import EditFileContent from "./edit-file-content"; - -class EditFilesDialog extends React.Component { - valid = true; - - constructor(props) { - super(props); - - this.state = { - uploadFile: null, - uploadProgress: 0, - editModal: false, - modalFile: {} - }; - } - - onClose() { - this.props.onClose(); - } - - selectUploadFile(event) { - this.setState({ uploadFile: event.target.files[0] }); - }; - - startFileUpload(){ - // upload file - const formData = new FormData(); - formData.append("file", this.state.uploadFile); - - AppDispatcher.dispatch({ - type: 'files/start-upload', - data: formData, - token: this.props.sessionToken, - progressCallback: this.updateUploadProgress, - finishedCallback: this.clearProgress, - scenarioID: this.props.scenarioID, - }); - - this.setState({ uploadFile: null }); - }; - - updateUploadProgress = (event) => { - if (event.hasOwnProperty("percent")){ - this.setState({ uploadProgress: parseInt(event.percent.toFixed(), 10) }); - } else { - this.setState({ uploadProgress: 0 }); - } - }; - - clearProgress = (newFileID) => { - this.setState({ uploadProgress: 0 }); - }; - - closeEditModal() { - this.setState({editModal: false}); - } - - deleteFile(index){ - let file = this.props.files[index] - AppDispatcher.dispatch({ - type: 'files/start-remove', - data: file, - token: this.props.sessionToken - }); - } - - render() { - let fileOptions = []; - if (this.props.files.length > 0){ - fileOptions.push( - - ) - fileOptions.push(this.props.files.map((file, index) => ( - - ))) - } else { - fileOptions = - } - - const progressBarStyle = { - marginLeft: '100px', - marginTop: '-40px' - }; - - let title = this.props.locked ? "View files of scenario" : "Edit Files of Scenario"; - - return ( - this.onClose()} - blendOutCancel = {true} - valid={true} - > - - - - - - this.deleteFile(index)} - editButton - onEdit={index => this.setState({ editModal: true, modalFile: this.props.files[index] })} - locked={this.props.locked} - /> -
- -
-
Add file
- - - this.selectUploadFile(event)} - disabled={this.props.locked} - /> - - - - - - - -
- -
- - - - - -
- - this.closeEditModal(data)} - sessionToken={this.props.sessionToken} - file={this.state.modalFile} - /> -
- ); - } -} - -export default EditFilesDialog; \ No newline at end of file diff --git a/src/pages/dashboards/dialogs/edit-files-dialog.jsx b/src/pages/dashboards/dialogs/edit-files-dialog.jsx new file mode 100644 index 00000000..d8fae870 --- /dev/null +++ b/src/pages/dashboards/dialogs/edit-files-dialog.jsx @@ -0,0 +1,111 @@ +/** + * This file is part of VILLASweb. + * + * VILLASweb is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * VILLASweb is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with VILLASweb. If not, see . + ******************************************************************************/ + +import React, { useState } from "react"; +import { Form, Button, Col, ProgressBar, Row } from "react-bootstrap"; +import Dialog from "../../../common/dialogs/dialog"; +import { Table, ButtonColumn, DataColumn } from "../../../common/table"; +import EditFileContent from "./edit-file-content.jsx"; + +const EditFilesDialog = (props) => { + const [uploadFile, setUploadFile] = useState(null); + const [uploadProgress, setUploadProgress] = useState(0); + const [editModal, setEditModal] = useState(false); + const [modalFile, setModalFile] = useState({}); + + const onClose = () => { + props.onClose(); + }; + + const selectUploadFile = (e) => { + console.log("SELECTED FILE", e.target.files[0]); + setUploadFile(e.target.files[0]); + }; + + let title = props.locked + ? "View files of scenario" + : "Edit Files of Scenario"; + + return ( + onClose()} + blendOutCancel={true} + valid={true} + > + + + + + + { + setEditModal(true); + setModalFile(props.files[index]); + }} + locked={props.locked} + /> +
+ +
+
Add file
+ + + selectUploadFile(event)} + disabled={props.locked} + /> + + + + + + + +
+ +
+ +
+ + setEditModal(false)} + sessionToken={props.sessionToken} + file={modalFile} + updateFile={props.updateFile} + /> +
+ ); +}; + +export default EditFilesDialog; diff --git a/src/pages/dashboards/dialogs/edit-signal-mapping.jsx b/src/pages/dashboards/dialogs/edit-signal-mapping.jsx new file mode 100644 index 00000000..ecc77d36 --- /dev/null +++ b/src/pages/dashboards/dialogs/edit-signal-mapping.jsx @@ -0,0 +1,273 @@ +import { useState, useEffect } from "react"; +import { Button, Form, OverlayTrigger, Tooltip } from "react-bootstrap"; +import { + Table, + ButtonColumn, + CheckboxColumn, + DataColumn, +} from "../../../common/table"; +import { + dialogWarningLabel, + signalDialogCheckButton, + buttonStyle, +} from "../styles"; +import Dialog from "../../../common/dialogs/dialog"; +import Icon from "../../../common/icon"; +import { + useDeleteSignalMutation, + useUpdateSignalMutation, +} from "../../../store/apiSlice"; +import { Collapse } from "react-collapse"; + +const EditSignalMappingDialog = ({ + show, + direction, + onClose, + signals, + refetch, +}) => { + const [isCollapseOpened, setCollapseOpened] = useState(false); + const [checkedSignalsIDs, setCheckedSignalsIDs] = useState([]); + const [deleteSignal] = useDeleteSignalMutation(); + const [updateSignal] = useUpdateSignalMutation(); + + const [updatedSignals, setUpdatedSignals] = useState([]); + const [updatedSignalsIDs, setUpdatedSignalsIDs] = useState([]); + + useEffect(() => { + if (signals.length > 0) { + setUpdatedSignals([...signals]); + } + }, [signals]); + + const handleMappingChange = (e, row, column) => { + const signalToUpdate = { ...updatedSignals[row] }; + switch (column) { + case 1: + signalToUpdate.index = e.target.value; + break; + case 2: + signalToUpdate.name = e.target.value; + break; + case 3: + signalToUpdate.unit = e.target.value; + break; + case 4: + signalToUpdate.scalingFactor = e.target.value; + break; + default: + break; + } + + setUpdatedSignals((prevState) => + prevState.map((signal, index) => + index === row ? signalToUpdate : signal + ) + ); + + setUpdatedSignalsIDs((prevState) => [signalToUpdate.id, ...prevState]); + }; + + const handleDelete = async (signalID) => { + try { + await deleteSignal(signalID).unwrap(); + } catch (err) { + console.log(err); + } + + refetch(); + }; + + const handleUpdate = async () => { + try { + for (let id of updatedSignalsIDs) { + const signalToUpdate = updatedSignals.find( + (signal) => signal.id === id + ); + + if (signalToUpdate) { + await updateSignal({ + signalID: id, + updatedSignal: signalToUpdate, + }).unwrap(); + } + } + + refetch(); + setUpdatedSignalsIDs([]); + } catch (error) { + console.error("Error updating signals:", error); + } + }; + + const onSignalChecked = (signal, event) => { + if (!checkedSignalsIDs.includes(signal.id)) { + setCheckedSignalsIDs((prevState) => [...prevState, signal.id]); + } else { + const index = checkedSignalsIDs.indexOf(signal.id); + setCheckedSignalsIDs((prevState) => + prevState.filter((_, i) => i !== index) + ); + } + }; + + const isSignalChecked = (signal) => { + return checkedSignalsIDs.includes(signal.id); + }; + + const toggleCheckAll = () => { + //check if all signals are already checked + if (checkedSignalsIDs.length === signals.length) { + setCheckedSignalsIDs([]); + } else { + signals.forEach((signal) => { + if (!checkedSignalsIDs.includes(signal.id)) { + setCheckedSignalsIDs((prevState) => [...prevState, signal.id]); + } + }); + } + }; + + const deleteCheckedSignals = async () => { + if (checkedSignalsIDs.length > 0) { + try { + const deletePromises = checkedSignalsIDs.map((signalID) => + deleteSignal(signalID).unwrap() + ); + await Promise.all(deletePromises); + refetchSignals(); + } catch (err) { + console.log(err); + } + } + }; + + const DialogWindow = ( + { + handleUpdate(); + onClose(c); + }} + onReset={() => {}} + valid={true} + > + + + IMPORTANT: Signal configurations that were created before January 2022 + have to be fixed manually. Signal indices have to start at 0 and not + 1. + + + {" "} + Click in table cell to edit + + onSignalChecked(signal)} + data={updatedSignals} + > + onSignalChecked(index, event)} + checked={(signal) => isSignalChecked(signal)} + width="30" + /> + + handleMappingChange(e, row, column) + } + /> + + handleMappingChange(e, row, column) + } + /> + + handleMappingChange(e, row, column) + } + /> + + handleMappingChange(e, row, column) + } + /> + handleDelete(signals[index].id)} + /> +
+ +
+ Check/Uncheck All + } + > + + + +
+
+ +
Choose a Component Configuration to add the signal to:
+
+ {typeof configs !== "undefined" && + configs.map((config) => ( + + ))} +
+
+
+
+
+ ); + + return show ? DialogWindow : <>; +}; + +export default EditSignalMappingDialog; diff --git a/src/pages/dashboards/grid/dashboard-button-group.js b/src/pages/dashboards/grid/dashboard-button-group.js new file mode 100644 index 00000000..e97536ed --- /dev/null +++ b/src/pages/dashboards/grid/dashboard-button-group.js @@ -0,0 +1,154 @@ +/** + * This file is part of VILLASweb. + * + * VILLASweb is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * VILLASweb is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with VILLASweb. If not, see . + ******************************************************************************/ + +import React from "react"; +import PropTypes from "prop-types"; +import IconButton from "../../../common/buttons/icon-button"; + +const buttonStyle = { + marginLeft: "12px", + height: "44px", + width: "35px", +}; + +const iconStyle = { + height: "25px", + width: "25px", +}; + +let buttonkey = 0; + +class DashboardButtonGroup extends React.Component { + getBtn(icon, tooltip, clickFn, locked = false) { + if (locked) { + return ( + + ); + } else { + return ( + + ); + } + } + + render() { + const buttons = []; + buttonkey = 0; + + if (this.props.editing) { + buttons.push(this.getBtn("save", "Save changes", this.props.onSave)); + buttons.push( + this.getBtn("times", "Discard changes", this.props.onCancel) + ); + } else { + if (this.props.fullscreen !== true) { + buttons.push( + this.getBtn( + "expand", + "Change to fullscreen view", + this.props.onFullscreen + ) + ); + } else { + buttons.push( + this.getBtn( + "compress", + "Back to normal view", + this.props.onFullscreen + ) + ); + } + + if (this.props.paused) { + buttons.push( + this.getBtn("play", "Continue simulation", this.props.onUnpause) + ); + } else { + buttons.push( + this.getBtn("pause", "Pause simulation", this.props.onPause) + ); + } + + if (this.props.fullscreen !== true) { + let tooltip = this.props.locked + ? "View files of scenario" + : "Add, edit or delete files of scenario"; + buttons.push(this.getBtn("file", tooltip, this.props.onEditFiles)); + buttons.push( + this.getBtn( + "sign-in-alt", + "Add, edit or delete input signals", + this.props.onEditInputSignals, + this.props.locked + ) + ); + buttons.push( + this.getBtn( + "sign-out-alt", + "Add, edit or delete output signals", + this.props.onEditOutputSignals, + this.props.locked + ) + ); + buttons.push( + this.getBtn( + "pen", + "Add widgets and edit layout", + this.props.onEdit, + this.props.locked + ) + ); + } + } + + return
{buttons}
; + } +} + +DashboardButtonGroup.propTypes = { + editing: PropTypes.bool, + fullscreen: PropTypes.bool, + paused: PropTypes.bool, + onEdit: PropTypes.func, + onSave: PropTypes.func, + onCancel: PropTypes.func, + onFullscreen: PropTypes.func, + onPause: PropTypes.func, + onUnpause: PropTypes.func, +}; + +export default DashboardButtonGroup; diff --git a/src/pages/dashboards/dropzone.js b/src/pages/dashboards/grid/dropzone.js similarity index 100% rename from src/pages/dashboards/dropzone.js rename to src/pages/dashboards/grid/dropzone.js diff --git a/src/pages/dashboards/grid.js b/src/pages/dashboards/grid/grid.js similarity index 100% rename from src/pages/dashboards/grid.js rename to src/pages/dashboards/grid/grid.js diff --git a/src/pages/dashboards/widget-area.js b/src/pages/dashboards/grid/widget-area.js similarity index 71% rename from src/pages/dashboards/widget-area.js rename to src/pages/dashboards/grid/widget-area.js index a70fd9c0..63a58910 100644 --- a/src/pages/dashboards/widget-area.js +++ b/src/pages/dashboards/grid/widget-area.js @@ -15,11 +15,11 @@ * along with VILLASweb. If not, see . ******************************************************************************/ -import React from 'react'; -import PropTypes from 'prop-types'; -import Dropzone from './dropzone'; -import Grid from './grid'; -import WidgetFactory from './widget/widget-factory'; +import React from "react"; +import PropTypes from "prop-types"; +import Dropzone from "./dropzone"; +import Grid from "./grid"; +import WidgetFactory from "../widget/widget-factory"; class WidgetArea extends React.Component { snapToGrid(value) { @@ -39,23 +39,24 @@ class WidgetArea extends React.Component { if (this.props.onWidgetAdded != null) { this.props.onWidgetAdded(widget); } - } + }; render() { + return ( + + {this.props.children} - return - {this.props.children} - - - ; + + + ); } } @@ -64,11 +65,11 @@ WidgetArea.propTypes = { editing: PropTypes.bool, grid: PropTypes.number, //widgets: PropTypes.array, - onWidgetAdded: PropTypes.func + onWidgetAdded: PropTypes.func, }; WidgetArea.defaultProps = { - widgets: {} + widgets: {}, }; export default WidgetArea; diff --git a/src/pages/dashboards/hooks/use-dashboard-data.js b/src/pages/dashboards/hooks/use-dashboard-data.js new file mode 100644 index 00000000..6734dd0c --- /dev/null +++ b/src/pages/dashboards/hooks/use-dashboard-data.js @@ -0,0 +1,102 @@ +/** + * This file is part of VILLASweb. + * + * VILLASweb is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * VILLASweb is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with VILLASweb. If not, see . + ******************************************************************************/ + +import { useState, useEffect, useMemo } from "react"; +import { + useLazyGetFilesQuery, + useLazyGetConfigsQuery, + useLazyGetSignalsQuery, + useGetICSQuery, +} from "../../../store/apiSlice"; + +// a custom hook designed to deliver data required for a dashboard and its widgets +export const useDashboardData = (scenarioID) => { + const [files, setFiles] = useState([]); + const [configs, setConfigs] = useState([]); + const [signals, setSignals] = useState([]); + const [activeICS, setActiveICS] = useState([]); + const { data: { ics } = { ics: [] } } = useGetICSQuery(); + + const [triggerGetFiles] = useLazyGetFilesQuery(); + const [triggerGetConfigs] = useLazyGetConfigsQuery(); + const [triggerGetSignals] = useLazyGetSignalsQuery(); + + const fetchDashboardData = async () => { + if (!scenarioID) return; + + //in case of refetching + setFiles([]); + setConfigs([]); + setSignals([]); + + try { + // Fetch files + const filesRes = await triggerGetFiles(scenarioID).unwrap(); + if (filesRes?.files) { + setFiles(filesRes.files); + } + + // Fetch configs and signals + const configsRes = await triggerGetConfigs(scenarioID).unwrap(); + if (configsRes?.configs) { + setConfigs(configsRes.configs); + + for (const config of configsRes.configs) { + const signalsInRes = await triggerGetSignals({ + configID: config.id, + direction: "in", + }).unwrap(); + const signalsOutRes = await triggerGetSignals({ + configID: config.id, + direction: "out", + }).unwrap(); + + setSignals((prev) => [ + ...prev, + ...signalsInRes.signals, + ...signalsOutRes.signals, + ]); + } + } + } catch (err) { + console.error("Error fetching dashboard data:", err); + } + }; + + useEffect(() => { + if (scenarioID) { + fetchDashboardData(); + } + }, [scenarioID]); + + // Derive active ICS based on the fetched configs + useEffect(() => { + let usedICS = []; + for (const config of configs) { + usedICS.push(config.icID); + } + setActiveICS(ics.filter((i) => usedICS.includes(i.id))); + }, [configs]); + + return { + files, + configs, + signals, + activeICS, + refetchDashboardData: fetchDashboardData, + }; +}; diff --git a/src/pages/dashboards/hooks/use-websocket-connection.js b/src/pages/dashboards/hooks/use-websocket-connection.js new file mode 100644 index 00000000..30a9c357 --- /dev/null +++ b/src/pages/dashboards/hooks/use-websocket-connection.js @@ -0,0 +1,86 @@ +/** + * This file is part of VILLASweb. + * + * VILLASweb is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * VILLASweb is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with VILLASweb. If not, see . + ******************************************************************************/ + +import { useEffect, useRef } from "react"; +import { useDispatch } from "react-redux"; +import { connectWebSocket } from "../../../store/websocketSlice"; + +//this hook is used to establish websocket connection in the dashboard +const useWebSocketConnection = (activeICS, signals, widgets) => { + const dispatch = useDispatch(); + const hasConnectedRef = useRef(false); + const startUpdaterWidgets = new Set(["Slider", "Button", "NumberInput"]); + + useEffect(() => { + //we want to connect only once after widgets, signals and activeICs are loaded + if ( + !hasConnectedRef.current && + signals.length > 0 && + activeICS.length > 0 && + widgets.length > 0 + ) { + activeICS.forEach((i) => { + if (i.websocketurl) { + console.log("CONNECTING TO A WEBSOCKET FROM", i); + let inputValues = []; + //read values of all the manipulation widgets and set their values to the corresponding input signals + const inputSignals = [...signals].filter((s) => s.direction == "in"); + if (inputSignals.length > 0) { + //initialize values for input signals + inputValues = new Array(inputSignals.length).fill(0); + //go through all the widgets and check if they have a value for any of the input signals + //add this value to the inputValues array at the index + widgets.forEach((widget) => { + if (startUpdaterWidgets.has(widget.type)) { + const matchingSignal = inputSignals.find((signal) => + widget.signalIDs.includes(signal.id) + ); + if ( + matchingSignal && + !isNaN(matchingSignal.index) && + matchingSignal.index < inputValues.length + ) { + if (widget.type == "Button") { + inputValues[matchingSignal.index] = widget.customProperties + .pressed + ? widget.customProperties.on_value + : widget.customProperties.off_value; + } else { + inputValues[matchingSignal.index] = + widget.customProperties.value; + } + } + } + }); + } + + dispatch( + connectWebSocket({ + url: i.websocketurl, + id: i.id, + inputValues: inputValues, + }) + ); + } + }); + + hasConnectedRef.current = true; + } + }, [activeICS, signals, widgets]); +}; + +export default useWebSocketConnection; diff --git a/src/pages/dashboards/styles.js b/src/pages/dashboards/styles.js new file mode 100644 index 00000000..98e69c78 --- /dev/null +++ b/src/pages/dashboards/styles.js @@ -0,0 +1,21 @@ +export const buttonStyle = { + marginLeft: "5px", +}; + +export const iconStyle = { + height: "25px", + width: "25px", +}; + +export const tableHeadingStyle = { + paddingTop: "30px", +}; + +export const dialogWarningLabel = { + background: "#eb4d2a", + color: "white", +}; + +export const signalDialogCheckButton = { + float: "right", +}; diff --git a/src/pages/dashboards/widget/edit-widget/edit-widget.js b/src/pages/dashboards/widget/edit-widget/edit-widget.js index 5d5a0ea1..c0635821 100644 --- a/src/pages/dashboards/widget/edit-widget/edit-widget.js +++ b/src/pages/dashboards/widget/edit-widget/edit-widget.js @@ -148,9 +148,6 @@ class EditWidgetDialog extends React.Component { } else { customProperty ? changeObject[parts[0]][parts[1]] = e.target.value : changeObject[e.target.id] = e.target.value ; } - - console.log(changeObject) - this.setState({ temporal: changeObject}); } diff --git a/src/pages/dashboards/widget/websocket-store.js b/src/pages/dashboards/widget/websocket-store.js deleted file mode 100644 index 0665cf9f..00000000 --- a/src/pages/dashboards/widget/websocket-store.js +++ /dev/null @@ -1,59 +0,0 @@ -/** - * This file is part of VILLASweb. - * - * VILLASweb is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * VILLASweb is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with VILLASweb. If not, see . - ******************************************************************************/ - -import 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(); diff --git a/src/pages/dashboards/widget/widget-container.js b/src/pages/dashboards/widget/widget-container.js index ff336e14..61ea65e8 100644 --- a/src/pages/dashboards/widget/widget-container.js +++ b/src/pages/dashboards/widget/widget-container.js @@ -15,12 +15,12 @@ * along with VILLASweb. If not, see . ******************************************************************************/ -import React from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import { Rnd } from 'react-rnd'; -import WidgetContextMenu from '../widget/widget-context-menu'; -import {contextMenu} from "react-contexify"; +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { Rnd } from "react-rnd"; +import WidgetContextMenu from "../widget/widget-context-menu.jsx"; +import { contextMenu } from "react-contexify"; class WidgetContainer extends React.Component { constructor(props) { @@ -36,7 +36,7 @@ class WidgetContainer extends React.Component { return Math.round(value / this.props.grid) * this.props.grid; } - borderWasClicked = event => { + borderWasClicked = (event) => { if (event.button !== 2) { return; } @@ -60,11 +60,19 @@ class WidgetContainer extends React.Component { const widget = this.props.widget; // resize depends on direction - if (direction === 'left' || direction === 'topLeft' || direction === 'bottomLeft') { + if ( + direction === "left" || + direction === "topLeft" || + direction === "bottomLeft" + ) { widget.x -= delta.width; } - if (direction === 'top' || direction === 'topLeft' || direction === 'topRight') { + if ( + direction === "top" || + direction === "topLeft" || + direction === "topRight" + ) { widget.y -= delta.height; } @@ -87,16 +95,15 @@ class WidgetContainer extends React.Component { e.preventDefault(); contextMenu.show({ event: e, - id: 'widgetMenu' + index, - }) + id: "widgetMenu" + index, + }); } render() { - const widget = this.props.widget; let contextMenu = ( ) + /> + ); - if ( !this.props.editing ){ + if (!this.props.editing) { const containerStyle = { width: Number(widget.width), height: Number(widget.height), left: Number(widget.x), top: Number(widget.y), zIndex: Number(widget.z), - position: 'absolute' + position: "absolute", }; - return
this.showMenu(e, this.props.index, this.props.editing)}> - {this.props.children} - {contextMenu} -
; + return ( +
+ this.showMenu(e, this.props.index, this.props.editing) + } + > + {this.props.children} + {contextMenu} +
+ ); } let resizingRestricted = false; - if (widget.customProperties.resizeLeftRightLock || widget.customProperties.resizeTopBottomLock) { + if ( + widget.customProperties.resizeLeftRightLock || + widget.customProperties.resizeTopBottomLock + ) { resizingRestricted = true; } @@ -136,40 +155,51 @@ class WidgetContainer extends React.Component { right: !(widget.customProperties.resizeLeftRightLock || widget.isLocked), top: !(widget.customProperties.resizeTopBottomLock || widget.isLocked), topLeft: !(resizingRestricted || widget.isLocked), - topRight: !(resizingRestricted || widget.isLocked) + topRight: !(resizingRestricted || widget.isLocked), }; const gridArray = [this.props.grid, this.props.grid]; const widgetClasses = classNames({ - 'editing-widget': true, - 'locked': widget.isLocked + "editing-widget": true, + locked: widget.isLocked, }); - return (
this.showMenu(e, this.props.index, this.props.editing)}> - { this.rnd = c; }} - size={{width: Number(widget.width), height: Number(widget.height)}} - position={{x: Number(widget.x), y: Number(widget.y)}} - minWidth={widget.minWidth} - minHeight={widget.minHeight} - lockAspectRatio={Boolean(widget.customProperties.lockAspect)} - bounds={'.toolbox-dropzone'} - className={widgetClasses} - onResizeStart={this.borderWasClicked} - onResizeStop={this.resizeStop} - onDragStop={this.dragStop} - dragGrid={gridArray} - resizeGrid={gridArray} - enableResizing={resizing} - disableDragging={widget.isLocked} + return ( +
+ this.showMenu(e, this.props.index, this.props.editing) + } > - {this.props.children} - + { + this.rnd = c; + }} + size={{ width: Number(widget.width), height: Number(widget.height) }} + position={{ x: Number(widget.x), y: Number(widget.y) }} + minWidth={widget.minWidth} + minHeight={widget.minHeight} + lockAspectRatio={Boolean(widget.customProperties.lockAspect)} + bounds={".toolbox-dropzone"} + className={widgetClasses} + onResizeStart={this.borderWasClicked} + onResizeStop={this.resizeStop} + onDragStop={this.dragStop} + dragGrid={gridArray} + resizeGrid={gridArray} + enableResizing={resizing} + disableDragging={widget.isLocked} + > + {this.props.children} + - {contextMenu} -
); + {contextMenu} +
+ ); } } @@ -182,4 +212,4 @@ WidgetContainer.propTypes = { editing: PropTypes.bool.isRequired, }; -export default WidgetContainer +export default WidgetContainer; diff --git a/src/pages/dashboards/widget/widget-context-menu.js b/src/pages/dashboards/widget/widget-context-menu.js deleted file mode 100644 index 51d96e85..00000000 --- a/src/pages/dashboards/widget/widget-context-menu.js +++ /dev/null @@ -1,148 +0,0 @@ -/** - * This file is part of VILLASweb. - * - * VILLASweb is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * VILLASweb is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with VILLASweb. If not, see . - ******************************************************************************/ - -import React from 'react'; -import ReactDOM from 'react-dom'; -import PropTypes from 'prop-types'; -import { Menu, Item, Separator } from 'react-contexify'; -import "react-contexify/dist/ReactContexify.css"; - -class WidgetContextMenu extends React.Component { - - editWidget = event => { - if (this.props.onEdit != null) { - this.props.onEdit(this.props.widget, this.props.index); - } - }; - - duplicateWidget = event => { - if (this.props.onDuplicate != null) { - this.props.onDuplicate(this.props.widget); - } - }; - - deleteWidget = event => { - if (this.props.onDelete != null) { - this.props.onDelete(this.props.widget, this.props.index); - } - }; - - moveAbove = event => { - this.props.widget.z += 10; - if (this.props.widget.z > 100) { - this.props.widget.z = 100; - } - - if (this.props.onChange != null) { - this.props.onChange(this.props.widget, this.props.index); - } - }; - - moveToFront = event => { - this.props.widget.z = 100; - - if (this.props.onChange != null) { - this.props.onChange(this.props.widget, this.props.index); - } - }; - - moveUnderneath = event => { - this.props.widget.z -= 10; - if (this.props.widget.z < 0) { - this.props.widget.z = 0; - } - - if (this.props.onChange != null) { - this.props.onChange(this.props.widget, this.props.index); - } - }; - - moveToBack = event => { - this.props.widget.z = 0; - - if (this.props.onChange != null) { - this.props.onChange(this.props.widget, this.props.index); - } - }; - - lockWidget = event => { - this.props.widget.isLocked = true; - - if (this.props.onChange != null) { - this.props.onChange(this.props.widget, this.props.index); - } - }; - - unlockWidget = event => { - this.props.widget.isLocked = false; - - if (this.props.onChange != null) { - this.props.onChange(this.props.widget, this.props.index); - } - }; - - - renderContextMenu() { - const isLocked = this.props.widget.locked; - - let dim = { - width: '100%', - height: '100%' - }; - - return ( -
- - Edit - Duplicate - Delete - - - - Move above - Move to front - Move underneath - Move to back - - - - Lock - Unlock - -
- ) - } - - - render() { - - return ReactDOM.createPortal( - this.renderContextMenu(), - document.body - ); - } -} - -WidgetContextMenu.propTypes = { - index: PropTypes.number.isRequired, - widget: PropTypes.object.isRequired, - onEdit: PropTypes.func.isRequired, - onDelete: PropTypes.func.isRequired, - onChange: PropTypes.func.isRequired -}; - -export default WidgetContextMenu; diff --git a/src/pages/dashboards/widget/widget-context-menu.jsx b/src/pages/dashboards/widget/widget-context-menu.jsx new file mode 100644 index 00000000..88af3b57 --- /dev/null +++ b/src/pages/dashboards/widget/widget-context-menu.jsx @@ -0,0 +1,144 @@ +/** + * This file is part of VILLASweb. + * + * VILLASweb is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * VILLASweb is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with VILLASweb. If not, see . + ******************************************************************************/ + +import React from "react"; +import ReactDOM from "react-dom"; +import PropTypes from "prop-types"; +import { Menu, Item, Separator } from "react-contexify"; +import "react-contexify/dist/ReactContexify.css"; + +const WidgetContextMenu = ({ + index, + widget, + onEdit, + onDuplicate, + onDelete, + onChange, +}) => { + const editWidget = () => { + if (onEdit) { + onEdit(widget, index); + } + }; + + const duplicateWidget = () => { + if (onDuplicate) { + onDuplicate(widget); + } + }; + + const deleteWidget = () => { + if (onDelete) { + onDelete(widget, index); + } + }; + + const moveAbove = () => { + const updatedWidget = { ...widget, z: Math.min(widget.z + 10, 100) }; + if (onChange) { + onChange(updatedWidget, index); + } + }; + + const moveToFront = () => { + const updatedWidget = { ...widget, z: 100 }; + if (onChange) { + onChange(updatedWidget, index); + } + }; + + const moveUnderneath = () => { + const updatedWidget = { ...widget, z: Math.max(widget.z - 10, 0) }; + if (onChange) { + onChange(updatedWidget, index); + } + }; + + const moveToBack = () => { + const updatedWidget = { ...widget, z: 0 }; + if (onChange) { + onChange(updatedWidget, index); + } + }; + + const lockWidget = () => { + const updatedWidget = { ...widget, isLocked: true }; + if (onChange) { + onChange(updatedWidget, index); + } + }; + + const unlockWidget = () => { + const updatedWidget = { ...widget, isLocked: false }; + if (onChange) { + onChange(updatedWidget, index); + } + }; + + const renderContextMenu = () => ( +
+ + + Edit + + + Duplicate + + + Delete + + + + + + Move above + + + Move to front + + + Move underneath + + + Move to back + + + + + + Lock + + + Unlock + + +
+ ); + + return ReactDOM.createPortal(renderContextMenu(), document.body); +}; + +WidgetContextMenu.propTypes = { + index: PropTypes.number.isRequired, + widget: PropTypes.object.isRequired, + onEdit: PropTypes.func.isRequired, + onDuplicate: PropTypes.func, + onDelete: PropTypes.func, + onChange: PropTypes.func.isRequired, +}; + +export default WidgetContextMenu; diff --git a/src/pages/dashboards/widget/widget-factory.js b/src/pages/dashboards/widget/widget-factory.js deleted file mode 100644 index 213226ce..00000000 --- a/src/pages/dashboards/widget/widget-factory.js +++ /dev/null @@ -1,248 +0,0 @@ -/** - * This file is part of VILLASweb. - * - * VILLASweb is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * VILLASweb is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with VILLASweb. If not, see . - ******************************************************************************/ - -//import WidgetSlider from './widgets/slider'; - -class WidgetFactory { - - static createWidgetOfType(type, position) { - - let widget = { - name: 'Name', - type: type, - width: 100, - height: 100, - x: position.x, - y: position.y, - z: position.z, - locked: false, - customProperties: {}, - signalIDs: [], - }; - - // set type specific properties - switch(type) { - case 'CustomAction': - widget.customProperties.actions = [ - { - action: 'stop' - }, - { - action: 'pause', - model: { - url: 'ftp://user:pass@example.com/projectA/model.zip' - }, - parameters: { - timestep: 50e-6 - } - } - ]; - widget.name = 'Action'; - widget.customProperties.icon = 'star'; - widget.width = 100; - widget.height = 50; - break; - case 'Action': - break; - case 'Lamp': - widget.minWidth = 5; - widget.minHeight = 5; - widget.width = 20; - widget.height = 20; - widget.customProperties.on_color = '#4287f5'; - widget.customProperties.on_color_opacity = 1; - widget.customProperties.off_color = '#4287f5'; - widget.customProperties.off_color_opacity = 1; - widget.customProperties.threshold = 0.5; - break; - case 'Value': - widget.minWidth = 70; - widget.minHeight = 20; - widget.width = 110; - widget.height = 30; - widget.customProperties.textSize = 16; - widget.name = 'Value'; - widget.customProperties.showUnit = false; - widget.customProperties.resizeTopBottomLock = true; - widget.customProperties.showScalingFactor = true; - break; - case 'Plot': - widget.customProperties.ylabel = ''; - widget.customProperties.time = 60; - widget.minWidth = 400; - widget.minHeight = 200; - widget.width = 400; - widget.height = 200; - widget.customProperties.yMin = 0; - widget.customProperties.yMax = 10; - widget.customProperties.yUseMinMax = false; - widget.customProperties.lineColors = []; - widget.customProperties.showUnit = false; - widget.customProperties.mode = 'auto time-scrolling'; - widget.customProperties.nbrSamples = 100; - break; - case 'Table': - widget.minWidth = 200; - widget.width = 300; - widget.height = 200; - widget.customProperties.showUnit = false; - widget.customProperties.showScalingFactor = true; - break; - case 'Label': - widget.minWidth = 20; - widget.minHeight = 20; - widget.customProperties.maxWidth = 100; // TODO: this props is currently ignored! - widget.width = 100; - widget.height = 35; - widget.name = 'Label'; - widget.customProperties.textSize = 32; - widget.customProperties.fontColor = '#4287f5'; - widget.customProperties.fontColor_opacity = 1; - widget.customProperties.resizeTopBottomLock = true; - break; - case 'Image': - widget.minWidth = 20; - widget.minHeight = 20; - widget.width = 200; - widget.height = 200; - widget.customProperties.lockAspect = true; - widget.customProperties.file = -1; // ID of image file, -1 means non selected - widget.customProperties.update = false; - break; - case 'Button': - widget.minWidth = 100; - widget.minHeight = 50; - widget.width = 100; - widget.height = 100; - 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; - widget.customProperties.pressed = false; - widget.customProperties.simStartedSendValue = false; - break; - case 'NumberInput': - widget.minWidth = 150; - widget.minHeight = 50; - widget.width = 200; - widget.height = 50; - widget.customProperties.showUnit = false; - widget.customProperties.resizeTopBottomLock = true; - widget.customProperties.value = ''; - widget.customProperties.simStartedSendValue = false; - break; - // case 'Slider': - // widget.minWidth = 380; - // widget.minHeight = 30; - // widget.width = 400; - // widget.height = 50; - // widget.customProperties.orientation = WidgetSlider.OrientationTypes.HORIZONTAL.value; // Assign default orientation - // widget.customProperties.rangeMin = 0; - // widget.customProperties.rangeMax = 200; - // widget.customProperties.rangeUseMinMax = true; - // widget.customProperties.showUnit = false; - // widget.customProperties.continous_update = false; - // widget.customProperties.value = ''; - // widget.customProperties.resizeLeftRightLock = false; - // widget.customProperties.resizeTopBottomLock = true; - // widget.customProperties.simStartedSendValue = false; - - // break; - case 'Gauge': - widget.minWidth = 100; - widget.minHeight = 150; - widget.width = 150; - widget.height = 150; - widget.customProperties.colorZones = false; - widget.customProperties.zones = []; - widget.customProperties.valueMin = 0; - widget.customProperties.valueMax = 1; - widget.customProperties.valueUseMinMax = false; - widget.customProperties.lockAspect = true; - widget.customProperties.showScalingFactor = true; - break; - case 'Box': - widget.minWidth = 50; - widget.minHeight = 50; - widget.width = 100; - widget.height = 100; - widget.customProperties.border_color = '#4287f5'; - widget.customProperties.border_color_opacity = 1; - widget.customProperties.border_width = 2; - widget.customProperties.background_color = '#961520'; - widget.customProperties.background_color_opacity = 1; - widget.z = 0; - break; - /*case 'HTML': - widget.customProperties.content = 'Hello World'; - break;*/ - case 'Topology': - widget.width = 600; - widget.height = 400; - widget.customProperties.file = -1; // ID of file, -1 means non selected - break; - case 'Line': - widget.height = 26; - widget.width = 100; - widget.customProperties.border_color = '#4287f5'; - widget.customProperties.border_color_opacity = 1; - widget.customProperties.border_width = 2; - widget.customProperties.rotation = 0; - widget.customProperties.lockAspect = false; - widget.customProperties.resizeLeftRightLock = false; - widget.customProperties.resizeTopBottomLock = true; - break; - - case 'TimeOffset': - widget.minWidth = 200; - widget.minHeight = 80; - widget.width = 200; - widget.height = 80; - 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; - - case 'Player': - widget.minWidth = 144; - widget.minHeight = 226; - widget.width = 400; - widget.height = 606; - widget.customProperties.configIDs = []; - widget.customProperties.uploadResults = false; - break; - - case 'ICstatus': - widget.customProperties.checkedIDs = [] - break; - - default: - widget.width = 100; - widget.height = 100; - } - return widget; - } -} - -export default WidgetFactory; diff --git a/src/pages/dashboards/widget/widget-factory.jsx b/src/pages/dashboards/widget/widget-factory.jsx new file mode 100644 index 00000000..a3bb5947 --- /dev/null +++ b/src/pages/dashboards/widget/widget-factory.jsx @@ -0,0 +1,245 @@ +/** + * 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 . + ******************************************************************************/ + +const widgetsMap = { + CustomAction: { + name: "Action", + width: 100, + height: 50, + customProperties: { + actions: [ + { action: "stop" }, + { + action: "pause", + model: { url: "ftp://user:pass@example.com/projectA/model.zip" }, + parameters: { timestep: 50e-6 }, + }, + ], + icon: "star", + }, + }, + Lamp: { + minWidth: 5, + minHeight: 5, + width: 20, + height: 20, + customProperties: { + on_color: "#4287f5", + on_color_opacity: 1, + off_color: "#4287f5", + off_color_opacity: 1, + threshold: 0.5, + }, + }, + Value: { + name: "Value", + minWidth: 70, + minHeight: 20, + width: 110, + height: 30, + customProperties: { + textSize: 16, + showUnit: false, + resizeTopBottomLock: true, + showScalingFactor: true, + }, + }, + Plot: { + minWidth: 400, + minHeight: 200, + width: 400, + height: 200, + customProperties: { + ylabel: "", + time: 60, + yMin: 0, + yMax: 10, + yUseMinMax: false, + lineColors: [], + showUnit: false, + mode: "auto time-scrolling", + nbrSamples: 100, + }, + }, + Table: { + minWidth: 200, + width: 300, + height: 200, + customProperties: { + showUnit: false, + showScalingFactor: true, + }, + }, + Label: { + name: "Label", + minWidth: 20, + minHeight: 20, + width: 100, + height: 35, + customProperties: { + maxWidth: 100, // Currently ignored + textSize: 32, + fontColor: "#4287f5", + fontColor_opacity: 1, + resizeTopBottomLock: true, + }, + }, + Image: { + minWidth: 20, + minHeight: 20, + width: 200, + height: 200, + customProperties: { + lockAspect: true, + file: -1, // ID of image file, -1 means not selected + update: false, + }, + }, + Button: { + minWidth: 100, + minHeight: 50, + width: 100, + height: 100, + customProperties: { + background_color: "#527984", + font_color: "#4287f5", + border_color: "#4287f5", + background_color_opacity: 1, + on_value: 1, + off_value: 0, + toggle: false, + pressed: false, + simStartedSendValue: false, + }, + }, + NumberInput: { + minWidth: 150, + minHeight: 50, + width: 200, + height: 50, + customProperties: { + showUnit: false, + resizeTopBottomLock: true, + value: "", + simStartedSendValue: false, + }, + }, + Gauge: { + minWidth: 100, + minHeight: 150, + width: 150, + height: 150, + customProperties: { + colorZones: false, + zones: [], + valueMin: 0, + valueMax: 1, + valueUseMinMax: false, + lockAspect: true, + showScalingFactor: true, + }, + }, + Box: { + minWidth: 50, + minHeight: 50, + width: 100, + height: 100, + customProperties: { + border_color: "#4287f5", + border_color_opacity: 1, + border_width: 2, + background_color: "#961520", + background_color_opacity: 1, + }, + }, + Topology: { + width: 600, + height: 400, + customProperties: { + file: -1, // ID of file, -1 means not selected + }, + }, + Line: { + width: 100, + height: 26, + customProperties: { + border_color: "#4287f5", + border_color_opacity: 1, + border_width: 2, + rotation: 0, + lockAspect: false, + resizeLeftRightLock: false, + resizeTopBottomLock: true, + }, + }, + TimeOffset: { + minWidth: 200, + minHeight: 80, + width: 200, + height: 80, + customProperties: { + threshold_yellow: 1, + threshold_red: 2, + icID: -1, + horizontal: true, + showOffset: true, + lockAspect: true, + showName: true, + }, + }, + Player: { + minWidth: 144, + minHeight: 226, + width: 144, + height: 226, + customProperties: { + configIDs: [], + uploadResults: false, + }, + }, + ICstatus: { + customProperties: { + checkedIDs: [], + }, + }, +}; + +const defaultWidget = { + name: "Name", + width: 100, + height: 100, + x: 0, + y: 0, + z: 0, + locked: false, + customProperties: {}, + signalIDs: [], +}; + +const WidgetFactory = { + createWidgetOfType: (type, position) => { + const widget = { + ...defaultWidget, + type: type, + ...widgetsMap[type], + ...position, + }; + return widget; + }, +}; + +export default WidgetFactory; diff --git a/src/pages/dashboards/widget/widget-store.js b/src/pages/dashboards/widget/widget-store.js deleted file mode 100644 index d350691d..00000000 --- a/src/pages/dashboards/widget/widget-store.js +++ /dev/null @@ -1,95 +0,0 @@ -/** - * This file is part of VILLASweb. - * - * VILLASweb is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * VILLASweb is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with VILLASweb. If not, see . - ******************************************************************************/ - - -import ArrayStore from '../common/array-store'; -import WidgetsDataManager from './widgets-data-manager'; - -class WidgetStore extends ArrayStore { - constructor() { - super('widgets', WidgetsDataManager); - } - - reduce(state, action) { - switch (action.type) { - - case 'widgets/loaded': - - //WidgetsDataManager.loadFiles(action.token, action.data); - // TODO make sure files of scenario are loaded - return super.reduce(state, action); - - case 'widgets/signal-value-changed': - // update all widgets in widget store that use the current signal - - if (action.values.length === 0 || action.signalID === 0){ - console.log("WARNING: attempt to update widget signal value(s) on loopback, " + - "but no value(s) or invalid signal ID provided"); - return; - } - - state.forEach(function (widget, index) { - - if (!widget.hasOwnProperty("signalIDs")){ - return; - } - - for(let i = 0; i 1){ - // widget uses array of signals, save complete array - widget.customProperties.value = action.values - } else { - console.log("WARNING: attempt tp update widget signal value(s) on loopback, " + - "but incompatible values (type or length of array) provided"); - } - - } else { - console.log("WARNING: attempt tp update widget signal value(s) on loopback, " + - "but affected widget (ID=", widget.id, ") does not have customProperties.value field"); - } - } else { - console.log("WARNING: attempt tp update widget signal value(s) on loopback, " + - "but affected widget (ID=", widget.id, ") does not have customProperties field"); - } - } // if signal found - } // for - }) - - // explicit call to prevent array copy - this.__emitChange(); - - return state; - - default: - return super.reduce(state, action); - - } - } - -} - -export default new WidgetStore(); diff --git a/src/pages/dashboards/widget/widget.js b/src/pages/dashboards/widget/widget.js deleted file mode 100644 index 6f86f547..00000000 --- a/src/pages/dashboards/widget/widget.js +++ /dev/null @@ -1,192 +0,0 @@ -/** - * This file is part of VILLASweb. - * - * VILLASweb is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * VILLASweb is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with VILLASweb. If not, see . - ******************************************************************************/ - -import React from 'react'; -import { useState, useEffect } from 'react'; -import { useSelector, useDispatch } from 'react-redux'; -import WidgetLabel from './widgets/label'; -import WidgetLine from './widgets/line'; -import WidgetBox from './widgets/box'; -import WidgetImage from './widgets/image'; -import WidgetPlot from './widgets/plot'; -import WidgetTable from './widgets/table'; -import WidgetValue from './widgets/value'; -import WidgetLamp from './widgets/lamp'; -import WidgetGauge from './widgets/gauge'; -import WidgetTimeOffset from './widgets/time-offset'; -import WidgetICstatus from './widgets/icstatus'; -// import WidgetCustomAction from './widgets/custom-action'; -// import WidgetAction from './widgets/action'; -import WidgetButton from './widgets/button'; -import WidgetInput from './widgets/input'; -import WidgetSlider from './widgets/slider'; -// import WidgetTopology from './widgets/topology'; -import WidgetPlayer from './widgets/player'; -//import WidgetHTML from './widgets/html'; -import '../../../styles/widgets.css'; -import { useGetICSQuery, useGetSignalsQuery, useGetConfigsQuery } from '../../../store/apiSlice'; -import { sessionToken } from '../../../localStorage'; -import { useUpdateWidgetMutation } from '../../../store/apiSlice'; -import { sendMessageToWebSocket } from '../../../store/websocketSlice'; -import { useGetResultsQuery } from '../../../store/apiSlice'; - -const Widget = ({widget, editing, files, configs, signals, paused, ics, scenarioID, onSimulationStarted}) => { - const dispatch = useDispatch(); - const { token: sessionToken } = useSelector((state) => state.auth); - const {data, refetch: refetchResults } = useGetResultsQuery(scenarioID); - const results = data ? data.results : []; - const [icIDs, setICIDs] = useState([]); - - const icdata = useSelector((state) => state.websocket.icdata); - - const [websockets, setWebsockets] = useState([]); - const activeSocketURLs = useSelector((state) => state.websocket.activeSocketURLs); - const [update] = useUpdateWidgetMutation(); - - useEffect(() => { - if(activeSocketURLs.length > 0){ - activeSocketURLs.forEach(url => { - setWebsockets(prevState=>([...prevState, { url: url.replace(/^wss:\/\//, "https://"), connected:true}])) - }) - } - }, [activeSocketURLs]) - - useEffect(() => { - if(signals.length > 0){ - let ids = []; - - for (let id of widget.signalIDs){ - let signal = signals.find(s => s.id === id); - if (signal !== undefined) { - let config = configs.find(m => m.id === signal.configID); - if (config !== undefined){ - ids[signal.id] = config.icID; - } - } - } - - setICIDs(ids); - } - }, [signals]) - - const inputDataChanged = (widget, data, controlID, controlValue, isFinalChange) => { - if (controlID !== '' && isFinalChange) { - let updatedWidget = JSON.parse(JSON.stringify(widget)); - updatedWidget.customProperties[controlID] = controlValue; - - updateWidget(updatedWidget); - } - - let signalID = widget.signalIDs[0]; - let signal = signals.filter(s => s.id === signalID) - if (signal.length === 0){ - console.warn("Unable to send signal for signal ID", signalID, ". Signal not found."); - return; - } - // determine ID of infrastructure component related to signal[0] - // Remark: there is only one selected signal for an input type widget - let icID = icIDs[signal[0].id]; - dispatch(sendMessageToWebSocket({message: {ic: icID, signalID: signal[0].id, signalIndex: signal[0].index, data: signal[0].scalingFactor * data}})); - } - - const updateWidget = async (updatedWidget) => { - try { - await update({ widgetID: widget.id, updatedWidget: { widget: updatedWidget } }).unwrap(); - } catch (err) { - console.log('error', err); - } - } - - - if (widget.type === 'Line') { - return ; - } else if (widget.type === 'Box') { - return ; - } else if (widget.type === 'Label') { - return ; - } else if (widget.type === 'Image') { - return ; - } else if (widget.type === 'Plot') { - return ; - } else if (widget.type === 'Table') { - return ; - } else if (widget.type === 'Value') { - return ; - } else if (widget.type === 'Lamp') { - return ; - } else if (widget.type === 'Gauge') { - return ; - } else if (widget.type === 'TimeOffset') { - return ; - } else if (widget.type === 'ICstatus') { - return ; - } else if (widget.type === 'Button') { - return ( - - inputDataChanged(widget, value, controlID, controlValue, isFinalChange) - } - signals={signals} - token={sessionToken} - /> - ); - } else if (widget.type === 'NumberInput') { - return ( - - inputDataChanged(widget, value, controlID, controlValue, isFinalChange) - } - signals={signals} - token={sessionToken} - /> - ); - } else if (widget.type === 'Slider') { - return ( - - inputDataChanged(widget, value, controlID, controlValue, isFinalChange) - } - signals={signals} - token={sessionToken} - /> - ); - } else if (widget.type === 'Player') { - return ( - - ); - } else { - console.log('Unknown widget type', widget.type); - return
Error: Widget not found!
; - } -} - -export default Widget; diff --git a/src/pages/dashboards/widget/widget.jsx b/src/pages/dashboards/widget/widget.jsx new file mode 100644 index 00000000..2ea606d3 --- /dev/null +++ b/src/pages/dashboards/widget/widget.jsx @@ -0,0 +1,240 @@ +/** + * This file is part of VILLASweb. + * + * VILLASweb is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * VILLASweb is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with VILLASweb. If not, see . + ******************************************************************************/ + +import React from "react"; +import { useState, useEffect } from "react"; +import { useSelector, useDispatch } from "react-redux"; +import WidgetLabel from "./widgets/label.jsx"; +import WidgetLine from "./widgets/line.jsx"; +import WidgetBox from "./widgets/box.jsx"; +import WidgetImage from "./widgets/image.jsx"; +import WidgetPlot from "./widgets/plot.jsx"; +import WidgetTable from "./widgets/table.jsx"; +import WidgetValue from "./widgets/value.jsx"; +import WidgetLamp from "./widgets/lamp.jsx"; +import WidgetGauge from "./widgets/gauge.jsx"; +import WidgetTimeOffset from "./widgets/time-offset.jsx"; +import WidgetICstatus from "./widgets/icstatus.jsx"; +import WidgetButton from "./widgets/button.jsx"; +import WidgetInput from "./widgets/input.jsx"; +import WidgetSlider from "./widgets/slider.jsx"; +import WidgetPlayer from "./widgets/player.jsx"; +import "../../../styles/widgets.css"; +import { useUpdateWidgetMutation } from "../../../store/apiSlice.js"; +import { sendMessageToWebSocket } from "../../../store/websocketSlice.js"; +import { useGetResultsQuery } from "../../../store/apiSlice.js"; + +const Widget = ({ + widget, + editing, + files, + configs, + signals, + paused, + ics, + scenarioID, + onSimulationStarted, +}) => { + const dispatch = useDispatch(); + const { token: sessionToken } = useSelector((state) => state.auth); + const { data, refetch: refetchResults } = useGetResultsQuery(scenarioID); + const results = data ? data.results : []; + const [icIDs, setICIDs] = useState([]); + + const icdata = useSelector((state) => state.websocket.icdata); + + const [websockets, setWebsockets] = useState([]); + const activeSocketURLs = useSelector( + (state) => state.websocket.activeSocketURLs + ); + const [update] = useUpdateWidgetMutation(); + + useEffect(() => { + if (activeSocketURLs.length > 0) { + activeSocketURLs.forEach((url) => { + setWebsockets((prevState) => [ + ...prevState, + { url: url.replace(/^wss:\/\//, "https://"), connected: true }, + ]); + }); + } + }, [activeSocketURLs]); + + useEffect(() => { + if (signals.length > 0) { + let ids = []; + + for (let id of widget.signalIDs) { + let signal = signals.find((s) => s.id === id); + if (signal !== undefined) { + let config = configs.find((m) => m.id === signal.configID); + if (config !== undefined) { + ids[signal.id] = config.icID; + } + } + } + + setICIDs(ids); + } + }, [signals]); + + const inputDataChanged = (value, controlID, controlValue, isFinalChange) => { + if (controlID !== "" && isFinalChange) { + let updatedWidget = JSON.parse(JSON.stringify(widget)); + updatedWidget.customProperties[controlID] = controlValue; + + updateWidget(updatedWidget); + } + + let signalID = widget.signalIDs[0]; + let signal = signals.filter((s) => s.id === signalID); + if (signal.length === 0) { + console.warn( + "Unable to send signal for signal ID", + signalID, + ". Signal not found." + ); + return; + } + // determine ID of infrastructure component related to signal[0] + // Remark: there is only one selected signal for an input type widget + let icID = icIDs[signal[0].id]; + dispatch( + sendMessageToWebSocket({ + message: { + ic: icID, + signalID: signal[0].id, + signalIndex: signal[0].index, + data: signal[0].scalingFactor * value, + }, + }) + ); + }; + + const updateWidget = async (updatedWidget) => { + try { + await update({ + widgetID: widget.id, + updatedWidget: { widget: updatedWidget }, + }).unwrap(); + } catch (err) { + console.log("error", err); + } + }; + + const widgetMap = { + Line: , + Box: , + Label: , + Image: , + Plot: ( + + ), + Table: ( + + ), + Value: ( + + ), + Lamp: ( + + ), + Gauge: ( + + ), + TimeOffset: ( + + ), + ICstatus: , + Button: ( + + ), + NumberInput: ( + + ), + Slider: ( + + ), + Player: ( + + ), + }; + + return widgetMap[widget.type] ||
Error: Widget not found!
; +}; + +export default Widget; diff --git a/src/pages/dashboards/widget/widgets/button.jsx b/src/pages/dashboards/widget/widgets/button.jsx index 2c285f4d..bf822b77 100644 --- a/src/pages/dashboards/widget/widgets/button.jsx +++ b/src/pages/dashboards/widget/widgets/button.jsx @@ -15,76 +15,66 @@ * along with VILLASweb. If not, see . ******************************************************************************/ -import React, { useState, useEffect } from 'react'; -import { Button } from 'react-bootstrap'; +import React, { useState, useEffect, useRef } from "react"; +import { Button } from "react-bootstrap"; +import { useUpdateWidgetMutation } from "../../../../store/apiSlice"; +const WidgetButton = ({ widget, editing, onInputChanged }) => { + const [pressed, setPressed] = useState(widget.customProperties.pressed); + const [toggle, setToggle] = useState(widget.customProperties.toggle); + const [updateWidget] = useUpdateWidgetMutation(); -const WidgetButton = (props) => { - const [pressed, setPressed] = useState(props.widget.customProperties.pressed); + //this ref is used for capturing last value of pressed so that it can be saved and sent on unmount + const pressedRef = useRef(pressed); useEffect(() => { - let widget = props.widget; - widget.customProperties.simStartedSendValue = false; - widget.customProperties.pressed = false; - - // AppDispatcher.dispatch({ - // type: 'widgets/start-edit', - // token: props.token, - // data: widget - // }); - - // Effect cleanup return () => { - // Clean up if needed + //if button is in toggle-mode, we want to save its pressed state for future reloads of dashboard + if (toggle) updateSimStartedAndPressedValues(false, pressedRef.current); }; - }, [props.token, props.widget]); - - useEffect(() => { - if (props.widget.customProperties.simStartedSendValue) { - let widget = props.widget; - widget.customProperties.simStartedSendValue = false; - widget.customProperties.pressed = false; - AppDispatcher.dispatch({ - type: 'widgets/start-edit', - token: props.token, - data: widget - }); - - props.onInputChanged(widget.customProperties.off_value, '', false, false); - } - }, [props, setPressed]); + }, []); useEffect(() => { - setPressed(props.widget.customProperties.pressed); - }, [props.widget.customProperties.pressed]); + setToggle(widget.customProperties.toggle); + }, [widget]); - const onPress = (e) => { - if (e.button === 0 && !props.widget.customProperties.toggle) { - valueChanged(props.widget.customProperties.on_value, true); + const updateSimStartedAndPressedValues = async (isSimStarted, isPressed) => { + try { + await updateWidget({ + widgetID: widget.id, + updatedWidget: { + widget: { + ...widget, + customProperties: { + ...widget.customProperties, + simStartedSendValue: isSimStarted, + pressed: isPressed, + }, + }, + }, + }).unwrap(); + } catch (err) { + console.log("error", err); } }; - const onRelease = (e) => { - if (e.button === 0) { - let nextState = false; - if (props.widget.customProperties.toggle) { - nextState = !pressed; - } - valueChanged(nextState ? props.widget.customProperties.on_value : props.widget.customProperties.off_value, nextState); - } - }; + useEffect(() => { + pressedRef.current = pressed; - const valueChanged = (newValue, newPressed) => { - if (props.onInputChanged) { - props.onInputChanged(newValue, 'pressed', newPressed, true); - } - setPressed(newPressed); - }; + onInputChanged( + pressed + ? widget.customProperties.on_value + : widget.customProperties.off_value, + "", + false, + false + ); + }, [pressed]); - let opacity = props.widget.customProperties.background_color_opacity; + let opacity = widget.customProperties.background_color_opacity; const buttonStyle = { - backgroundColor: props.widget.customProperties.background_color, - borderColor: props.widget.customProperties.border_color, - color: props.widget.customProperties.font_color, + backgroundColor: widget.customProperties.background_color, + borderColor: widget.customProperties.border_color, + color: widget.customProperties.font_color, opacity: pressed ? (opacity + 1) / 4 : opacity, }; @@ -94,11 +84,16 @@ const WidgetButton = (props) => { className="full" style={buttonStyle} active={pressed} - disabled={props.editing} - onMouseDown={(e) => onPress(e)} - onMouseUp={(e) => onRelease(e)} + disabled={editing} + onMouseDown={(e) => { + if (!toggle) setPressed(true); + else setPressed(!pressed); + }} + onMouseUp={(e) => { + if (!toggle) setPressed(false); + }} > - {props.widget.name} + {widget.name}
); diff --git a/src/pages/dashboards/widget/widgets/icstatus.jsx b/src/pages/dashboards/widget/widgets/icstatus.jsx index 943c4187..a1b3c336 100644 --- a/src/pages/dashboards/widget/widgets/icstatus.jsx +++ b/src/pages/dashboards/widget/widgets/icstatus.jsx @@ -16,40 +16,48 @@ ******************************************************************************/ import React, { useState, useEffect } from "react"; -import { Badge } from "react-bootstrap"; -import {stateLabelStyle} from "../../../infrastructure/styles"; +import { Badge, Spinner } from "react-bootstrap"; +import { stateLabelStyle } from "../../../infrastructure/styles"; +import { useDispatch } from "react-redux"; +import { useLazyGetICbyIdQuery } from "../../../../store/apiSlice"; +let timer = null; const WidgetICstatus = (props) => { - const [sessionToken, setSessionToken] = useState( - localStorage.getItem("token") - ); + const dispatch = useDispatch(); + const [ics, setIcs] = useState(props.ics); + const [triggerGetICbyId] = useLazyGetICbyIdQuery(); + const refresh = async () => { + if (props.ics) { + try { + const requests = props.ics.map((ic) => + triggerGetICbyId(ic.id).unwrap() + ); - useEffect(() => { - // Function to refresh data - const refresh = () => { - if (props.ics) { - props.ics.forEach((ic) => { - let icID = parseInt(ic.id, 10); - }); + const results = await Promise.all(requests); + setIcs(results); + } catch (error) { + console.error("Error loading ICs:", error); } - }; + } + }; + useEffect(() => { + window.clearInterval(timer); + timer = window.setInterval(refresh, 3000); - // Start timer for periodic refresh - const timer = window.setInterval(() => refresh(), 3000); + refresh(); - // Cleanup function equivalent to componentWillUnmount return () => { window.clearInterval(timer); }; - }, [props.ics, sessionToken]); + }, [props.ics]); let badges = []; let checkedICs = props.widget ? props.widget.customProperties.checkedIDs : []; if (props.ics && checkedICs) { - badges = props.ics - .filter((ic) => checkedICs.includes(ic.id)) - .map((ic) => { + badges = ics + .filter(({ ic }) => checkedICs.includes(ic?.id)) + .map(({ ic }) => { let badgeStyle = stateLabelStyle(ic.state, ic); return ( @@ -59,7 +67,7 @@ const WidgetICstatus = (props) => { }); } - return
{badges}
; + return badges.length > 0 ?
{badges}
: ; }; export default WidgetICstatus; diff --git a/src/pages/dashboards/widget/widgets/input.jsx b/src/pages/dashboards/widget/widgets/input.jsx index 938e2d75..d19fe776 100644 --- a/src/pages/dashboards/widget/widgets/input.jsx +++ b/src/pages/dashboards/widget/widgets/input.jsx @@ -16,79 +16,95 @@ ******************************************************************************/ import React, { useState, useEffect } from "react"; import { Form, Col, InputGroup } from "react-bootstrap"; - -function WidgetInput(props) { - const [value, setValue] = useState(""); +import { useUpdateWidgetMutation } from "../../../../store/apiSlice"; + +const WidgetInput = ({ signals, widget, editing, onInputChanged }) => { + const initialValue = + widget.customProperties.value !== undefined + ? Number(widget.customProperties.value) + : widget.customProperties.default_value !== undefined + ? Number(widget.customProperties.default_value) + : ""; + const [value, setValue] = useState(initialValue); const [unit, setUnit] = useState(""); - useEffect(() => { - const widget = { ...props.widget }; - widget.customProperties.simStartedSendValue = false; - - // AppDispatcher.dispatch({ - // type: "widgets/start-edit", - // token: props.token, - // data: widget, - // }); - }, [props.token, props.widget]); + const [updateWidget] = useUpdateWidgetMutation(); useEffect(() => { - if (props.widget.customProperties.simStartedSendValue) { - const widget = { ...props.widget }; + if (widget.customProperties.simStartedSendValue) { widget.customProperties.simStartedSendValue = false; + if(onInputChanged && signals && signals.length > 0){ + onInputChanged(widget.customProperties.value, "", "", false); + } + updateWidgetSimStatus(false); - AppDispatcher.dispatch({ - type: "widgets/start-edit", - token: props.token, - data: widget, - }); - - props.onInputChanged(Number(value), "", "", false); + onInputChanged(Number(value), "", false, false); } - }, [props, value]); + }, [value]); + + //once widget is mounted, update status + useEffect(() => { + updateWidgetSimStatus(true); + }, [widget.id]); useEffect(() => { - let newValue = ""; - let newUnit = ""; + if (widget.customProperties.simStartedSendValue) { + updateWidgetSimStatus(false); + if (onInputChanged) { + onInputChanged(Number(value), "", false, false); + } + } + }, [value]); + useEffect(() => { + let newValue = widget.customProperties.value; if ( - props.widget.customProperties.hasOwnProperty("value") && - props.widget.customProperties.value !== value + newValue === undefined && + widget.customProperties.default_value !== undefined ) { - newValue = Number(props.widget.customProperties.value); - } else if ( - props.widget.customProperties.hasOwnProperty("default_value") && - value === "" - ) { - newValue = Number(props.widget.customProperties.default_value); + newValue = widget.customProperties.default_value; } - - if (props.widget.signalIDs.length > 0) { - const signalID = props.widget.signalIDs[0]; - const signal = props.signals.find((sig) => sig.id === signalID); - if (signal !== undefined) { - newUnit = signal.unit; - } + if (newValue !== undefined && Number(newValue) !== Number(value)) { + setValue(Number(newValue)); } - if (newUnit !== unit) { - setUnit(newUnit); + if (widget.signalIDs && widget.signalIDs.length > 0) { + const signalID = widget.signalIDs[0]; + const signal = signals.find((sig) => sig.id === signalID); + if (signal && signal.unit !== unit) { + setUnit(signal.unit); + } } - - if (newValue !== value) { - setValue(newValue); + }, [widget, signals]); + + const updateWidgetSimStatus = async (isSimStarted) => { + try { + await updateWidget({ + widgetID: widget.id, + updatedWidget: { + widget: { + ...widget, + customProperties: { + ...widget.customProperties, + simStartedSendValue: isSimStarted, + }, + }, + }, + }).unwrap(); + } catch (err) { + console.log("Error updating simulation status:", err); } - }, [props, value, unit]); + }; const valueIsChanging = (newValue) => { const numericalValue = Number(newValue); setValue(numericalValue); - props.widget.customProperties.value = numericalValue; + widget.customProperties.value = numericalValue; }; const valueChanged = (newValue) => { - if (props.onInputChanged) { - props.onInputChanged(Number(newValue), "value", Number(newValue), true); + if (onInputChanged) { + onInputChanged(Number(newValue), "value", Number(newValue), true); } }; @@ -103,15 +119,15 @@ function WidgetInput(props) {
- {props.widget.name} - {props.widget.customProperties.showUnit ? ` [${unit}]` : ""} + {widget.name} + {widget.customProperties.showUnit ? ` [${unit}]` : ""} valueChanged(value)} onChange={(e) => valueIsChanging(e.target.value)} @@ -124,6 +140,6 @@ function WidgetInput(props) { ); -} +}; export default WidgetInput; diff --git a/src/pages/dashboards/widget/widgets/player.js b/src/pages/dashboards/widget/widgets/player.js deleted file mode 100644 index 100bf1a8..00000000 --- a/src/pages/dashboards/widget/widgets/player.js +++ /dev/null @@ -1,325 +0,0 @@ -/** - * This file is part of VILLASweb. - * - * VILLASweb is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * VILLASweb is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with VILLASweb. If not, see . - ******************************************************************************/ - -import React, { Component } from 'react'; -import { Container, Row, Col } from 'react-bootstrap'; -import JSZip from 'jszip'; -import IconButton from '../../../../common/buttons/icon-button'; -import IconTextButton from '../../../../common/buttons/icon-text-button'; -import ParametersEditor from '../../../../common/parameters-editor'; -import ResultPythonDialog from '../../../scenarios/dialogs/result-python-dialog'; -import { playerMachine } from '../widget-player/player-machine'; -import { interpret } from 'xstate'; - -const playerService = interpret(playerMachine); - -function transitionState(currentState, playerEvent) { - return playerMachine.transition(currentState, { type: playerEvent }) -} - -class WidgetPlayer extends Component { - constructor(props) { - super(props); - - playerService.start(); - - this.state = { - showDialog: false, - configID: '', - config: null, - playerState: playerMachine.initialState, - showConfig: false, - startParameters: {}, - icState: 'unknown', - ic: null, - configBtnText: 'Component Configuration', - uploadResults: false, - sessionToken: localStorage.getItem("token"), - pythonResultsModal: false, - resultArrayId: 0, - filesToDownload: [], - showWarning: true, - warningText: 'no config selected' - }; - } - - componentDidUpdate(prevProps) { - if (this.props.results && this.props.results.length - 1 !== this.state.resultArrayId) { - this.setState({ resultArrayId: this.props.results.length - 1 }); - } - // zip download files - if (this.state.filesToDownload && this.state.filesToDownload.length > 0) { - if (this.props.files !== prevProps.files) { - let filesToDownload = this.props.files.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); - }); - let zipname = "results_" + (new Date()).toISOString(); - zip.generateAsync({ type: "blob" }).then(function (content) { - saveAs(content, zipname); - }); - this.setState({ filesToDownload: [] }); - } - } - } - } - - componentWillUnmount() { - playerService.stop(); - } - - static getDerivedStateFromProps(props, state) { - - // configID was changed via edit control - if (typeof props.widget.customProperties.configID !== "undefined" - && state.configID !== props.widget.customProperties.configID) { - let configID = props.widget.customProperties.configID - let config = props.configs.find(cfg => cfg.id === parseInt(configID, 10)); - if (config) { - let playeric = props.ics.find(ic => ic.id === parseInt(config.icID, 10)) - if (playeric) { - var afterCreateState = '' - if (playeric.state === 'idle') { - afterCreateState = transitionState(state.playerState, 'ICIDLE') - } else { - afterCreateState = transitionState(state.playerState, 'ICBUSY') - } - return { - configID: configID, - config: config, - startParameters: config.startParameters, - ic: playeric, - icState: playeric.state, - configBtnText: playeric.name, - uploadResults: props.widget.customProperties.uploadResults, - playerState: afterCreateState, - showWarning: false, - warningText: '' - }; - } - } - } - - // upload results was un-/checked via edit control - if (props.widget.customProperties.uploadResults !== state.uploadResults) { - return { uploadResults: props.widget.customProperties.uploadResults } - } - - // IC state changed - if (state.ic && state.ic.state != state.icState) { - var newState; - switch (state.ic.state) { - case 'stopping': // if configured, show results - if (state.uploadResults) { - // AppDispatcher.dispatch({ - // type: 'results/start-load', - // param: '?scenarioID=' + props.scenarioID, - // token: state.sessionToken, - // }) - - // AppDispatcher.dispatch({ - // type: 'files/start-load', - // token: state.sessionToken, - // param: '?scenarioID=' + props.scenarioID, - // }); - } - newState = transitionState(state.playerState, 'FINISH') - return { playerState: newState, icState: state.ic.state } - case 'idle': - newState = transitionState(state.playerState, 'ICIDLE') - return { playerState: newState, icState: state.ic.state } - default: - if (state.ic.state === 'running') { - props.onStarted() - } - newState = transitionState(state.playerState, 'ICBUSY') - return { playerState: newState, icState: state.ic.state } - } - } - - return {}; - } - - clickStart() { - let config = this.state.config - config.startParameters = this.state.startParameters - // dispatch(sendActionToIC({token: sessionToken, id: id, actions: newAction})); - - let newState = transitionState(this.state.playerState, 'START') - this.setState({ playerState: newState }) - } - - clickReset() { - //dispatch(sendActionToIC({token: sessionToken, id: id, actions: newAction})); - } - - openPythonDialog() { - if (this.props.results.length <= this.state.resultArrayId) { - this.setState({ showWarning: true, warningText: 'no new result' }); - return - } - - this.setState({ pythonResultsModal: true }) - } - - downloadResultFiles() { - if (this.props.results.length <= this.state.resultArrayId) { - this.setState({ showWarning: true, warningText: 'no new result' }); - return - } - - let result = this.props.results[this.state.resultArrayId] - let toDownload = result.resultFileIDs; - - if (toDownload.length <= 0) { - this.setState({ showWarning: true, warningText: 'no result files' }); - return - } - - toDownload.forEach(fileid => { - // AppDispatcher.dispatch({ - // type: 'files/start-download', - // data: fileid, - // token: this.state.sessionToken - // }); - }); - - this.setState({ filesToDownload: toDownload }); - } - - render() { - - const iconStyle = { - height: '20px', - width: '20px' - } - - let configButton = { - height: '70px', - width: '120px', - fontSize: '13px' - } - - return ( -
-
- - - - - this.clickStart()} - icon='play' - disabled={(this.state.playerState && this.state.playerState.matches('startable')) ? false : true} - iconStyle={iconStyle} - tooltip='Start Component' - /> - - - - - this.clickReset()} - icon='undo' - iconStyle={iconStyle} - tooltip='Reset Component' - /> - - - - - - - - this.setState(prevState => ({ showConfig: !prevState.showConfig }))} - icon='cogs' - text={this.state.configBtnText + ' '} - buttonStyle={configButton} - disabled={false} - tooltip='Open/Close Component Configuration' - /> - - - - - {this.state.uploadResults ? -
-

Results

- - - - this.openPythonDialog()} - icon={['fab', 'python']} - disabled={(this.state.playerState && this.state.playerState.matches('finished')) ? false : true} - iconStyle={iconStyle} - /> - - - - - this.downloadResultFiles()} - icon='file-download' - disabled={(this.state.playerState && this.state.playerState.matches('finished')) ? false : true} - iconStyle={iconStyle} - /> - - - -
- : <> - } -
- - -
- {this.state.showConfig && this.state.config ? -
- this.setState({ startParameters: data })} - />
- : <> - } - {this.state.showWarning ? -

{this.state.warningText}

: <> - } - {this.state.uploadResults ? - this.setState({ pythonResultsModal: false })} - /> : <> - } -
- ); - } -} - -export default WidgetPlayer; diff --git a/src/pages/dashboards/widget/widgets/player.jsx b/src/pages/dashboards/widget/widgets/player.jsx new file mode 100644 index 00000000..177ead07 --- /dev/null +++ b/src/pages/dashboards/widget/widgets/player.jsx @@ -0,0 +1,445 @@ +/** + * This file is part of VILLASweb. + * + * VILLASweb is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * VILLASweb is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with VILLASweb. If not, see . + ******************************************************************************/ +import JSZip from "jszip"; +import React, { useState, useEffect } from "react"; +import { Container, Row, Col, Form } from "react-bootstrap"; +import { sendActionToIC, loadICbyId } from "../../../../store/icSlice"; +import { sessionToken } from "../../../../localStorage"; +import IconButton from "../../../../common/buttons/icon-button"; +import IconTextButton from "../../../../common/buttons/icon-text-button"; +import ParametersEditor from "../../../../common/parameters-editor"; +import ResultPythonDialog from "../../../scenarios/dialogs/result-python-dialog"; +import { playerMachine } from "../widget-player/player-machine"; +import { interpret } from "xstate"; +import { + useAddResultMutation, + useLazyDownloadFileQuery, + useGetResultsQuery, + useGetFilesQuery, + useUpdateComponentConfigMutation, +} from "../../../../store/apiSlice"; +import notificationsDataManager from "../../../../common/data-managers/notifications-data-manager"; +import NotificationsFactory from "../../../../common/data-managers/notifications-factory"; +import FileSaver from "file-saver"; +import { useDispatch } from "react-redux"; +import __origin from "../../../../url"; + +const WidgetPlayer = ({ + widget, + editing, + configs, + onStarted, + ics, + results, + files, + scenarioID, +}) => { + const dispatch = useDispatch(); + const zip = new JSZip(); + const [triggerDownloadFile] = useLazyDownloadFileQuery(); + const { refetch: refetchResults } = useGetResultsQuery(scenarioID); + const [updateComponentConfig] = useUpdateComponentConfigMutation(); + const { refetch: refetchFiles } = useGetFilesQuery(scenarioID); + const [addResult, { isError: isErrorAddingResult }] = useAddResultMutation(); + const [playerState, setPlayerState] = useState(playerMachine.initialState); + const [configID, setConfigID] = useState(-1); + const [config, setConfig] = useState({}); + const [icState, setICState] = useState("unknown"); + const [startParameters, setStartParameters] = useState({}); + const [playerIC, setPlayerIC] = useState({ name: "" }); + const [showPythonModal, setShowPythonModal] = useState(false); + const [showConfig, setShowConfig] = useState(false); + const [isUploadResultsChecked, setIsUploadResultsChecked] = useState( + widget.customProperties.uploadResults + ); + const [resultArrayId, setResultArrayId] = useState(0); + const [filesToDownload, setFilesToDownload] = useState([]); + const [showWarning, setShowWarning] = useState(false); + const [warningText, setWarningText] = useState(""); + const [configBtnText, setConfigBtnText] = useState("Component Configuration"); + + const playerService = interpret(playerMachine); + playerService.start(); + useEffect(() => { + setIsUploadResultsChecked(widget.customProperties.uploadResults); + }, [widget.customProperties.uploadResults]); + + useEffect(() => { + if (playerIC.name.length !== 0) { + const refresh = async () => { + const res = await dispatch( + loadICbyId({ id: playerIC.id, token: sessionToken }) + ); + setICState(res.payload.state); + }; + const timer = window.setInterval(() => refresh(), 1000); + return () => { + window.clearInterval(timer); + }; + } + }, [playerIC]); + + useEffect(() => { + if ( + typeof widget.customProperties.configID !== "undefined" && + configID !== widget.customProperties.configID + ) { + let configID = widget.customProperties.configID; + let config = configs.find((cfg) => cfg.id === parseInt(configID, 10)); + if (config) { + let t_playeric = ics.find((ic) => ic.id === parseInt(config.icID, 10)); + if (t_playeric) { + var afterCreateState = ""; + if (t_playeric.state === "idle") { + afterCreateState = transitionState(playerState, "ICIDLE"); + } else { + afterCreateState = transitionState(playerState, "ICBUSY"); + } + setPlayerIC(t_playeric); + setConfigID(configID); + setPlayerState(afterCreateState); + setConfig(config); + setStartParameters(config.startParameters); + } + } + } + }, [configs, ics]); + + useEffect(() => { + if (results && results.length != resultArrayId) { + setResultArrayId(results.length - 1); + } + }, [results]); + + useEffect(() => { + var newState = ""; + switch (icState) { + case "stopping": // if configured, show results + if (isUploadResultsChecked) { + refetchResults(); + refetchFiles().then((v) => { + setFilesToDownload(v.data.files); + }); + } + newState = transitionState(playerState, "FINISH"); + setPlayerState(newState); + return { playerState: newState, icState: icState }; + case "idle": + newState = transitionState(playerState, "ICIDLE"); + setPlayerState(newState); + return { playerState: newState, icState: icState }; + default: + if (icState === "running") { + onStarted(); + } + newState = transitionState(playerState, "ICBUSY"); + setPlayerState(newState); + return { playerState: newState, icState: icState }; + } + }, [icState]); + + const transitionState = (currentState, playerEvent) => { + return playerMachine.transition(currentState, { type: playerEvent }); + }; + + const clickStart = async () => { + try { + setPlayerState(transitionState(playerState, "ICBUSY")); + let pld = { + action: "start", + when: Math.round(new Date().getTime() / 1000), + parameters: { ...startParameters }, + }; + if (isUploadResultsChecked) { + addResult({ + result: { + scenarioID: scenarioID, + }, + }) + .then((v) => { + pld.results = { + url: `https://${__origin}/api/v2/results/${v.data.result.id}/file`, + type: "url", + token: sessionToken, + }; + dispatch( + sendActionToIC({ + token: sessionToken, + id: config.icID, + actions: [pld], + }) + ); + }) + .catch((e) => { + notificationsDataManager.addNotification( + NotificationsFactory.LOAD_ERROR(e.toString()) + ); + }); + } else { + dispatch( + sendActionToIC({ + token: sessionToken, + id: config.icID, + actions: [pld], + }) + ); + } + //sendAction({ icid: startConfig.icID, action: "start", when: Math.round((new Date()).getTime() / 1000), parameters: {...startParameters } }).unwrap(); + } catch (error) { + notificationsDataManager.addNotification( + NotificationsFactory.LOAD_ERROR(error?.data?.message) + ); + } + }; + + const clickReset = async () => { + try { + dispatch( + sendActionToIC({ + token: sessionToken, + id: config.icID, + actions: [ + { action: "reset", when: Math.round(new Date().getTime() / 1000) }, + ], + }) + ); + //sendAction({ icid: ic.id, action: "reset", when: Math.round((new Date()).getTime() / 1000), parameters: {...startParameters } }).unwrap(); + } catch (error) { + notificationsDataManager.addNotification( + NotificationsFactory.LOAD_ERROR(error?.data?.message) + ); + console.log(error); + } + }; + + const updateStartParameters = async (data) => { + let copy = structuredClone(config); + copy.startParameters = data; + if (copy.fileIDs === null) { + copy.fileIDs = []; + } + + const newConf = { id: config.id, config: { config: copy } }; + updateComponentConfig(newConf) + .then((v) => { + setStartParameters(data); + notificationsDataManager.addNotification( + NotificationsFactory.ACTION_INFO() + ); + }) + .catch((error) => { + notificationsDataManager.addNotification( + NotificationsFactory.UPDATE_ERROR(error?.data?.message) + ); + }); + }; + + const downloadResultFiles = () => { + if (results.length <= resultArrayId) { + setShowWarning(true); + setWarningText("no new result"); + return; + } + + const result = results[resultArrayId]; + const toDownload = result.resultFileIDs; + + if (toDownload.length <= 0) { + setShowWarning(true); + setWarningText("no result files"); + } else { + toDownload.forEach((fileID) => handleDownloadFile(fileID)); + } + + setFilesToDownload(toDownload); + }; + + const handleDownloadFile = async (fileID) => { + triggerDownloadFile(fileID) + .then((v) => { + const file = filesToDownload.find((f) => f.id === fileID); + const blob = new Blob([v.data], { type: "application/octet-stream" }); + zip.file(file.name, blob); + zip + .generateAsync({ type: "blob" }) + .then((content) => { + FileSaver.saveAs(content, `result-${file.id}.zip`); + }) + .catch((err) => { + notificationsDataManager.addNotification( + NotificationsFactory.UPDATE_ERROR("Failed to create ZIP archive") + ); + console.error("Failed to create ZIP archive", err); + }); + }) + .catch((e) => { + notificationsDataManager.addNotification( + NotificationsFactory.UPDATE_ERROR(e) + ); + }); + }; + + const openPythonDialog = () => { + if (results.length <= resultArrayId) { + setShowWarning(true); + setWarningText("no new result"); + return; + } + + setShowPythonModal(true); + }; + + const iconStyle = { + height: "20px", + width: "20px", + }; + + const configButton = { + height: "70px", + width: "120px", + fontSize: "13px", + }; + + return ( +
+
+ + + + + clickStart()} + icon="play" + disabled={!(playerState && playerState.matches("startable"))} + iconStyle={iconStyle} + tooltip="Start Component" + /> + + + + + clickReset()} + icon="undo" + iconStyle={iconStyle} + tooltip="Reset Component" + /> + + + + + + + + setShowConfig((prevState) => !prevState)} + icon="cogs" + text={configBtnText + " "} + buttonStyle={configButton} + disabled={false} + tooltip="Open/Close Component Configuration" + /> + + + + + + + + setIsUploadResultsChecked((prevState) => !prevState) + } + /> + + + + + {isUploadResultsChecked ? ( +
+ + + + openPythonDialog()} + icon={["fab", "python"]} + disabled={ + playerState && + playerState.matches("finished") && + isUploadResultsChecked + ? false + : true + } + iconStyle={iconStyle} + /> + + + + + downloadResultFiles()} + icon="file-download" + disabled={ + playerState && playerState.matches("finished") + ? false + : true + } + iconStyle={iconStyle} + /> + + + +
+ ) : ( + <> + )} +
+
+ {showConfig && config ? ( +
+ updateStartParameters(data)} + /> +
+ ) : ( + <> + )} + {showWarning ?

{warningText}

: <>} + {isUploadResultsChecked ? ( + setShowPythonModal(false)} + /> + ) : ( + <> + )} +
+ ); +}; + +export default WidgetPlayer; diff --git a/src/pages/dashboards/widget/widgets/slider.jsx b/src/pages/dashboards/widget/widgets/slider.jsx index 7c6a262f..1ef384fa 100644 --- a/src/pages/dashboards/widget/widgets/slider.jsx +++ b/src/pages/dashboards/widget/widgets/slider.jsx @@ -20,19 +20,18 @@ import { format } from "d3"; import classNames from "classnames"; import Slider from "rc-slider"; import "rc-slider/assets/index.css"; +import { useUpdateWidgetMutation } from "../../../../store/apiSlice"; const WidgetSlider = (props) => { const [value, setValue] = useState(""); const [unit, setUnit] = useState(""); + const [updateWidget] = useUpdateWidgetMutation(); + useEffect(() => { let widget = { ...props.widget }; widget.customProperties.simStartedSendValue = false; - // AppDispatcher.dispatch({ - // type: "widgets/start-edit", - // token: props.token, - // data: widget, - // }); + updateWidgetSimStatus(false); }, [props.token, props.widget]); useEffect(() => { @@ -40,16 +39,31 @@ const WidgetSlider = (props) => { if (props.widget.customProperties.simStartedSendValue) { let widget = { ...props.widget }; widget.customProperties.simStartedSendValue = false; - // AppDispatcher.dispatch({ - // type: "widgets/start-edit", - // token: props.token, - // data: widget, - // }); + updateWidgetSimStatus(true); // Send value without changing widget props.onInputChanged(widget.customProperties.value, "", "", false); } - }, [props.token, props.widget, props.onInputChanged]); + }, [props.widget]); + + const updateWidgetSimStatus = async (isSimStarted) => { + try { + await updateWidget({ + widgetID: props.widget.id, + updatedWidget: { + widget: { + ...props.widget, + customProperties: { + ...props.widget.customProperties, + simStartedSendValue: isSimStarted, + }, + }, + }, + }).unwrap(); + } catch (err) { + console.log("error", err); + } + }; useEffect(() => { let newValue = ""; @@ -109,7 +123,7 @@ const WidgetSlider = (props) => { disabled={props.editing} vertical={isVertical} onChange={valueIsChanging} - onAfterChange={(v) => valueChanged(v, true)} + onChangeComplete={(v) => valueChanged(v, true)} /> ), value: ( diff --git a/src/pages/infrastructure/dialogs/new-ic-dialog.js b/src/pages/infrastructure/dialogs/new-ic-dialog.js index a4380e74..fb9a5ebd 100644 --- a/src/pages/infrastructure/dialogs/new-ic-dialog.js +++ b/src/pages/infrastructure/dialogs/new-ic-dialog.js @@ -14,341 +14,461 @@ * You should have received a copy of the GNU General Public License * along with VILLASweb. If not, see . ******************************************************************************/ - -import React from 'react'; -import { Form as BForm, OverlayTrigger, Tooltip} from 'react-bootstrap'; -import Dialog from '../../../common/dialogs/dialog'; -import ParametersEditor from '../../../common/parameters-editor'; -import Form from "@rjsf/core"; -import $RefParser from '@apidevtools/json-schema-ref-parser'; - -class NewICDialog extends React.Component { - valid = false; - - constructor(props) { - super(props); - - this.state = { - name: '', - websocketurl: '', - apiurl: '', - uuid: '', - type: '', - category: '', - managedexternally: false, - description: '', - location: '', - manager: '', - properties: {}, - schema: {}, - formData: {}, - }; - } - - onClose(canceled) { - if (canceled === false) { - if (this.valid) { - const parameters = { - name: this.state.name, - type: this.state.type, - category: this.state.category, - uuid: this.state.uuid, - location: this.state.location, - description: this.state.description, - } - - const data = { - managedexternally: this.state.managedexternally, - manager: this.state.manager, - name: this.state.name, - type: this.state.type, - category: this.state.category, - uuid: this.state.uuid, - description: this.state.description, - location: this.state.location, - parameters: parameters - }; - - // Add custom properties - if (this.state.managedexternally) - Object.assign(parameters, this.state.properties); - - if (this.state.websocketurl != null && this.state.websocketurl !== "") { - parameters.websocketurl = this.state.websocketurl; - data.websocketurl = this.state.websocketurl; - } - - if (this.state.apiurl != null && this.state.apiurl !== "") { - parameters.apiurl = this.state.apiurl; - data.apiurl = this.state.apiurl; - } - - this.props.onClose(data); - this.setState({managedexternally: false}); - } +import { useEffect, useState, useRef } from "react"; +import Dialog from "../../../common/dialogs/dialog"; +import { Form, OverlayTrigger, Dropdown, Tooltip } from "react-bootstrap"; +import timeAgo from "../../../utils/timeAgo"; +import uuidv4 from "../../../utils/uuidv4"; +import FormFromParameterSchema from "./new-ic-form-builder"; + +/* + If user chooses to create new IC using manager, we a building a form from parameter schema + provided by said manager. Otherwise, use the generic form. +*/ + +const NewICDiallog = ({ show, managers, onClose }) => { + const [isManagedExternally, setIsManagedExternally] = useState(false); + const [selectedManager, setSelectedManager] = useState(null); + //form data that is used by the non-manager form + const initialFormData = { + name: "", + location: "", + description: "", + uuid: uuidv4(), + location: "", + category: "", + type: "", + websocketURL: "", + apiURL: "", + }; + const [formData, setFormData] = useState(initialFormData); + const [formErrors, setFormErrors] = useState({}); + + //each time use manager check is toggled we want to set a proper initial values + useEffect(() => { + if (isManagedExternally) { + setFormData((prevState) => ({ + category: prevState.category, + type: prevState.type, + })); + setFormErrors({}); } else { - this.props.onClose(); - this.setState({managedexternally: false}); - } - } - - handleChange(e) { - if(e.target.id === "managedexternally"){ - this.setState({ managedexternally : !this.state.managedexternally}); - } - else{ - this.setState({ [e.target.id]: e.target.value }); + setFormData(initialFormData); + setSelectedManager(null); + setFormErrors({}); } - } - - setManager(e) { - this.setState({ [e.target.id]: e.target.value }); + }, [isManagedExternally]); + + const handleChange = ({ target }) => { + setFormData((prevState) => ({ + ...prevState, + [target.id]: target.value, + })); + //clear errors when added new input + setFormErrors((prevErrors) => ({ + ...prevErrors, + [target.id]: "", + })); + }; - if (this.props.managers) { - let schema = this.props.managers.find(m => m.uuid === e.target.value).createparameterschema - if (schema) { - $RefParser.dereference(schema, (err, deref) => { - if (err) { - console.error(err) - } - else { - this.setState({schema: schema}) - } - }) + const handleClose = (c) => { + if (c) { + onClose(false); + } else { + if (validateForm()) { + const icData = { + data: { ...formData, ManagedExternally: isManagedExternally }, + manager: selectedManager, + }; + onClose(icData); } } - } - - handlePropertiesChange = properties => { - this.setState({ - properties: properties - }); }; - handleFormChange({formData}) { - this.setState({properties: formData, formData: formData}) - } - - resetState() { - this.setState({ - name: '', - websocketurl: '', - apiurl: '', - uuid: this.uuidv4(), - type: '', - category: '', - managedexternally: false, - description: '', - location: '', - properties: {}, - }); - } - - validateForm(target) { - - if (this.state.managedexternally) { - this.valid = this.state.manager !== ''; - return this.state.manager !== '' ? "success" : "error"; - } + const onReset = () => { + setFormData(initialFormData); + setSelectedManager(null); + }; - // check all controls - let name = true; - let uuid = true; - let type = true; - let category = true; + //ref for the form built by the manager's schema used to get its valdiation status + const managerFormRef = useRef(); - if (this.state.name === '') { - name = false; - } + const validateForm = () => { + //we are validating type and category in both cases, but other validations will differ for generic and manager form + const errors = {}; - if (this.state.uuid === '') { - uuid = false; + if (!formData.category.trim() || formData.category == "Select category") { + errors.category = "Category is required"; } - - if (this.state.type === '') { - type = false; + if (!formData.type.trim() || formData.category == "Select type") { + errors.type = "Type is required"; } - if (this.state.category === '') { - category = false; - } - - this.valid = name && uuid && type && category; - - // return state to control - if (target === 'name') return name ? "success" : "error"; - if (target === 'uuid') return uuid ? "success" : "error"; - if (target === 'type') return type ? "success" : "error"; - if (target === 'category') return category ? "success" : "error"; - - return this.valid; - } - - uuidv4() { - 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); - }); - } + if (isManagedExternally) { + //when no parameter schema is provided, there is nothing to verify + if (selectedManager.createparameterschema == null) { + return true; + } + const isManagerFormValid = managerFormRef.current.validateForm(); + setFormErrors((prevState) => ({ ...errors, ...prevState })); + return Object.keys(errors).length === 0 && isManagerFormValid; + } else { + if (!formData.name.trim()) { + errors.name = "Name is required"; + } - render() { - let typeOptions = []; - switch(this.state.category){ - case "simulator": - typeOptions = ["dummy","generic","dpsim","rtlab","rscad","rtlab","kubernetes"]; - break; - case "manager": - typeOptions = ["villas-node","villas-relay","generic","kubernetes"]; - break; - case "gateway": - typeOptions = ["villas-node","villas-relay"]; - break; - case "service": - typeOptions = ["ems","custom"]; - break; - case "equipment": - typeOptions = ["chroma-emulator","chroma-loads","sma-sunnyboy","fleps","sonnenbatterie"]; - break; - default: - typeOptions =[]; - } + setFormErrors(errors); - let managerOptions = []; - managerOptions.push(); - for (let m of this.props.managers) { - managerOptions.push ( - - ); + return Object.keys(errors).length === 0; } + }; - return ( - this.onClose(c)} - onReset={() => this.resetState()} - valid={this.validateForm()} - > - {this.props.managers.length > 0 ? - - - An externally managed component is created and - managed by an IC manager via AMQP}> - this.handleChange(e)}> - - - - : <> - } - {this.state.managedexternally === true ? - <> - - - - Required field } > - Manager to create new IC * - - this.setManager(e)}> - {managerOptions} - - - - - {this.state.schema ? -
this.handleFormChange({formData})} - children={true} // hides submit button - /> - : - - - Create Properties - this.handlePropertiesChange(data)} + return ( + handleClose(c)} + onReset={() => onReset()} + valid={ + !Object.values(formErrors).some( + (error) => error && error.trim() !== "" + ) || + (selectedManager && selectedManager.createparameterschema == null) + } + > + + setIsManagedExternally((prevState) => !prevState)} + > + + + { + //if its managed externally, then select a manager and build a form according to the schema of the manager, + //use the generic form otherwise + isManagedExternally ? ( +
+ + + setSelectedManager(managers.find((m) => m.id == eventKey)) + } + > + + {selectedManager == null + ? "Select a manager" + : selectedManager.name + " " + selectedManager.id} + + + + {managers.map((manager) => ( + + + {manager.name + " " + manager.id} + + + {"created " + timeAgo(manager.createdAt)} + + + ))} + + + {selectedManager == null || + selectedManager?.createparameterschema == null ? ( + <> + ) : ( + <> + - - - } - - : - - - Required field } > - Name * + + + {" "} + Required field{" "} + + } + > + Category of component * + + handleChange(e)} + > + { + /* + this is done for the case, when createparameterschema, like the one from generic manager (which this check is created for), has its own type and category property + otherwise it would lead to a crash, since selects have predifined properties for the category and type, while manager's form utilizes textfileds which allows any input, which + can be different from the pre-defined options + */ + formData.category == "" || + typeOptionsMap[formData.category] ? ( + <> + + + + + + + + ) : ( + + ) + } + + + {formErrors.category} + + + + + + {" "} + Required field{" "} + + } + > + Type of component * + + handleChange(e)} + > + + { + /* + this is done for the case, when createparameterschema, like the one from generic manager (which this check is created for), has its own type and category property + otherwise it would lead to a crash, since selects have predifined properties for the category and type, while manager's form utilizes textfileds which allows any input, which + can be different from the pre-defined options + */ + formData.category == "" ? ( + <> + ) : formData.category == "" || + typeOptionsMap[formData.category] ? ( + typeOptionsMap[formData.category].map( + (name, index) => + ) + ) : ( + + ) + } + + + {formErrors.type} + + + + )} + +
+ ) : ( + //the generic form +
+ + + {" "} + Required field{" "} + + } + > + Name * - this.handleChange(e)} /> - - - - - UUID - this.handleChange(e)}/> - - - - - Location - this.handleChange(e)} /> - - - - - Description - this.handleChange(e)} /> - - - - - Required field } > - Category of component * + handleChange(e)} + /> + + + {formErrors.name} + + + + + UUID + handleChange(e)} + /> + + + + + Location + handleChange(e)} + /> + + + + + Description + handleChange(e)} + /> + + + + + + {" "} + Required field{" "} + + } + > + Category of component * - this.handleChange(e)}> + handleChange(e)} + isInvalid={!!formErrors.category} + > - - - - - Required field } > - Type of component * - - this.handleChange(e)}> + + + {formErrors.category} + + + + + + {" "} + Required field{" "} + + } + > + Type of component * + + handleChange(e)} + isInvalid={!!formErrors.type} + > - {typeOptions.map((name,index) => ( - - ))} - - - - - Websocket URL - this.handleChange(e)} /> - - - - - API URL - this.handleChange(e)} /> - - - - } -
- ); - } -} - -export default NewICDialog; + {formData.category == "" ? ( + <> + ) : ( + typeOptionsMap[formData.category].map((name, index) => ( + + )) + )} +
+ + {formErrors.type} + +
+ + + Websocket URL + handleChange(e)} + /> + + + + + API URL + handleChange(e)} + /> + + + + ) + } + + ); +}; + +//a map of available parameter types accoding to the picked category +const typeOptionsMap = { + simulator: [ + "dummy", + "generic", + "dpsim", + "rtlab", + "rscad", + "rtlab", + "kubernetes", + ], + manager: ["villas-node", "villas-relay", "generic", "kubernetes"], + gateway: ["villas-node", "villas-relay"], + service: ["ems", "custom"], + equipment: [ + "chroma-emulator", + "chroma-loads", + "sma-sunnyboy", + "fleps", + "sonnenbatterie", + ], +}; + +export default NewICDiallog; diff --git a/src/pages/infrastructure/dialogs/new-ic-form-builder.js b/src/pages/infrastructure/dialogs/new-ic-form-builder.js new file mode 100644 index 00000000..7dade28a --- /dev/null +++ b/src/pages/infrastructure/dialogs/new-ic-form-builder.js @@ -0,0 +1,148 @@ +/** + * This file is part of VILLASweb. + * + * VILLASweb is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * VILLASweb is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with VILLASweb. If not, see . + ******************************************************************************/ + +import React, { + useEffect, + useState, + useImperativeHandle, + forwardRef, +} from "react"; +import { Form, OverlayTrigger, Dropdown, Tooltip } from "react-bootstrap"; + +//Builds a form using a schema from IC Manager +//ref is for the new ic modal to validate the whole together +const FormFromParameterSchema = forwardRef( + ({ createparameterschema, setParentFormData, setParentFormErrors }, ref) => { + if (!createparameterschema) return null; + + const [formData, setFormData] = useState({}); + const [formErrors, setFormErrors] = useState({}); + + //After initialization we want to set the initial values for the generated form + useEffect(() => { + if (createparameterschema !== null && createparameterschema.properties) { + Object.entries(createparameterschema.properties).forEach( + ([key, property]) => { + setFormData((prevState) => ({ + ...prevState, + [key]: property?.default || "", + })); + setParentFormData((prevState) => ({ + ...prevState, + [key]: property?.default || "", + })); + } + ); + } + }, [createparameterschema]); + + const handleChange = ({ target }) => { + const { id, value, type, checked } = target; + setFormData((prevState) => ({ + ...prevState, + [id]: type === "checkbox" ? checked : value, + })); + setFormErrors((prevState) => ({ + ...prevState, + [id]: "", + })); + setParentFormData((prevState) => ({ + ...prevState, + [id]: type === "checkbox" ? checked : value, + })); + setParentFormErrors((prevState) => ({ + ...prevState, + [id]: "", + })); + }; + + //schema has an array of required fields + const validateForm = () => { + const errors = {}; + createparameterschema.required?.forEach((key) => { + if (!formData[key]) { + errors[key] = "This field is required"; + } + }); + + setFormErrors(errors); + setParentFormErrors(errors); + return Object.keys(errors).length === 0; + }; + + //this is for new ic modal to call when handling submition + useImperativeHandle(ref, () => ({ + validateForm, + })); + + return ( +
+

{createparameterschema.title || ""}

+ {Object.entries(createparameterschema.properties).map( + ([key, property]) => { + const isRequired = createparameterschema.required?.includes(key); + //filter the type and category fields as they are always in the parent form + + console.log("ADDING FIELD WITH KEY", key); + + if (key != "type" && key != "category") { + //right now only text field and checkbox are supported. Text field is default + return property.type != "boolean" ? ( + + {isRequired ? ( + Required field} + > + {property.title} * + + ) : ( + {property.title} + )} + handleChange(e)} + /> + {isRequired ? ( + + {formErrors[key]} + + ) : ( + <> + )} + + ) : ( + handleChange(e)} + > + ); + } + } + )} +
+ ); + } +); + +export default FormFromParameterSchema; diff --git a/src/pages/infrastructure/ic-action-board.js b/src/pages/infrastructure/ic-action-board.js index 6e904645..446d2479 100644 --- a/src/pages/infrastructure/ic-action-board.js +++ b/src/pages/infrastructure/ic-action-board.js @@ -19,13 +19,13 @@ import { Form, Row, Col } from 'react-bootstrap'; import DateTimePicker from 'react-datetime-picker'; import ActionBoardButtonGroup from '../../common/buttons/action-board-button-group'; import classNames from 'classnames'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { sessionToken } from '../../localStorage'; -import { clearCheckedICs, deleteIC, sendActionToIC } from '../../store/icSlice'; +import { clearCheckedICs, deleteIC, loadICbyId, sendActionToIC } from '../../store/icSlice'; import { useGetICSQuery } from '../../store/apiSlice'; -const ICActionBoard = (props) => { +const ICActionBoard = ({externalICs}) => { const dispatch = useDispatch(); const {refetch: refetchICs} = useGetICSQuery(); const checkedICsIds = useSelector(state => state.infrastructure.checkedICsIds); @@ -45,14 +45,25 @@ const ICActionBoard = (props) => { }); } - const onRecreate = () => { + const onRecreate =() => { let newAction = {}; newAction['action'] = 'create'; newAction['when'] = time; - - checkedICsIds.forEach((id) => { - dispatch(sendActionToIC({token: sessionToken, id: id, actions: newAction})); - }); + for (let checkedIC of checkedICsIds){ + let IC = externalICs.find(e=>{ + return e.id == checkedIC + }) + if(IC){ + let manager = externalICs.find(e=>{ + return e.uuid == IC.manager + }) + if(manager){ + newAction['parameters'] = IC.properties ? IC.properties : IC.statusupdateraw.properties + dispatch(sendActionToIC({token:sessionToken,id:manager.id,actions:newAction})) + } + + } + } } const onDelete = () => { @@ -90,7 +101,6 @@ const ICActionBoard = (props) => { onReset()} - onShutdown={() => onShutdown()} onDelete={() => onDelete()} onRecreate={() => onRecreate()} paused={false} diff --git a/src/pages/infrastructure/ic-pages/kubernetes-ic-page.js b/src/pages/infrastructure/ic-pages/kubernetes-ic-page.js index 96e7b1ea..0180755b 100644 --- a/src/pages/infrastructure/ic-pages/kubernetes-ic-page.js +++ b/src/pages/infrastructure/ic-pages/kubernetes-ic-page.js @@ -15,7 +15,7 @@ * along with VILLASweb. If not, see . ******************************************************************************/ -import { Col, Row } from "react-bootstrap"; +import { Col, Row, Table} from "react-bootstrap"; import IconButton from "../../../common/buttons/icon-button"; import RawDataTable from "../../../common/rawDataTable"; import { useDispatch, useSelector } from "react-redux"; diff --git a/src/pages/infrastructure/infrastructure.js b/src/pages/infrastructure/infrastructure.js index 0c221681..03ee44eb 100644 --- a/src/pages/infrastructure/infrastructure.js +++ b/src/pages/infrastructure/infrastructure.js @@ -15,175 +15,181 @@ * along with VILLASweb. If not, see . ******************************************************************************/ -import { useEffect, useState } from "react" +import { useEffect, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; -import { addIC, sendActionToIC, closeDeleteModal, closeEditModal, editIC, deleteIC } from "../../store/icSlice"; +import { + addIC, + sendActionToIC, + closeDeleteModal, + closeEditModal, + editIC, + deleteIC, +} from "../../store/icSlice"; import IconButton from "../../common/buttons/icon-button"; import ICCategoryTable from "./ic-category-table"; import ICActionBoard from "./ic-action-board"; import { buttonStyle, iconStyle } from "./styles"; -import NewICDialog from "./dialogs/new-ic-dialog"; import ImportICDialog from "./dialogs/import-ic-dialog"; import EditICDialog from "./dialogs/edit-ic-dialog"; import DeleteDialog from "../../common/dialogs/delete-dialog"; -import NotificationsDataManager from "../../common/data-managers/notifications-data-manager"; -import NotificationsFactory from "../../common/data-managers/notifications-factory"; -import {useGetICSQuery} from '../../store/apiSlice'; +import { useGetICSQuery } from "../../store/apiSlice"; +import NewICDiallog from "./dialogs/new-ic-dialog"; const Infrastructure = () => { - const dispatch = useDispatch(); + const dispatch = useDispatch(); - const { user: currentUser, token: sessionToken } = useSelector((state) => state.auth); + const { user: currentUser, token: sessionToken } = useSelector( + (state) => state.auth + ); - const {data: icsRes, isLoading, refetch: refetchICs} = useGetICSQuery(); - const ics = icsRes ? icsRes.ics : []; + const { data: icsRes, isLoading, refetch: refetchICs } = useGetICSQuery(); + const ics = icsRes ? icsRes.ics : []; - const externalICs = ics.filter(ic => ic.managedexternally === true); + const externalICs = ics.filter((ic) => ic.managedexternally === true); - //track status of the modals - const [isNewModalOpened, setIsNewModalOpened] = useState(false); - const [isImportModalOpened, setIsImportModalOpened] = useState(false); - const [isICModalOpened, setIsICModalOpened] = useState(false); - const [checkedICs, setCheckedICs] = useState([]); - - useEffect(() => { - //start a timer for periodic refresh - let timer = window.setInterval(() => refetchICs(), 10000); + //track status of the modals + const [isNewModalOpened, setIsNewModalOpened] = useState(false); + const [isImportModalOpened, setIsImportModalOpened] = useState(false); + const [isICModalOpened, setIsICModalOpened] = useState(false); + const [checkedICs, setCheckedICs] = useState([]); - return () => { - window.clearInterval(timer); - } - }, []); - - //modal actions and selectors - - const isEditModalOpened = useSelector(state => state.infrastructure.isEditModalOpened); - const isDeleteModalOpened = useSelector(state => state.infrastructure.isDeleteModalOpened); - const editModalIC = useSelector(state => state.infrastructure.editModalIC); - const deleteModalIC = useSelector(state => state.infrastructure.deleteModalIC); - - const onNewModalClose = (data) => { - setIsNewModalOpened(false); - - console.log("Adding ic. External: ", !data.managedexternally) - - if(data){ - if(!data.managedexternally){ - dispatch(addIC({token: sessionToken, ic: data})) - }else { - // externally managed IC: dispatch create action to selected manager - let newAction = {}; - - newAction["action"] = "create"; - newAction["parameters"] = data.parameters; - newAction["when"] = new Date(); - - // find the manager IC - const managerIC = ics.find(ic => ic.uuid === data.manager) - if (managerIC === null || managerIC === undefined) { - NotificationsDataManager.addNotification(NotificationsFactory.ADD_ERROR("Could not find manager IC with UUID " + data.manager)); - return; - } - - dispatch(sendActionToIC({token: sessionToken, id: managerIC.id, actions: newAction})) - } - } - } + useEffect(() => { + //start a timer for periodic refresh + let timer = window.setInterval(() => refetchICs(), 10000); - const onImportModalClose = (data) => { - setIsImportModalOpened(false); + return () => { + window.clearInterval(timer); + }; + }, []); - dispatch(addIC({token: sessionToken, ic: data})) - } + //modal actions and selectors + + const isEditModalOpened = useSelector( + (state) => state.infrastructure.isEditModalOpened + ); + const isDeleteModalOpened = useSelector( + (state) => state.infrastructure.isDeleteModalOpened + ); + const editModalIC = useSelector((state) => state.infrastructure.editModalIC); + const deleteModalIC = useSelector( + (state) => state.infrastructure.deleteModalIC + ); + + const onNewModalClose = (formInput) => { + setIsNewModalOpened(false); + + if (formInput) { + const { data, manager } = formInput; - const onEditModalClose = (data) => { - if(data){ - //some changes where done - dispatch(editIC({token: sessionToken, ic: data})) + let newAction = { action: "create", when: new Date() }; + + if (manager !== null) { + if (manager.createparameterschema) { + newAction.parameters = data; } - dispatch(closeEditModal(data)); + + dispatch( + sendActionToIC({ + token: sessionToken, + id: manager.id, + actions: newAction, + }) + ); + } else { + dispatch(addIC({ token: sessionToken, ic: data })); + } } - const onCloseDeleteModal = (isDeleteConfirmed) => { - if(isDeleteConfirmed){ - dispatch(deleteIC({token: sessionToken, id:deleteModalIC.id})) - } - dispatch(closeDeleteModal()); + refetchICs(); + }; + + const onImportModalClose = (data) => { + setIsImportModalOpened(false); + + dispatch(addIC({ token: sessionToken, ic: data })); + }; + + const onEditModalClose = (data) => { + if (data) { + //some changes where done + dispatch(editIC({ token: sessionToken, ic: data })); } + dispatch(closeEditModal(data)); + }; - //getting list of managers for the new IC modal - const managers = ics.filter(ic => ic.category === "manager"); - return ( -
-
-

Infrastructure - {currentUser.role === "Admin" ? - - setIsNewModalOpened(true)} - icon='plus' - buttonStyle={buttonStyle} - iconStyle={iconStyle} - /> - setIsImportModalOpened(true)} - icon='upload' - buttonStyle={buttonStyle} - iconStyle={iconStyle} - /> - - : - } -

- - - - - - - - - - - - {currentUser.role === "Admin" ? : null} - -
- - onNewModalClose(data)} managers={managers} /> - onImportModalClose(data)} /> - onEditModalClose(data)} - ic={editModalIC ? editModalIC : {}} - /> - onCloseDeleteModal(e)} - /> -
- ); -} + const onCloseDeleteModal = (isDeleteConfirmed) => { + if (isDeleteConfirmed) { + dispatch(deleteIC({ token: sessionToken, id: deleteModalIC.id })); + } + dispatch(closeDeleteModal()); + }; + + //getting list of managers for the new IC modal + const managers = ics.filter((ic) => ic.category === "manager"); + + return ( +
+
+

+ Infrastructure + {currentUser.role === "Admin" ? ( + + setIsNewModalOpened(true)} + icon="plus" + buttonStyle={buttonStyle} + iconStyle={iconStyle} + /> + setIsImportModalOpened(true)} + icon="upload" + buttonStyle={buttonStyle} + iconStyle={iconStyle} + /> + + ) : ( + + )} +

+ + + + + + + + + + + + {currentUser.role === "Admin" ? : null} +
+ onNewModalClose(data)} + /> + onImportModalClose(data)} + /> + onEditModalClose(data)} + ic={editModalIC ? editModalIC : {}} + /> + onCloseDeleteModal(e)} + /> +
+ ); +}; export default Infrastructure; diff --git a/src/pages/scenarios/dialogs/edit-signal-mapping.js b/src/pages/scenarios/dialogs/edit-signal-mapping.js index 7ccaaee7..a2fa0f9d 100644 --- a/src/pages/scenarios/dialogs/edit-signal-mapping.js +++ b/src/pages/scenarios/dialogs/edit-signal-mapping.js @@ -1,260 +1,310 @@ import { useState, useEffect } from "react"; import { Button, Form, OverlayTrigger, Tooltip } from "react-bootstrap"; -import { Table, ButtonColumn, CheckboxColumn, DataColumn } from '../../../common/table'; -import { dialogWarningLabel, signalDialogCheckButton, buttonStyle } from "../styles"; +import { + Table, + ButtonColumn, + CheckboxColumn, + DataColumn, +} from "../../../common/table"; +import { + dialogWarningLabel, + signalDialogCheckButton, + buttonStyle, +} from "../styles"; import Dialog from "../../../common/dialogs/dialog"; import Icon from "../../../common/icon"; -import { useGetSignalsQuery, useAddSignalMutation, useDeleteSignalMutation, useUpdateSignalMutation } from "../../../store/apiSlice"; -import { Collapse } from 'react-collapse'; +import { + useGetSignalsQuery, + useAddSignalMutation, + useDeleteSignalMutation, + useUpdateSignalMutation, +} from "../../../store/apiSlice"; +import { Collapse } from "react-collapse"; -const ExportSignalMappingDialog = ({isShown, direction, onClose, configID}) => { +const EditSignalMappingDialog = ({ show, direction, onClose, configID }) => { + const [isCollapseOpened, setCollapseOpened] = useState(false); + const [checkedSignalsIDs, setCheckedSignalsIDs] = useState([]); + const { data, refetch: refetchSignals } = useGetSignalsQuery({ + configID: configID, + direction: direction, + }); + const [addSignalToConfig] = useAddSignalMutation(); + const [deleteSignal] = useDeleteSignalMutation(); + const [updateSignal] = useUpdateSignalMutation(); + const signals = data ? data.signals : []; - const [isCollapseOpened, setCollapseOpened] = useState(false); - const [checkedSignalsIDs, setCheckedSignalsIDs] = useState([]); - const {data, refetch: refetchSignals } = useGetSignalsQuery({configID: configID, direction: direction}); - const [addSignalToConfig] = useAddSignalMutation(); - const [deleteSignal] = useDeleteSignalMutation(); - const [updateSignal] = useUpdateSignalMutation(); - const signals = data ? data.signals : []; + const [updatedSignals, setUpdatedSignals] = useState([]); + const [updatedSignalsIDs, setUpdatedSignalsIDs] = useState([]); - const [updatedSignals, setUpdatedSignals] = useState([]); - const [updatedSignalsIDs, setUpdatedSignalsIDs] = useState([]); - - useEffect(() => { - if (signals.length > 0) { - setUpdatedSignals([...signals]); - } - }, [signals]); - - const handleMappingChange = (e, row, column) => { - const signalToUpdate = {...updatedSignals[row]}; - switch (column) { - case 1: - signalToUpdate.index = e.target.value; - break; - case 2: - signalToUpdate.name = e.target.value; - break; - case 3: - signalToUpdate.unit = e.target.value; - break; - case 4: - signalToUpdate.scalingFactor = e.target.value; - break; - default: - break; - } - - setUpdatedSignals(prevState => - prevState.map((signal, index) => - index === row ? signalToUpdate : signal - ) - ); + useEffect(() => { + if (signals.length > 0) { + setUpdatedSignals([...signals]); + } + }, [signals]); - setUpdatedSignalsIDs(prevState => ([signalToUpdate.id, ...prevState])); + const handleMappingChange = (e, row, column) => { + const signalToUpdate = { ...updatedSignals[row] }; + switch (column) { + case 1: + signalToUpdate.index = Number(e.target.value); + break; + case 2: + signalToUpdate.name = e.target.value; + break; + case 3: + signalToUpdate.unit = e.target.value; + break; + case 4: + signalToUpdate.scalingFactor = e.target.value; + break; + default: + break; } - const handleAdd = async () => { + setUpdatedSignals((prevState) => + prevState.map((signal, index) => + index === row ? signalToUpdate : signal + ) + ); - let largestIndex = -1; - signals.forEach(signal => { - if(signal.index > largestIndex){ - largestIndex = signal.index; - } - }) + setUpdatedSignalsIDs((prevState) => [signalToUpdate.id, ...prevState]); + }; - const newSignal = { - configID: configID, - direction: direction, - name: "PlaceholderName", - unit: "PlaceholderUnit", - index: largestIndex + 1, - scalingFactor: 1.0 - }; + const handleAdd = async () => { + let largestIndex = -1; + signals.forEach((signal) => { + if (signal.index > largestIndex) { + largestIndex = signal.index; + } + }); - try { - await addSignalToConfig(newSignal).unwrap(); - } catch (err) { - console.log(err); - } + const newSignal = { + configID: configID, + direction: direction, + name: "PlaceholderName", + unit: "PlaceholderUnit", + index: largestIndex + 1, + scalingFactor: 1.0, + }; - refetchSignals(); - console.log(signals) + try { + await addSignalToConfig(newSignal).unwrap(); + } catch (err) { + console.log(err); } - const handleDelete = async (signalID) => { - try { - await deleteSignal(signalID).unwrap(); - } catch (err) { - console.log(err); - } + refetchSignals(); + }; - refetchSignals(); + const handleDelete = async (signalID) => { + try { + await deleteSignal(signalID).unwrap(); + } catch (err) { + console.log(err); } - const handleUpdate = async () => { - try { - for (let id of updatedSignalsIDs) { - - const signalToUpdate = updatedSignals.find(signal => signal.id === id); - - if (signalToUpdate) { - await updateSignal({ signalID: id, updatedSignal: signalToUpdate }).unwrap(); - } - } + refetchSignals(); + }; - refetchSignals(); - setUpdatedSignalsIDs([]); - } catch (error) { - console.error("Error updating signals:", error); - } - } + const handleUpdate = async () => { + try { + for (let id of updatedSignalsIDs) { + const signalToUpdate = updatedSignals.find( + (signal) => signal.id === id + ); - const onSignalChecked = (signal, event) => { - if(!checkedSignalsIDs.includes(signal.id)){ - setCheckedSignalsIDs(prevState => ([...prevState, signal.id])); - } else { - const index = checkedSignalsIDs.indexOf(signal.id); - setCheckedSignalsIDs(prevState => prevState.filter((_, i) => i !== index)); + if (signalToUpdate) { + console.log(signalToUpdate) + await updateSignal({ + signalID: id, + updatedSignal: signalToUpdate, + }).unwrap(); } + } + + refetchSignals(); + setUpdatedSignalsIDs([]); + } catch (error) { + console.error("Error updating signals:", error); } + }; - const isSignalChecked = (signal) => { - return checkedSignalsIDs.includes(signal.id); + const onSignalChecked = (signal, event) => { + if (!checkedSignalsIDs.includes(signal.id)) { + setCheckedSignalsIDs((prevState) => [...prevState, signal.id]); + } else { + const index = checkedSignalsIDs.indexOf(signal.id); + setCheckedSignalsIDs((prevState) => + prevState.filter((_, i) => i !== index) + ); } + }; + + const isSignalChecked = (signal) => { + return checkedSignalsIDs.includes(signal.id); + }; - const toggleCheckAll = () => { - //check if all signals are already checked - if(checkedSignalsIDs.length === signals.length){ - setCheckedSignalsIDs([]); - } else { - signals.forEach(signal => { - if(!checkedSignalsIDs.includes(signal.id)){ - setCheckedSignalsIDs(prevState => ([...prevState, signal.id])); - } - }) + const toggleCheckAll = () => { + //check if all signals are already checked + if (checkedSignalsIDs.length === signals.length) { + setCheckedSignalsIDs([]); + } else { + signals.forEach((signal) => { + if (!checkedSignalsIDs.includes(signal.id)) { + setCheckedSignalsIDs((prevState) => [...prevState, signal.id]); } + }); } + }; - const deleteCheckedSignals = async () => { - if(checkedSignalsIDs.length > 0){ - try { - const deletePromises = checkedSignalsIDs.map(signalID => deleteSignal(signalID).unwrap()); - await Promise.all(deletePromises); - refetchSignals(); - } catch (err) { - console.log(err); - } - } + const deleteCheckedSignals = async () => { + if (checkedSignalsIDs.length > 0) { + try { + const deletePromises = checkedSignalsIDs.map((signalID) => + deleteSignal(signalID).unwrap() + ); + await Promise.all(deletePromises); + refetchSignals(); + } catch (err) { + console.log(err); + } } + }; - const DialogWindow = ( - { - handleUpdate(); - onClose(c) - }} - onReset={() => {}} - valid={true} + const DialogWindow = ( + { + handleUpdate(); + onClose(c); + }} + onReset={() => {}} + valid={true} + > + + + IMPORTANT: Signal configurations that were created before January 2022 + have to be fixed manually. Signal indices have to start at 0 and not + 1. + + + {" "} + Click in table cell to edit + + onSignalChecked(signal)} + data={updatedSignals} > - - IMPORTANT: Signal configurations that were created before January 2022 have to be fixed manually. Signal indices have to start at 0 and not 1. - Click in table cell to edit -
onSignalChecked(signal)} data={updatedSignals}> - onSignalChecked(index, event)} - checked={(signal) => isSignalChecked(signal)} - width='30' - /> - handleMappingChange(e, row, column)} - /> - handleMappingChange(e, row, column)} - /> - handleMappingChange(e, row, column)} - /> - handleMappingChange(e, row, column)} - /> - handleDelete(signals[index].id)} - /> -
+ onSignalChecked(index, event)} + checked={(signal) => isSignalChecked(signal)} + width="30" + /> + + handleMappingChange(e, row, column) + } + /> + + handleMappingChange(e, row, column) + } + /> + + handleMappingChange(e, row, column) + } + /> + + handleMappingChange(e, row, column) + } + /> + handleDelete(signals[index].id)} + /> + -
- Check/Uncheck All } - > - + + + +
+
+ +
Choose a Component Configuration to add the signal to:
+
+ {typeof configs !== "undefined" && + configs.map((config) => ( + - - - -
-
- -
Choose a Component Configuration to add the signal to:
-
- {typeof configs !== "undefined" && configs.map(config => ( - + ))} -
-
- -
- ) + + + +
+ ); - return isShown ? DialogWindow : <> -} + return show ? DialogWindow : <>; +}; -export default ExportSignalMappingDialog; +export default EditSignalMappingDialog; diff --git a/src/pages/scenarios/dialogs/result-python-dialog.js b/src/pages/scenarios/dialogs/result-python-dialog.js index 3042ffd0..cd1bc069 100644 --- a/src/pages/scenarios/dialogs/result-python-dialog.js +++ b/src/pages/scenarios/dialogs/result-python-dialog.js @@ -22,6 +22,7 @@ import Dialog from '../../../common/dialogs/dialog'; import {CopyToClipboard} from 'react-copy-to-clipboard'; import SyntaxHighlighter from 'react-syntax-highlighter'; import { github } from 'react-syntax-highlighter/dist/esm/styles/hljs'; +import __origin from '../../../url'; class ResultPythonDialog extends React.Component { villasDataProcessingUrl = 'https://pypi.org/project/villas-dataprocessing/'; @@ -105,7 +106,7 @@ class ResultPythonDialog extends React.Component { code_snippets.push(code_imports) /* Result object */ - code_snippets.push(`r = Result(${result.id}, '${token}')`); + code_snippets.push(`r = Result(${result.id}, '${token}', endpoint='https://${__origin}')`); /* Examples */ code_snippets.push(`# Get result metadata diff --git a/src/pages/scenarios/scenarios.js b/src/pages/scenarios/scenarios.js index 42e96fb7..fa8b4585 100644 --- a/src/pages/scenarios/scenarios.js +++ b/src/pages/scenarios/scenarios.js @@ -85,7 +85,6 @@ const Scenarios = (props) => { } const onEditScenario = async (data) => { - console.log("data: ", {scenario: data}); if(data){ try{ await updateScenario({id: modalScenario.id, ...{scenario: data}}).unwrap(); diff --git a/src/pages/scenarios/tables/config-action-board.js b/src/pages/scenarios/tables/config-action-board.js index 60379a80..42db6065 100644 --- a/src/pages/scenarios/tables/config-action-board.js +++ b/src/pages/scenarios/tables/config-action-board.js @@ -20,11 +20,11 @@ import DateTimePicker from 'react-datetime-picker'; import ActionBoardButtonGroup from '../../../common/buttons/action-board-button-group'; import classNames from 'classnames'; import { useState } from 'react'; -import { useSelector, useDispatch } from 'react-redux'; import { sessionToken } from '../../../localStorage'; import { useSendActionMutation, useAddResultMutation, useLazyGetSignalsQuery, useGetResultsQuery } from '../../../store/apiSlice'; import NotificationsFactory from "../../../common/data-managers/notifications-factory"; import notificationsDataManager from "../../../common/data-managers/notifications-data-manager"; +import __origin from '../../../url'; const ConfigActionBoard = ({selectedConfigs, scenarioID}) => { let pickedTime = new Date(); @@ -33,7 +33,7 @@ const ConfigActionBoard = ({selectedConfigs, scenarioID}) => { const [triggerGetSignals] = useLazyGetSignalsQuery(); const [sendAction] = useSendActionMutation(); - const [addResult] = useAddResultMutation(); + const [addResult, {isError: isErrorAddingResult}] = useAddResultMutation(); //we only need to update results table in case new result being added const { refetch: refetchResults } = useGetResultsQuery(scenarioID); @@ -43,8 +43,14 @@ const ConfigActionBoard = ({selectedConfigs, scenarioID}) => { const handleConfigStart = async () => { for(const config of selectedConfigs){ try { - if(isResultRequested){ + const action = { + icid: config.icID, + action: "start", + when: Math.round(new Date(time).getTime() / 1000), + parameters: {...config.startParameters} + } + if(isResultRequested){ const signalsInRes = await triggerGetSignals({configID: config.id, direction: "in"}, ).unwrap(); const signalsOutRes = await triggerGetSignals({configID: config.id, direction: "out"}, ).unwrap(); @@ -73,10 +79,26 @@ const ConfigActionBoard = ({selectedConfigs, scenarioID}) => { } } - await addResult({result: newResult}) + //get result id (if successfull) before sending an action + const res = await addResult({result: newResult}).unwrap(); + + if(!isErrorAddingResult){ + action.results = { + url: `https://${__origin}/api/v2/results/${res.result.id}/file`, + type: "url", + token: sessionToken + } + await sendAction(action).unwrap(); + } else { + notificationsDataManager.addNotification(NotificationsFactory.LOAD_ERROR("Error adding result")); + } + refetchResults(); + } else { + await sendAction(action).unwrap(); } - await sendAction({ icid: config.icID, action: "start", when: Math.round(new Date(time).getTime() / 1000), parameters: {} }).unwrap(); + + notificationsDataManager.addNotification(NotificationsFactory.ACTION_INFO()); } catch (err) { if(err.data){ notificationsDataManager.addNotification(NotificationsFactory.LOAD_ERROR(err.data.message)); diff --git a/src/pages/scenarios/tables/configs-table.js b/src/pages/scenarios/tables/configs-table.js index 7e9c63f1..e8699b39 100644 --- a/src/pages/scenarios/tables/configs-table.js +++ b/src/pages/scenarios/tables/configs-table.js @@ -470,7 +470,7 @@ const ConfigsTable = ({scenario, ics}) => { onClose={(c) => deleteConfig(c)} /> setIsEditSignalMappingModalOpened(false)} configID={signalMappingConfigID} diff --git a/src/pages/users/users.js b/src/pages/users/users.js index b5890dea..6e9c5e90 100644 --- a/src/pages/users/users.js +++ b/src/pages/users/users.js @@ -1,6 +1,6 @@ import { useState } from "react"; import { useSelector } from "react-redux"; -import { Dropdown, DropdownButton } from 'react-bootstrap'; +import { Dropdown, DropdownButton, Spinner, Row, Col } from 'react-bootstrap'; import { Table, ButtonColumn, CheckboxColumn, DataColumn } from "../../common/table"; import Icon from "../../common/icon"; import IconButton from "../../common/buttons/icon-button"; @@ -11,6 +11,7 @@ import DeleteDialog from "../../common/dialogs/delete-dialog"; import { buttonStyle, iconStyle } from "./styles"; import NotificationsFactory from "../../common/data-managers/notifications-factory"; import notificationsDataManager from "../../common/data-managers/notifications-data-manager"; +import Usergroups from "../usergroups/usergroups"; import { useGetUsersQuery, useAddUserMutation, @@ -18,26 +19,31 @@ import { useDeleteUserMutation, useGetScenariosQuery, useAddUserToScenarioMutation, + useGetUsergroupsQuery, + useAddUserToUsergroupMutation } from "../../store/apiSlice"; -const Users = ({}) => { +const Users = () => { const { user: currentUser, token: sessionToken } = useSelector((state) => state.auth); const {data: fetchedUsers, refetch: refetchUsers} = useGetUsersQuery(); const users = fetchedUsers ? fetchedUsers.users : []; - const { data: fetchedScenarios } = useGetScenariosQuery(); - const scenarios = fetchedScenarios ? fetchedScenarios.scenarios : []; + const { data: {scenarios} = [], isLoading: isLoadingScenarios } = useGetScenariosQuery(); + const {data: {usergroups} = [], isLoading: isLoadingUsergroups } = useGetUsergroupsQuery(); const [checkedUsersIDs, setCheckedUsersIDs] = useState([]); const [addUserMutation] = useAddUserMutation(); const [updateUserMutation] = useUpdateUserMutation(); const [deleteUserMutation] = useDeleteUserMutation(); const [addUserToScenarioMutation] = useAddUserToScenarioMutation(); + const [addUserToUsergroup] = useAddUserToUsergroupMutation(); const [isNewModalOpened, setIsNewModalOpened] = useState(false); const [isEditModalOpened, setIsEditModalOpened] = useState(false); const [isDeleteModalOpened, setIsDeleteModalOpened] = useState(false); const [scenario, setScenario] = useState({name: ''}); + const [usergroup, setUsergroup] = useState({name: ''}); const [isUsersToScenarioModalOpened, setUsersToScenarioModalOpened] = useState(false); + const [isUsersToUsegroupModalOpened, setUsersToUsegroupModalOpened] = useState(false); const [userToEdit, setUserToEdit] = useState({}); const [userToDelete, setUserToDelete] = useState({}); const [areAllUsersChecked, setAreAllUsersChecked] = useState(false); @@ -62,6 +68,10 @@ const Users = ({}) => { setIsNewModalOpened(false); } + const getIconForActiveColumn = (active) => { + return + } + const handleEditUser = async (data) => { if(data){ try { @@ -106,7 +116,6 @@ const Users = ({}) => { await addUserToScenarioMutation({ scenarioID: scenario.id, username: users.find(u => u.id === checkedUsersIDs[i]).username }).unwrap(); } } catch (error) { - console.log('ERROR', error) if(error.data){ notificationsDataManager.addNotification(NotificationsFactory.LOAD_ERROR(error.data.message)); } else { @@ -121,6 +130,28 @@ const Users = ({}) => { setAreAllUsersChecked(false); } + const handleAddUsersToUsergroup = async (isCanceled) => { + if(!isCanceled){ + try { + for(let i = 0; i < checkedUsersIDs.length; i++){ + await addUserToUsergroup({ usergroupID: usergroup.id, username: users.find(u => u.id === checkedUsersIDs[i]).username }).unwrap(); + } + } catch (error) { + if(error.data){ + notificationsDataManager.addNotification(NotificationsFactory.LOAD_ERROR(error.data.message)); + } else { + console.log("error", error) + notificationsDataManager.addNotification(NotificationsFactory.LOAD_ERROR("Unknown error")); + } + } + } + + setUsersToUsegroupModalOpened(false); + setCheckedUsersIDs([]); + setUsergroup({name: ''}); + setAreAllUsersChecked(false); + } + const toggleCheckAllUsers = () => { if(checkedUsersIDs.length === users.length){ setCheckedUsersIDs([]); @@ -188,7 +219,7 @@ const Users = ({}) => { - {}}/> + getIconForActiveColumn(active)}/> { }} /> - - { - let scenario; - if(scenarios.length > 0) { - scenario = scenarios.find(s => s.id == id); - } - setScenario(scenario); - setUsersToScenarioModalOpened(true); - }} - > - {scenarios.map(scenario => ( - {scenario.name} - ))} - - - - checkedUsersIDs.includes(user.id))} - scenario={scenario.name} - onClose={(canceled) => handleAddUserToScenario(canceled)} - /> + + + + {isLoadingScenarios? : <> + + { + let scenario; + if(scenarios.length > 0) { + scenario = scenarios.find(s => s.id == id); + } + setScenario(scenario); + setUsersToScenarioModalOpened(true); + }} + > + {scenarios.map(scenario => ( + {scenario.name} + ))} + + + + + checkedUsersIDs.includes(user.id))} + scenario={scenario.name} + onClose={(canceled) => handleAddUserToScenario(canceled)} + /> + } + + + + {isLoadingUsergroups? : <> + + { + let usergroup; + if(usergroups.length > 0) { + usergroup = usergroups.find(s => s.id == id); + } + setUsergroup(usergroup); + setUsersToUsegroupModalOpened(true); + }} + > + {usergroups.map(usergroup => ( + {usergroup.name} + ))} + + + + {/* re-using same modal to implement adding suers to usergroup */} + checkedUsersIDs.includes(user.id))} + scenario={usergroup.name} + onClose={(canceled) => handleAddUsersToUsergroup(canceled)} + /> + } + + + handleAddNewUser(data)} @@ -243,6 +313,10 @@ const Users = ({}) => { show={isDeleteModalOpened} onClose={(e) => handleDeleteUser(e)} /> + +
+ +
) } diff --git a/src/store/apiSlice.js b/src/store/apiSlice.js index 3080f15d..afd56e1a 100644 --- a/src/store/apiSlice.js +++ b/src/store/apiSlice.js @@ -1,25 +1,43 @@ -import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; -import { sessionToken } from '../localStorage'; -import { widgetEndpoints } from './endpoints/widget-endpoints'; -import { scenarioEndpoints } from './endpoints/scenario-endpoints'; -import { dashboardEndpoints } from './endpoints/dashboard-endpoints'; -import { icEndpoints } from './endpoints/ic-endpoints'; -import { configEndpoints } from './endpoints/config-endpoints'; -import { userEndpoints } from './endpoints/user-endpoints'; -import { fileEndpoints } from './endpoints/file-endpoints'; -import { signalEndpoints } from './endpoints/signal-endpoints'; -import { resultEndpoints } from './endpoints/result-endpoints'; -import { authEndpoints } from './endpoints/auth-endpoints'; -import { websocketEndpoints } from './endpoints/websocket-endpoints'; +/** + * This file is part of VILLASweb. + * + * VILLASweb is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * VILLASweb is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with VILLASweb. If not, see . + ******************************************************************************/ + +import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; +import { sessionToken } from "../localStorage"; +import { widgetEndpoints } from "./endpoints/widget-endpoints"; +import { scenarioEndpoints } from "./endpoints/scenario-endpoints"; +import { dashboardEndpoints } from "./endpoints/dashboard-endpoints"; +import { icEndpoints } from "./endpoints/ic-endpoints"; +import { configEndpoints } from "./endpoints/config-endpoints"; +import { userEndpoints } from "./endpoints/user-endpoints"; +import { fileEndpoints } from "./endpoints/file-endpoints"; +import { signalEndpoints } from "./endpoints/signal-endpoints"; +import { resultEndpoints } from "./endpoints/result-endpoints"; +import { authEndpoints } from "./endpoints/auth-endpoints"; +import { usergroupEndpoints } from "./endpoints/usergroup-endpoints"; +import { selectToken } from "./authSlice"; export const apiSlice = createApi({ - reducerPath: 'api', + reducerPath: "api", baseQuery: fetchBaseQuery({ - baseUrl: '/api/v2', - prepareHeaders: (headers) => { - const token = sessionToken; + baseUrl: "/api/v2", + prepareHeaders: (headers, { getState }) => { + const token = selectToken(getState()); if (token) { - headers.set('Authorization', `Bearer ${token}`); + headers.set("Authorization", `Bearer ${token}`); } return headers; }, @@ -35,18 +53,18 @@ export const apiSlice = createApi({ ...resultEndpoints(builder), ...signalEndpoints(builder), ...authEndpoints(builder), - ...websocketEndpoints(builder), + ...usergroupEndpoints(builder), }), }); -export const { - useGetScenariosQuery, - useGetScenarioByIdQuery, - useGetConfigsQuery, +export const { + useGetScenariosQuery, + useGetScenarioByIdQuery, + useGetConfigsQuery, useLazyGetConfigsQuery, - useGetDashboardsQuery, + useGetDashboardsQuery, useGetICSQuery, - useAddScenarioMutation, + useAddScenarioMutation, useDeleteScenarioMutation, useUpdateScenarioMutation, useGetUsersOfScenarioQuery, @@ -86,5 +104,15 @@ export const { useUpdateSignalMutation, useGetIcDataQuery, useLazyDownloadImageQuery, - useUpdateComponentConfigMutation + useUpdateComponentConfigMutation, + useGetUsergroupsQuery, + useAddUsergroupMutation, + useDeleteUsergroupMutation, + useGetUsergroupByIdQuery, + useGetUsersByUsergroupIdQuery, + useAddUserToUsergroupMutation, + useDeleteUserFromUsergroupMutation, + useUpdateUsergroupMutation, + useGetWidgetsQuery, + useLazyGetICbyIdQuery, } = apiSlice; diff --git a/src/store/authSlice.js b/src/store/authSlice.js index b0cf1f1a..5e408394 100644 --- a/src/store/authSlice.js +++ b/src/store/authSlice.js @@ -66,6 +66,7 @@ const authSlice = createSlice({ ); }, }); +export const selectToken = (state) => state.auth.token; export const { setUser, deleteUser } = authSlice.actions; diff --git a/src/store/configSlice.js b/src/store/configSlice.js index 744fcd60..9f35b19c 100644 --- a/src/store/configSlice.js +++ b/src/store/configSlice.js @@ -32,7 +32,6 @@ const configSlice = createSlice({ .addCase(loadConfig.fulfilled, (state, action) => { state.isLoading = false state.config = action.payload; - console.log("fetched config", action.payload) }) } }); @@ -43,7 +42,6 @@ export const loadConfig = createAsyncThunk( async (data) => { try { const res = await RestAPI.get("/api/v2/config", null); - console.log("response:", res) return res; } catch (error) { console.log("Error loading config", error); diff --git a/src/store/endpoints/file-endpoints.js b/src/store/endpoints/file-endpoints.js index 765c03fd..40137f70 100644 --- a/src/store/endpoints/file-endpoints.js +++ b/src/store/endpoints/file-endpoints.js @@ -16,52 +16,52 @@ ******************************************************************************/ export const fileEndpoints = (builder) => ({ - getFiles: builder.query({ - query: (scenarioID) => ({ - url: 'files', - params: { scenarioID }, - }), + getFiles: builder.query({ + query: (scenarioID) => ({ + url: "files", + params: { scenarioID }, }), - addFile: builder.mutation({ - query: ({ scenarioID, file }) => { - const formData = new FormData(); - formData.append('inputFile', file); - return { - url: `files?scenarioID=${scenarioID}`, - method: 'POST', - body: formData, - }; - }, + }), + addFile: builder.mutation({ + query: ({ scenarioID, file }) => { + const formData = new FormData(); + formData.append("file", file); + return { + url: `files?scenarioID=${scenarioID}`, + method: "POST", + body: formData, + }; + }, + }), + downloadFile: builder.query({ + query: (fileID) => ({ + url: `files/${fileID}`, + responseHandler: "blob", + responseType: "blob", }), - downloadFile: builder.query({ - query: (fileID) => ({ - url: `files/${fileID}`, - responseHandler: 'blob', - responseType: 'blob', - }), - }), - downloadImage: builder.query({ - query: (fileID) => ({ - url: `files/${fileID}`, - method: 'GET', - responseHandler: (response) => response.blob(), - }), - }), - updateFile: builder.mutation({ - query: ({ fileID, file }) => { - const formData = new FormData(); - formData.append('inputFile', file); - return { - url: `files/${fileID}`, - method: 'PUT', - body: formData, - }; - }, + }), + downloadImage: builder.query({ + query: (fileID) => ({ + url: `files/${fileID}`, + method: "GET", + responseHandler: (response) => response.blob(), }), - deleteFile: builder.mutation({ - query: (fileID) => ({ + }), + updateFile: builder.mutation({ + query: ({ fileID, file }) => { + const formData = new FormData(); + formData.append("file", file); + return { url: `files/${fileID}`, - method: 'DELETE', - }), + method: "PUT", + body: formData, + }; + }, + }), + deleteFile: builder.mutation({ + query: (fileID) => ({ + url: `files/${fileID}`, + method: "DELETE", }), + }), }); diff --git a/src/store/endpoints/ic-endpoints.js b/src/store/endpoints/ic-endpoints.js index fdb2f0ae..bea8481b 100644 --- a/src/store/endpoints/ic-endpoints.js +++ b/src/store/endpoints/ic-endpoints.js @@ -16,14 +16,18 @@ ******************************************************************************/ export const icEndpoints = (builder) => ({ - getICS: builder.query({ - query: () => 'ic', - }), - sendAction: builder.mutation({ - query: (params) => ({ - url: `/ic/${params.icid}/action`, - method: 'POST', - body: [params], - }), + getICS: builder.query({ + query: () => "ic", + }), + sendAction: builder.mutation({ + query: (params) => ({ + url: `/ic/${params.icid}/action`, + method: "POST", + body: [params], }), + }), + + getICbyId: builder.query({ + query: (icID) => `/ic/${icID}`, + }), }); diff --git a/src/store/endpoints/websocket-endpoints.js b/src/store/endpoints/websocket-endpoints.js deleted file mode 100644 index cb191c52..00000000 --- a/src/store/endpoints/websocket-endpoints.js +++ /dev/null @@ -1,103 +0,0 @@ -/** - * 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 . - ******************************************************************************/ - - -function setupWebSocket(WS_URL, onMessage, protocol) { - const socket = new WebSocket(WS_URL, protocol); // Include the protocol here - socket.binaryType = 'arraybuffer'; // Set binary type - socket.onmessage = (event) => { - onMessage(event.data); - }; - return socket; -} - -const sendMessage = (message) => { - if (socket && socket.readyState === WebSocket.OPEN) { - socket.send(JSON.stringify(message)); - } -}; - -export const websocketEndpoints = (builder) => ({ - getIcData: builder.query({ - query: () => ({data: []}), - async onCacheEntryAdded( - arg, - { updateCachedData, cacheDataLoaded, cacheEntryRemoved } - ) { - // create a websocket connection when the cache subscription starts - const socket = new WebSocket('wss://villas.k8s.eonerc.rwth-aachen.de/ws/ws_sig', 'live'); - socket.binaryType = 'arraybuffer'; - try { - // wait for the initial query to resolve before proceeding - await cacheDataLoaded; - - // when data is received from the socket connection to the server, - // if it is a message and for the appropriate channel, - // update our query result with the received message - const listener = (event) => { - console.log(event.data) - } - - socket.addEventListener('message', listener) - } catch { - // no-op in case `cacheEntryRemoved` resolves before `cacheDataLoaded`, - // in which case `cacheDataLoaded` will throw - } - // cacheEntryRemoved will resolve when the cache subscription is no longer active - await cacheEntryRemoved - // perform cleanup steps once the `cacheEntryRemoved` promise resolves - ws.close() - }, - }), -}); - -function bufferToMessageArray(blob) { - let offset = 0; - const msgs = []; - - while (offset < blob.byteLength) { - const msg = bufferToMessage(new DataView(blob, offset)); - if (msg !== undefined) { - msgs.push(msg); - offset += msg.blob.byteLength; - } - } - - return msgs; -} - -function bufferToMessage(data) { - if (data.byteLength === 0) { - return null; - } - - const source_index = data.getUint8(1); - const bits = data.getUint8(0); - const length = data.getUint16(0x02, 1); - const bytes = length * 4 + 16; - - return { - version: (bits >> OFFSET_VERSION) & 0xF, - type: (bits >> OFFSET_TYPE) & 0x3, - source_index: source_index, - length: length, - sequence: data.getUint32(0x04, 1), - timestamp: data.getUint32(0x08, 1) * 1e3 + data.getUint32(0x0C, 1) * 1e-6, - values: new Float32Array(data.buffer, data.byteOffset + 0x10, length), - blob: new DataView(data.buffer, data.byteOffset + 0x00, bytes), - }; -} \ No newline at end of file diff --git a/src/store/icSlice.js b/src/store/icSlice.js index 175b4852..defbc9c9 100644 --- a/src/store/icSlice.js +++ b/src/store/icSlice.js @@ -15,232 +15,257 @@ * along with VILLASweb. If not, see . ******************************************************************************/ -import {createSlice, createAsyncThunk} from '@reduxjs/toolkit' -import RestAPI from '../common/api/rest-api'; -import { sessionToken } from '../localStorage'; -import NotificationsDataManager from '../common/data-managers/notifications-data-manager'; -import NotificationsFactory from '../common/data-managers/notifications-factory'; - +import { createSlice, createAsyncThunk } from "@reduxjs/toolkit"; +import RestAPI from "../common/api/rest-api"; +import { sessionToken } from "../localStorage"; +import NotificationsDataManager from "../common/data-managers/notifications-data-manager"; +import NotificationsFactory from "../common/data-managers/notifications-factory"; const icSlice = createSlice({ - name: 'infrastructure', - initialState: { - ICsArray: [], - - checkedICsIds: [], - isLoading: false, - currentIC: {}, - isCurrentICLoading: false, - //IC used for Edit and Delete Modals - editModalIC: null, - deleteModalIC: null, - isDeleteModalOpened: false, - isEditModalOpened: false - }, - reducers: { - updateCheckedICs: (state, args) => { - // each table has an object that maps IDs of all its ICs to boolean values - // which indicates wether or note user picked it in checbox column - const checkboxValues = args.payload; - let checkedICsIds = [...state.checkedICsIds]; - - for(const id in checkboxValues){ - if(checkedICsIds.includes(id)){ - if(!checkboxValues[id]){ - checkedICsIds = checkedICsIds.filter((checkedId) => checkedId != id); - } - } else { - if(checkboxValues[id]){ - checkedICsIds.push(id); - } - } - } - - state.checkedICsIds = checkedICsIds; - }, - clearCheckedICs: (state, args) => { - state.checkedICsIds = []; - }, - openEditModal: (state, args) => { - state.isEditModalOpened = true; - state.editModalIC = args.payload; - console.log(state.editModalIC) - }, - closeEditModal: (state, args) => { - state.isEditModalOpened = false; - state.editModalIC = null; - }, - openDeleteModal: (state, args) => { - state.deleteModalIC = args.payload; - state.isDeleteModalOpened = true; - }, - closeDeleteModal: (state, args) => { - state.deleteModalIC = null; - state.isDeleteModalOpened = false; + name: "infrastructure", + initialState: { + ICsArray: [], + + checkedICsIds: [], + isLoading: false, + currentIC: {}, + isCurrentICLoading: false, + //IC used for Edit and Delete Modals + editModalIC: null, + deleteModalIC: null, + isDeleteModalOpened: false, + isEditModalOpened: false, + }, + reducers: { + updateCheckedICs: (state, args) => { + // each table has an object that maps IDs of all its ICs to boolean values + // which indicates wether or note user picked it in checbox column + const checkboxValues = args.payload; + let checkedICsIds = [...state.checkedICsIds]; + + for (const id in checkboxValues) { + if (checkedICsIds.includes(id)) { + if (!checkboxValues[id]) { + checkedICsIds = checkedICsIds.filter( + (checkedId) => checkedId != id + ); + } + } else { + if (checkboxValues[id]) { + checkedICsIds.push(id); + } } + } + + state.checkedICsIds = checkedICsIds; }, - extraReducers: builder => { - builder - .addCase(loadICbyId.pending, (state, action) => { - state.isCurrentICLoading = true - }) - - .addCase(loadICbyId.fulfilled, (state, action) => { - state.isCurrentICLoading = false - state.currentIC = action.payload; - console.log("fetched IC", state.currentIC.name) - }) - .addCase(addIC.rejected, (state, action) => { - NotificationsDataManager.addNotification(NotificationsFactory.ADD_ERROR("Error while adding infrastructural component: " + action.error.message)); - }) - .addCase(sendActionToIC.rejected, (state, action) => { - NotificationsDataManager.addNotification(NotificationsFactory.ADD_ERROR("Error while sending action to infrastructural component: " + action.error.message)); - }) - .addCase(editIC.rejected, (state, action) => { - NotificationsDataManager.addNotification(NotificationsFactory.ADD_ERROR("Error while trying to update an infrastructural component: " + action.error.message)); - }) - .addCase(deleteIC.rejected, (state, action) => { - NotificationsDataManager.addNotification(NotificationsFactory.ADD_ERROR("Error while trying to delete an infrastructural component: " + action.error.message)); - }) - //TODO - // .addCase(restartIC.fullfilled, (state, action) => { - // console.log("restart fullfilled") - // //loadAllICs({token: sessionToken}) - // }) - // .addCase(shutdownIC.fullfilled, (state, action) => { - // console.log("shutdown fullfilled") - // //loadAllICs({token: sessionToken}) - // }) - } + clearCheckedICs: (state, args) => { + state.checkedICsIds = []; + }, + openEditModal: (state, args) => { + state.isEditModalOpened = true; + state.editModalIC = args.payload; + console.log(state.editModalIC); + }, + closeEditModal: (state, args) => { + state.isEditModalOpened = false; + state.editModalIC = null; + }, + openDeleteModal: (state, args) => { + state.deleteModalIC = args.payload; + state.isDeleteModalOpened = true; + }, + closeDeleteModal: (state, args) => { + state.deleteModalIC = null; + state.isDeleteModalOpened = false; + }, + }, + extraReducers: (builder) => { + builder + .addCase(loadICbyId.pending, (state, action) => { + state.isCurrentICLoading = true; + }) + + .addCase(loadICbyId.fulfilled, (state, action) => { + state.isCurrentICLoading = false; + state.currentIC = action.payload; + console.log("fetched IC", state.currentIC.name); + }) + .addCase(addIC.rejected, (state, action) => { + NotificationsDataManager.addNotification( + NotificationsFactory.ADD_ERROR( + "Error while adding infrastructural component: " + + action.error.message + ) + ); + }) + .addCase(sendActionToIC.rejected, (state, action) => { + NotificationsDataManager.addNotification( + NotificationsFactory.ADD_ERROR( + "Error while sending action to infrastructural component: " + + action.error.message + ) + ); + }) + .addCase(editIC.rejected, (state, action) => { + NotificationsDataManager.addNotification( + NotificationsFactory.ADD_ERROR( + "Error while trying to update an infrastructural component: " + + action.error.message + ) + ); + }) + .addCase(deleteIC.rejected, (state, action) => { + NotificationsDataManager.addNotification( + NotificationsFactory.ADD_ERROR( + "Error while trying to delete an infrastructural component: " + + action.error.message + ) + ); + }); + //TODO + // .addCase(restartIC.fullfilled, (state, action) => { + // console.log("restart fullfilled") + // //loadAllICs({token: sessionToken}) + // }) + // .addCase(shutdownIC.fullfilled, (state, action) => { + // console.log("shutdown fullfilled") + // //loadAllICs({token: sessionToken}) + // }) + }, }); //loads one IC by its id export const loadICbyId = createAsyncThunk( - 'infrastructure/loadICbyId', - async (data) => { - try { - const res = await RestAPI.get('/api/v2/ic/' + data.id, data.token); - return res.ic; - } catch (error) { - console.log("Error loading IC (id=" + data.id + ") : ", error); - } + "infrastructure/loadICbyId", + async (data) => { + try { + const res = await RestAPI.get("/api/v2/ic/" + data.id, data.token); + return res.ic; + } catch (error) { + console.log("Error loading IC (id=" + data.id + ") : ", error); } -) + } +); //adds a new Infrastructural component. Data object must contain token and ic fields export const addIC = createAsyncThunk( - 'infrastructure/addIC', - async (data, {rejectWithValue}) => { - try { - //post request body: ic object that is to be added - const ic = {ic: data.ic}; - const res = await RestAPI.post('/api/v2/ic/', ic, data.token); - return res; - } catch (error) { - console.log("Error adding IC: ", error); - return rejectWithValue(error.response.data); - } + "infrastructure/addIC", + async (data, { rejectWithValue }) => { + try { + //post request body: ic object that is to be added + const ic = { ic: data.ic }; + const res = await RestAPI.post("/api/v2/ic/", ic, data.token); + return res; + } catch (error) { + console.log("Error adding IC: ", error); + return rejectWithValue(error.response.data); } -) + } +); //sends an action to IC. Data object must contain a token, IC's id and actions string export const sendActionToIC = createAsyncThunk( - 'infrastructure/sendActionToIC', - async (data, {rejectWithValue}) => { - try { - const token = data.token; - const id = data.id; - let actions = data.actions; - - console.log("actions: ", actions) - - if (!Array.isArray(actions)) - actions = [ actions ] - - for (let action of actions) { - if (action.when) { - // Send timestamp as Unix Timestamp - action.when = Math.round(new Date(action.when).getTime() / 1000); - } - } - - const res = await RestAPI.post('/api/v2/ic/'+id+'/action', actions, token); - NotificationsDataManager.addNotification(NotificationsFactory.ACTION_INFO()); - return res; - } catch (error) { - console.log("Error sending an action to IC: ", error); - return rejectWithValue(error.response.data); - } + "infrastructure/sendActionToIC", + async (data, { rejectWithValue }) => { + try { + const token = data.token; + const id = data.id; + let actions = data.actions; + + if (!Array.isArray(actions)) actions = [actions]; + + for (let action of actions) { + if (action.when) { + // Send timestamp as Unix Timestamp + action.when = Math.round(new Date(action.when).getTime() / 1000); + } + } + + const res = await RestAPI.post( + "/api/v2/ic/" + id + "/action", + actions, + token + ); + NotificationsDataManager.addNotification( + NotificationsFactory.ACTION_INFO() + ); + return res; + } catch (error) { + console.log("Error sending an action to IC: ", error); + return rejectWithValue(error.response.data); } -) + } +); //send a request to update IC's data. Data object must contain token, and updated ic object export const editIC = createAsyncThunk( - 'infrastructure/editIC', - async (data, {rejectWithValue}) => { - try { - //post request body: ic object that is to be added - const {token, ic} = data; - const res = await RestAPI.put('/api/v2/ic/'+ic.id, {ic: ic}, token); - return res; - } catch (error) { - return rejectWithValue(error.response.data); - } + "infrastructure/editIC", + async (data, { rejectWithValue }) => { + try { + //post request body: ic object that is to be added + const { token, ic } = data; + const res = await RestAPI.put("/api/v2/ic/" + ic.id, { ic: ic }, token); + return res; + } catch (error) { + return rejectWithValue(error.response.data); } -) + } +); //send a request to delete IC. Data object must contain token, and id of the IC that is to be deleted export const deleteIC = createAsyncThunk( - 'infrastructure/deleteIC', - async (data, {rejectWithValue}) => { - try { - //post request body: ic object that is to be added - const {token, id} = data; - const res = await RestAPI.delete('/api/v2/ic/'+id, token); - return res; - } catch (error) { - console.log("Error updating IC: ", error); - return rejectWithValue(error.response.data); - } + "infrastructure/deleteIC", + async (data, { rejectWithValue }) => { + try { + //post request body: ic object that is to be added + const { token, id } = data; + const res = await RestAPI.delete("/api/v2/ic/" + id, token); + return res; + } catch (error) { + console.log("Error updating IC: ", error); + return rejectWithValue(error.response.data); } -) - + } +); //TODO //restarts ICs export const restartIC = createAsyncThunk( - 'infrastructure/restartIC', - async (data) => { - try { - const url = data.apiurl + '/restart' - const res = await RestAPI.post(data.apiurl, null); - console.log(res) - return res; - } catch (error) { - console.log("Error restarting IC: ", error); - } + "infrastructure/restartIC", + async (data) => { + try { + const url = data.apiurl + "/restart"; + const res = await RestAPI.post(data.apiurl, null); + return res; + } catch (error) { + console.log("Error restarting IC: ", error); } -) - + } +); //shut ICs down export const shutdownIC = createAsyncThunk( - 'infrastructure/shutdownIC', - async (data) => { - try { - const url = data.apiurl + '/shutdown' - const res = await RestAPI.post(data.apiurl, null); - console.log(res) - return res; - } catch (error) { - console.log("Error shutting IC down: ", error); - } + "infrastructure/shutdownIC", + async (data) => { + try { + const url = data.apiurl + "/shutdown"; + const res = await RestAPI.post(data.apiurl, null); + return res; + } catch (error) { + console.log("Error shutting IC down: ", error); } -) - + } +); -export const {updateCheckedICs, clearCheckedICs, openEditModal, openDeleteModal, closeDeleteModal, closeEditModal} = icSlice.actions; +export const { + updateCheckedICs, + clearCheckedICs, + openEditModal, + openDeleteModal, + closeDeleteModal, + closeEditModal, +} = icSlice.actions; export default icSlice.reducer; - diff --git a/src/store/index.js b/src/store/index.js index 71f8893b..9ac5b717 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -16,21 +16,21 @@ ******************************************************************************/ import { configureStore } from "@reduxjs/toolkit"; -import icReducer from './icSlice'; -import configReducer from './configSlice' +import icReducer from "./icSlice"; +import configReducer from "./configSlice"; import { apiSlice } from "./apiSlice"; -import authReducer from './authSlice'; -import websocketReducer from './websocketSlice'; +import authReducer from "./authSlice"; +import websocketReducer from "./websocketSlice"; export const store = configureStore({ - reducer: { - auth: authReducer, - infrastructure: icReducer, - config: configReducer, - websocket: websocketReducer, - [apiSlice.reducerPath]: apiSlice.reducer, - }, - middleware: (getDefaultMiddleware) => + reducer: { + auth: authReducer, + infrastructure: icReducer, + config: configReducer, + websocket: websocketReducer, + [apiSlice.reducerPath]: apiSlice.reducer, + }, + middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(apiSlice.middleware), - devTools: true, -}) + devTools: true, +}); diff --git a/src/store/websocketSlice.js b/src/store/websocketSlice.js index 240ddcbc..2312a093 100644 --- a/src/store/websocketSlice.js +++ b/src/store/websocketSlice.js @@ -20,168 +20,198 @@ import { wsManager } from "../common/api/websocket-api"; import { current } from "@reduxjs/toolkit"; export const connectWebSocket = createAsyncThunk( - 'websocket/connect', - async ({ url, id, length }, { dispatch, getState }) => { - - console.log('Want to connect to', url); - - //check if we are already connected to this socket - if(getState().websocket.activeSocketURLs.includes(url)) return; - - return new Promise((resolve, reject) => { - dispatch(addActiveSocket({parameters: {id: id, url: url, length: length}})); - wsManager.connect( - id, - url, - (msgs, id) => { - const icdata = { - input: { - sequence: -1, - length: length, - version: 2, - type: 0, - timestamp: Date.now(), - values: new Array(length).fill(0), - }, - output: { - values: [], - }, - }; - - const MAX_VALUES = 10000; - - if (msgs.length > 0) { - for (let j = 0; j < msgs.length; j++) { - let smp = msgs[j]; - - if (smp.source_index !== 0) { - for (let i = 0; i < smp.length; i++) { - while (icdata.input.values.length < i + 1) { - icdata.input.values.push([]); - } - - icdata.input.values[i] = smp.values[i]; - - if (icdata.input.values[i].length > MAX_VALUES) { - const pos = icdata.input.values[i].length - MAX_VALUES; - icdata.input.values[i].splice(0, pos); - } - } - - icdata.input.timestamp = smp.timestamp; - icdata.input.sequence = smp.sequence; - } else { - for (let i = 0; i < smp.length; i++) { - while (icdata.output.values.length < i + 1) { - icdata.output.values.push([]); - } - - icdata.output.values[i].push({ x: smp.timestamp, y: smp.values[i] }); - - if (icdata.output.values[i].length > MAX_VALUES) { - const pos = icdata.output.values[i].length - MAX_VALUES; - icdata.output.values[i].splice(0, pos); - } - } - - icdata.output.timestamp = smp.timestamp; - icdata.output.sequence = smp.sequence; + "websocket/connect", + async ({ url, id, inputValues }, { dispatch, getState }) => { + //check if we are already connected to this socket + if (getState().websocket.activeSocketURLs.includes(url)) return; + + return new Promise((resolve, reject) => { + dispatch( + addActiveSocket({ + parameters: { id: id, url: url, inputValues: inputValues }, + }) + ); + wsManager.connect( + id, + url, + (msgs, id) => { + const icdata = { + input: { + version: 2, + type: 0, + sequence: -1, + length: inputValues.length, + timestamp: Date.now(), + values: inputValues, + }, + output: { + values: [], + }, + }; + + const MAX_VALUES = 10000; + + if (msgs.length > 0) { + for (let j = 0; j < msgs.length; j++) { + let smp = msgs[j]; + + for (let i = 0; i < smp.length; i++) { + while (icdata.output.values.length < i + 1) { + icdata.output.values.push([]); } + + icdata.output.values[i].push({ + x: smp.timestamp, + y: smp.values[i], + }); + + if (icdata.output.values[i].length > MAX_VALUES) { + const pos = icdata.output.values[i].length - MAX_VALUES; + icdata.output.values[i].splice(0, pos); + } + + icdata.output.timestamp = smp.timestamp; + icdata.output.sequence = smp.sequence; } - - // Dispatch the action to update the Redux state - dispatch(updateIcData({ id, newIcData: icdata })); } - }, - () => { - console.log('WebSocket connected to:', url); - resolve(); // Resolve the promise on successful connection - }, - () => { - console.log('WebSocket disconnected from:', url); - dispatch(disconnect()); - reject(); // Reject the promise if the connection is closed + + // Dispatch the action to update the Redux state + dispatch(updateIcData({ id, newIcData: icdata })); } - ); - }); - } + }, + () => { + wsManager.send(id, { + version: 2, + type: 0, + sequence: 0, + timestamp: Date.now(), + source_index: 1, + length: inputValues.length, + values: inputValues, + }); + resolve(); + }, + () => { + console.log("WebSocket disconnected from:", url); + dispatch(disconnect()); + reject(); + } + ); + }); + } ); const websocketSlice = createSlice({ - name: 'websocket', + name: "websocket", initialState: { icdata: {}, - activeSocketURLs: [] + activeSocketURLs: [], }, reducers: { addActiveSocket: (state, action) => { - const {url, id, length} = action.payload.parameters; - const currentSockets = current(state.activeSocketURLs); - state.activeSocketURLs = [...currentSockets, url]; - state.icdata[id] = {input: { - sequence: -1, - length: length, - version: 2, - type: 0, - timestamp: Date.now(), - values: new Array(length).fill(0) - }, output: {}}; + const { url, id, inputValues } = action.payload.parameters; + const currentSockets = current(state.activeSocketURLs); + state.activeSocketURLs = [...currentSockets, url]; + state.icdata[id] = { + input: { + sequence: -1, + length: inputValues.length, + version: 2, + type: 0, + timestamp: Date.now(), + values: inputValues, + }, + output: {}, + }; + }, + reportLength:(state,action)=>{ + return { + ...state, + values:new Array(action.payload).fill(0) + } + }, + initValue:(state,action)=>{ + let {idx,initVal} = action.payload + state.values.splice(idx,1,initVal) }, disconnect: (state, action) => { - wsManager.disconnect(action.payload.id); // Ensure the WebSocket is disconnected + if (action.payload) { + wsManager.disconnect(action.payload.id); // Ensure the WebSocket is disconnected + } }, updateIcData: (state, action) => { const { id, newIcData } = action.payload; const currentICdata = current(state.icdata); - if(currentICdata[id].output.values){ - const {values, ...rest} = newIcData.output; + if (currentICdata[id].output.values) { + const { values, ...rest } = newIcData.output; let oldValues = [...currentICdata[id].output.values]; - for(let i = 0; i < newIcData.output.values.length; i++){ - oldValues[i] = [...oldValues[i], ...values[i]] + for (let i = 0; i < newIcData.output.values.length; i++) { + oldValues[i] = [...oldValues[i], ...values[i]]; } - state.icdata[id] = { - input: newIcData.input, - output: { + return { + ...state, + icdata:{ + ...state.icdata, + [id]:{ + ...state.icdata[id], + output:{ ...rest, - values: oldValues + values:oldValues + } } + } } } else { - state.icdata[id] = { - ...newIcData, - }; + console.log(newIcData) + return { + ...state, + icdata:{ + ...state.icdata, + [id]:{ + ...newIcData + } + } + } } }, + //widget changes a value in input values array and they are sent to the websocket sendMessageToWebSocket: (state, action) => { - const { ic, signalID, signalIndex, data} = action.payload.message; - const currentICdata = current(state.icdata); - - if (!(ic == null || currentICdata[ic].input == null)) { - const inputAction = JSON.parse(JSON.stringify(currentICdata[ic].input)); - // update message properties - inputAction.timestamp = Date.now(); - inputAction.sequence++; - inputAction.values[signalIndex] = data; - inputAction.length = inputAction.values.length; - inputAction.source_index = signalID; - // The previous line sets the source_index field of the message to the ID of the signal - // so that upon loopback through VILLASrelay the value can be mapped to correct signal - - state.icdata[ic].input = inputAction; - let input = JSON.parse(JSON.stringify(inputAction)); - wsManager.send(ic, input); + const { ic, signalID, signalIndex, data } = action.payload.message; + + if (ic != null && state.icdata[ic] && state.icdata[ic].input) { + const inputMessage = state.icdata[ic].input; + + inputMessage.timestamp = Date.now(); + inputMessage.sequence++; + + //replace only the value of the taget signal if rest of the payload is valid + if ( + Array.isArray(inputMessage.values) && + signalIndex >= 0 && + signalIndex < inputMessage.values.length + ) { + inputMessage.values[signalIndex] = data; } - } + + inputMessage.length = inputMessage.values.length; + inputMessage.source_index = signalID; + + wsManager.send(ic, JSON.parse(JSON.stringify(inputMessage))); + } + }, }, extraReducers: (builder) => { - builder.addCase(connectWebSocket.fulfilled, (state, action) => { - // Handle the fulfilled state if needed - }); + builder.addCase(connectWebSocket.fulfilled, (state, action) => {}); builder.addCase(connectWebSocket.rejected, (state, action) => { - console.log('error', action); + console.log("error", action); }); }, }); -export const { disconnect, updateIcData, addActiveSocket, sendMessageToWebSocket } = websocketSlice.actions; +export const { + disconnect, + updateIcData, + addActiveSocket, + sendMessageToWebSocket, +} = websocketSlice.actions; export default websocketSlice.reducer; diff --git a/src/url.js b/src/url.js new file mode 100644 index 00000000..8e65f9ad --- /dev/null +++ b/src/url.js @@ -0,0 +1,4 @@ + +var dev = false; +const __origin = dev ? "slew.k8s.eonerc.rwth-aachen.de" : window.location.origin +export default __origin; \ No newline at end of file diff --git a/src/utils/timeAgo.js b/src/utils/timeAgo.js new file mode 100644 index 00000000..1bc5599b --- /dev/null +++ b/src/utils/timeAgo.js @@ -0,0 +1,43 @@ +/** + * 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 . + ******************************************************************************/ + +//returns a text that says how many seconds, minutes, .. , years has passed since the given date +const timeAgo = (date) => { + const now = new Date(); + const past = new Date(date); + const diffSec = Math.floor((now - past) / 1000); + + const intervals = [ + { label: "year", seconds: 31536000 }, + { label: "month", seconds: 2592000 }, + { label: "day", seconds: 86400 }, + { label: "hour", seconds: 3600 }, + { label: "minute", seconds: 60 }, + { label: "second", seconds: 1 }, + ]; + + for (const interval of intervals) { + const count = Math.floor(diffSec / interval.seconds); + if (count >= 1) { + return `${count} ${interval.label}${count !== 1 ? "s" : ""} ago`; + } + } + + return "just now"; +}; + +export default timeAgo; diff --git a/src/utils/uuidv4.js b/src/utils/uuidv4.js new file mode 100644 index 00000000..ffe1a1d7 --- /dev/null +++ b/src/utils/uuidv4.js @@ -0,0 +1,27 @@ +/** + * 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 . + ******************************************************************************/ + +const uuidv4 = () => { + 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); + }); +}; + +export default uuidv4;