diff --git a/README.md b/README.md index c68cb4b4fec901b9501f08ea8b2fd7dbce5533be..16b40bbf28344c5d2250a18237ec93bc06530467 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,6 @@ NRP web-frontend 2.0 using React -- created and built with Node v14.15.0 ### Commands diff --git a/package-lock.json b/package-lock.json index 4a622054b67bd072dc2dd3536b7d13bbce6bfedb..085d53b6245f8d37a504c55ec286de6d71a37b4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10346,6 +10346,11 @@ } } }, + "jquery": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.0.tgz", + "integrity": "sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw==" + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -11399,6 +11404,21 @@ "tslib": "^1.10.0" } }, + "node": { + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/node/-/node-16.1.0.tgz", + "integrity": "sha512-VCdVc3JI7WrGFwaentjwZsL936Gbzvi4k4y8pqWvml/7JM98oVY9mCv/WvzpPHr2NyZHXCngTiIa0Y26xufNSA==", + "dev": true, + "requires": { + "node-bin-setup": "^1.0.0" + } + }, + "node-bin-setup": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/node-bin-setup/-/node-bin-setup-1.0.6.tgz", + "integrity": "sha512-uPIxXNis1CRbv1DwqAxkgBk5NFV3s7cMN/Gf556jSw6jBvV7ca4F9lRL/8cALcZecRibeqU+5dFYqFFmzv5a0Q==", + "dev": true + }, "node-fetch": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", diff --git a/package.json b/package.json index e4fa562d022406d00b3c4776c1d57fb97ef2c884..d8f260b2740c8660d6d4529f2fc8d8612e072cdc 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@material-ui/lab": "4.0.0-alpha.57", "bootstrap": "4.5", "jszip": "3.2.0", + "jquery": "3.6.0", "react": "^17.0.1", "react-bootstrap": "1.4.0", "react-dom": "^17.0.1", @@ -29,14 +30,15 @@ "web-vitals": "^0.2.4" }, "devDependencies": { - "jest-fetch-mock": "^3.0.3", - "jest-localstorage-mock": "2.4.6", - "msw": "^0.23.0", "@testing-library/jest-dom": "^5.11.5", "@testing-library/react": "^11.1.0", "@testing-library/user-event": "^12.1.10", "@typescript-eslint/parser": "4.14.2", "eslint": "7.19.0", + "jest-fetch-mock": "^3.0.3", + "jest-localstorage-mock": "2.4.6", + "msw": "^0.23.0", + "node": "16.1.0", "ts-node": "^9.1.1", "typescript": "4.1.3" }, diff --git a/src/App.js b/src/App.js index 676465d36b760623aa6d05f08ea1b7ce948c82c4..8376431b189bcd3447684f2c77c62162d9e5549f 100644 --- a/src/App.js +++ b/src/App.js @@ -4,6 +4,7 @@ import { HashRouter, Switch, Route } from 'react-router-dom'; import EntryPage from './components/entry-page/entry-page.js'; import ErrorDialog from './components/dialog/error-dialog.js'; +import NotificationDialog from './components/dialog/notification-dialog.js'; import ExperimentOverview from './components/experiment-overview/experiment-overview.js'; class App extends React.Component { @@ -11,6 +12,7 @@ class App extends React.Component { return( <div> <ErrorDialog /> + <NotificationDialog/> <HashRouter> <Switch> <Route path='/experiments-overview'> diff --git a/src/components/dialog/error-dialog.css b/src/components/dialog/error-dialog.css index 02e9b3a1eae2d741aa5f835cdf59ecc137e57c69..63c319bee51416bdc4d6ad21d6ab2765c3df81b4 100644 --- a/src/components/dialog/error-dialog.css +++ b/src/components/dialog/error-dialog.css @@ -1,4 +1,4 @@ -.modal-dialog-wrapper { +.error-dialog-wrapper { position: fixed; width: 100%; height: 100%; @@ -7,6 +7,6 @@ } .modal-header{ - background-color: rgb(241, 185, 185); + background-color: rgb(238, 173, 173); color: rgb(138, 41, 41); } \ No newline at end of file diff --git a/src/components/dialog/error-dialog.js b/src/components/dialog/error-dialog.js index 67746983eafb8e1ea2d304d4ec6f73e3ff134ee0..6c69fa7ef95c0a8c8a4be2ffbdc54302a35c04ed 100644 --- a/src/components/dialog/error-dialog.js +++ b/src/components/dialog/error-dialog.js @@ -1,7 +1,7 @@ import React from 'react'; import { Modal, Button } from 'react-bootstrap'; -import ErrorHandlerService from '../../services/error-handler-service.js'; +import DialogService from '../../services/dialog-service.js'; import './error-dialog.css'; @@ -15,10 +15,16 @@ class ErrorDialog extends React.Component{ } async componentDidMount() { - ErrorHandlerService.instance.addListener( - ErrorHandlerService.EVENTS.ERROR, (error) => { - this.onError(error); - }); + this.onError = this.onError.bind(this); + DialogService.instance.addListener( + DialogService.EVENTS.ERROR, this.onError + ); + } + + componentWillUnmount() { + DialogService.instance.removeListener( + DialogService.EVENTS.ERROR, this.onError + ); } onError(error) { @@ -45,7 +51,7 @@ class ErrorDialog extends React.Component{ return ( <div> {error? - <div className="modal-dialog-wrapper"> + <div className="error-dialog-wrapper"> <Modal.Dialog> <Modal.Header className="modal-header"> <h4>{error.type}</h4> diff --git a/src/components/dialog/notification-dialog.css b/src/components/dialog/notification-dialog.css new file mode 100644 index 0000000000000000000000000000000000000000..b0ebdcec1e9cbf0955001ee874326c36633a4a3a --- /dev/null +++ b/src/components/dialog/notification-dialog.css @@ -0,0 +1,25 @@ +.toast-notification-wrapper{ + position: fixed; + bottom: 0; + right: 0; + z-index: 498; +} + +.no-style{ + list-style-type: none; +} + +.toast-width{ + max-width: none; + width: 350px; +} + +.warning{ + background-color:rgb(248, 248, 122); + color:rgb(126, 126, 45); +} + +.info{ + background-color: rgb(143, 143, 250); + color: rgb(48, 48, 134) +} diff --git a/src/components/dialog/notification-dialog.js b/src/components/dialog/notification-dialog.js new file mode 100644 index 0000000000000000000000000000000000000000..26e0763f1ec2df8edc275bcfae201d5df72d29d2 --- /dev/null +++ b/src/components/dialog/notification-dialog.js @@ -0,0 +1,81 @@ +import React from 'react'; +import { Toast } from 'react-bootstrap'; + +import DialogService from '../../services/dialog-service.js'; + +import './notification-dialog.css'; + +class NotificationDialog extends React.Component{ + constructor(props){ + super(props); + this.state = { + notifications: [] + }; + } + + async componentDidMount() { + this.onNotification = this.onNotification.bind(this); + DialogService.instance.addListener( + DialogService.EVENTS.NOTIFICATION, this.onNotification + ); + } + + componentWillUnmount() { + DialogService.instance.removeListener( + DialogService.EVENTS.NOTIFICATION, this.onNotification + ); + } + + onNotification(notification) { + // avoid duplicates + var isIn = false; + this.state.notifications.forEach((notif) =>{ + if (notification.type===notif.type && notification.message===notif.message){ + isIn = true; + } + }); + if (!isIn){ + this.setState({ + notifications: [...this.state.notifications, notification] + }); + } + } + + handleClose(index) { + var copy = [...this.state.notifications]; + copy.splice(index, 1); + this.setState({ + notifications: copy + }); + } + + render(){ + let notifications = this.state.notifications; + return( + <div className='toast-notification-wrapper'> + {notifications.length!==0? + <ol> + {notifications.map((notification, index) => { + return ( + <li key={index} className='no-style'> + <Toast className='toast-width' onClose={(index) => this.handleClose(index)} + delay={notification.type==='Warning'? 60000: 10000} autohide> + <Toast.Header className={notification.type==='Warning'? 'warning': 'info'} > + <strong className='mr-auto'>{notification.type}</strong> + </Toast.Header> + <Toast.Body> + {notification.message} + </Toast.Body> + </Toast> + </li> + ); + })} + </ol> + : null + } + </div> + ); + } +} + +export default NotificationDialog; \ No newline at end of file diff --git a/src/components/experiment-files-viewer/experiment-files-viewer.js b/src/components/experiment-files-viewer/experiment-files-viewer.js index 222c1600500a988be4b9c8fbc92ad449d1c465bb..7accbc6b7f34e459a3f9baac908fd87bea7db936 100644 --- a/src/components/experiment-files-viewer/experiment-files-viewer.js +++ b/src/components/experiment-files-viewer/experiment-files-viewer.js @@ -99,7 +99,6 @@ export default class ExperimentFilesViewer extends React.Component { render() { let selectedExperimentFiles = this.state.selectedExperiment ? RemoteExperimentFilesService.instance.mapFileInfos.get(this.state.selectedExperiment.uuid) : undefined; - return ( <div> {RemoteExperimentFilesService.instance.isSupported() ? diff --git a/src/components/experiment-list/experiment-list.css b/src/components/experiment-list/experiment-list.css index 4eb57d703094f83aa7e2f18e273bf78002beda5d..0c37870a1e5a527454302288981f562662bedb70 100644 --- a/src/components/experiment-list/experiment-list.css +++ b/src/components/experiment-list/experiment-list.css @@ -1,4 +1,4 @@ -li.nostyle { +.no-style { list-style-type: none; } diff --git a/src/components/experiment-list/experiment-list.js b/src/components/experiment-list/experiment-list.js index 8581fceab98712af9faf094b3d42b37e3e423c64..cb263408867e57fa3fde26e9d219f426efe3cf94 100644 --- a/src/components/experiment-list/experiment-list.js +++ b/src/components/experiment-list/experiment-list.js @@ -13,7 +13,7 @@ export default class ExperimentList extends React.Component { <ol> {this.props.experiments.map(experiment => { return ( - <li key={experiment.id || experiment.configuration.id} className='nostyle'> + <li key={experiment.id || experiment.configuration.id} className='no-style'> <ExperimentListElement experiment={experiment} availableServers={this.props.availableServers} startingExperiment={this.props.startingExperiment} diff --git a/src/components/experiment-overview/experiment-overview.js b/src/components/experiment-overview/experiment-overview.js index 43bc7ff7a7580d9f6e21c9921b06952316e014c7..efca5cc231cedc10837d2a24aa90503ce51f1d31 100644 --- a/src/components/experiment-overview/experiment-overview.js +++ b/src/components/experiment-overview/experiment-overview.js @@ -6,6 +6,7 @@ import ExperimentStorageService from '../../services/experiments/files/experimen import PublicExperimentsService from '../../services/experiments/files/public-experiments-service.js'; import ExperimentServerService from '../../services/experiments/execution/server-resources-service.js'; import ExperimentExecutionService from '../../services/experiments/execution/experiment-execution-service.js'; +import RemoteExperimentFilesService from '../../services/experiments/files/remote-experiment-files-service.js'; import ImportExperimentButtons from '../experiment-list/import-experiment-buttons.js'; import ExperimentList from '../experiment-list/experiment-list.js'; @@ -109,6 +110,13 @@ export default class ExperimentOverview extends React.Component { }); } + onSelectTab(index, lastIndex){ + this.setState({selectedTabIndex: index}); + if (index===3 && lastIndex!==3){ + RemoteExperimentFilesService.instance.notifyNotSupported(); + } + } + render() { return ( <div className='experiment-overview-wrapper'> @@ -118,7 +126,7 @@ export default class ExperimentOverview extends React.Component { <Tabs className="tabs-view" id="tabs-experiment-lists" selectedIndex={this.state.selectedTabIndex} - onSelect={(index) => this.setState({ selectedTabIndex: index })} > + onSelect={(index, lastIndex) => this.onSelectTab(index, lastIndex)} > <TabList> <Tab>My Experiments</Tab> <Tab>New Experiment</Tab> diff --git a/src/mocks/handlers.js b/src/mocks/handlers.js index 829ef5dc8a47c4b9f0a42bfd810faa383c070bff..01d5248a19a848b8ef55a9a58b97caf280a0f1df 100644 --- a/src/mocks/handlers.js +++ b/src/mocks/handlers.js @@ -10,6 +10,8 @@ import MockUsers from './mock_users.json'; import MockSimulations from './mock_simulations.json'; import MockUserGroups from './mock_user-groups.json'; import MockGDPR from './mock_gdpr.json'; +import MockModels from './mock_models.json'; +import MockCustomModels from './mock_custom_models.json'; import ImageAI from '../assets/images/Artificial_Intelligence_2.jpg'; @@ -58,6 +60,27 @@ export const handlers = [ }), rest.post(`${config.api.proxy.url}${endpoints.proxy.identity.url}${endpoints.proxy.identity.gdpr.url}`, (req, res, ctx) => { - return res(ctx.json({'status':'success'})); + return res(ctx.json({ 'status': 'success' })); + }), + rest.get(`${config.api.proxy.url}${endpoints.proxy.models.url}/:modelType`, + (req, res, ctx) => { + return res(ctx.json(MockModels[0])); + }), + rest.post(`${config.api.proxy.url}${endpoints.proxy.models.url}/:modelType/:modelName`, + (req, res, ctx) => { + return res(ctx.json(MockCustomModels[2])); + }), + rest.get(`${config.api.proxy.url}${endpoints.proxy.storage.allCustomModels.url}/:modelType`, + (req, res, ctx) => { + return res(ctx.json(MockCustomModels[0])); + }), + rest.get(`${config.api.proxy.url}${endpoints.proxy.storage.userModels.url}/:modelType`, + (req, res, ctx) => { + return res(ctx.json(MockCustomModels[0])); + }), + rest.delete(`${config.api.proxy.url}${endpoints.proxy.storage.userModels.url}/:modelType/:modelName`, + (req, res, ctx) => { + return res(ctx.json(MockCustomModels[1])); }) + ]; \ No newline at end of file diff --git a/src/mocks/mock_custom_models.json b/src/mocks/mock_custom_models.json new file mode 100644 index 0000000000000000000000000000000000000000..3b312f522b3653e213da73a590223b99935ae14f --- /dev/null +++ b/src/mocks/mock_custom_models.json @@ -0,0 +1,20 @@ +[ + { + "name": "custom_hbp_clearpath_robotics_husky_a200", + "path": "robots/husky_model.zip", + "ownerName": "nrpuser", + "type": "robots", + "fileName": "husky_model.zip" + }, + { + "name": "deleted_hbp_clearpath_robotics_husky_a200", + "ownerName": "nrpuser", + "type": "robots" + }, + { + "name": "created_hbp_clearpath_robotics_husky_a200", + "ownerName": "nrpuser", + "type": "robots" + } + +] \ No newline at end of file diff --git a/src/mocks/mock_models.json b/src/mocks/mock_models.json new file mode 100644 index 0000000000000000000000000000000000000000..2f2905b11a72d9128392831dcb72473bb215d1f4 --- /dev/null +++ b/src/mocks/mock_models.json @@ -0,0 +1,9 @@ +[ + { + "name": "hbp_clearpath_robotics_husky_a200", + "path": "robots/husky_model.zip", + "ownerName": "nrpuser", + "type": "robots", + "fileName": "husky_model.zip" + } +] \ No newline at end of file diff --git a/src/services/error-handler-service.js b/src/services/dialog-service.js similarity index 54% rename from src/services/error-handler-service.js rename to src/services/dialog-service.js index 026e9396a03fe5f02f74bab0cd252721fba8104c..1398e8d4f2555104e5742fc46cfa3aaaa684d524 100644 --- a/src/services/error-handler-service.js +++ b/src/services/dialog-service.js @@ -12,7 +12,7 @@ const SINGLETON_ENFORCER = Symbol(); * - data: related content | optional * - stack: call stack | optional */ -class ErrorHandlerService extends EventEmitter { +class DialogService extends EventEmitter { constructor(enforcer) { super(); if (enforcer !== SINGLETON_ENFORCER) { @@ -22,7 +22,7 @@ class ErrorHandlerService extends EventEmitter { static get instance() { if (_instance == null) { - _instance = new ErrorHandlerService(SINGLETON_ENFORCER); + _instance = new DialogService(SINGLETON_ENFORCER); } return _instance; @@ -31,28 +31,37 @@ class ErrorHandlerService extends EventEmitter { // HTTP request error networkError(error) { error.type = 'Network Error'; - this.emit(ErrorHandlerService.EVENTS.ERROR, error); + this.emit(DialogService.EVENTS.ERROR, error); } // Handling data error dataError(error){ error.type = 'Data Error'; - this.emit(ErrorHandlerService.EVENTS.ERROR, error); + this.emit(DialogService.EVENTS.ERROR, error); } - startSimulationError(error) { - error.type = 'Start Simulation Error'; - this.emit(ErrorHandlerService.EVENTS.ERROR, error); + simulationError(error) { + error.type = 'Simulation Error'; + this.emit(DialogService.EVENTS.ERROR, error); } - updateSimulationError(error) { - error.type = 'Update Simulation Error'; - this.emit(ErrorHandlerService.EVENTS.ERROR, error); + progressNotification(notification) { + notification.type = 'Progress Status'; + this.emit(DialogService.EVENTS.NOTIFICATION, notification); + } + + warningNotification(notification) { + notification.type = 'Warning'; + this.emit(DialogService.EVENTS.NOTIFICATION, notification); } } -ErrorHandlerService.EVENTS = Object.freeze({ +DialogService.EVENTS = Object.freeze({ ERROR: 'ERROR' }); -export default ErrorHandlerService; \ No newline at end of file +DialogService.EVENTS = Object.freeze({ + NOTIFICATION: 'NOTIFICATION' +}); + +export default DialogService; \ No newline at end of file diff --git a/src/services/experiments/execution/__tests__/running-simulation-service.test.js b/src/services/experiments/execution/__tests__/running-simulation-service.test.js index 7af3b6e72eb1dbc0f5b53d53d0a9ffd960e99855..3948e481b95a231826e39f3d8bf4fec30438c106 100644 --- a/src/services/experiments/execution/__tests__/running-simulation-service.test.js +++ b/src/services/experiments/execution/__tests__/running-simulation-service.test.js @@ -8,7 +8,7 @@ import MockAvailableServers from '../../../../mocks/mock_available-servers.json' import MockSimulations from '../../../../mocks/mock_simulations.json'; import RunningSimulationService from '../running-simulation-service.js'; -import ErrorHandlerService from '../../../error-handler-service'; +import DialogService from '../../../dialog-service'; import RoslibService from '../../../roslib-service'; import { EXPERIMENT_STATE } from '../../experiment-constants.js'; @@ -41,11 +41,11 @@ test('initializes and gets the simulation resources', async () => { expect(resources).toBeDefined(); // failure case - jest.spyOn(ErrorHandlerService.instance, 'networkError').mockImplementation(() => { }); + jest.spyOn(DialogService.instance, 'networkError').mockImplementation(() => { }); let simIDFailure = 0; - expect(ErrorHandlerService.instance.networkError).not.toHaveBeenCalled(); + expect(DialogService.instance.networkError).not.toHaveBeenCalled(); resources = await RunningSimulationService.instance.initConfigFiles(serverBaseURL, simIDFailure); - expect(ErrorHandlerService.instance.networkError).toHaveBeenCalled(); + expect(DialogService.instance.networkError).toHaveBeenCalled(); }); test('verifies whether a simulation is ready', async () => { @@ -139,7 +139,7 @@ test('register for ROS status information', () => { test('can retrieve the state of a simulation', async () => { let returnValueGET = undefined; - jest.spyOn(ErrorHandlerService.instance, 'networkError').mockImplementation(); + jest.spyOn(DialogService.instance, 'networkError').mockImplementation(); jest.spyOn(RunningSimulationService.instance, 'httpRequestGET').mockImplementation(() => { if (RunningSimulationService.instance.httpRequestGET.mock.calls.length === 1) { returnValueGET = { state: EXPERIMENT_STATE.PAUSED }; // proper state msg @@ -161,12 +161,12 @@ test('can retrieve the state of a simulation', async () => { // call 2 => rejected simSate = await RunningSimulationService.instance.getState('test-url', 1); - expect(ErrorHandlerService.instance.networkError).toHaveBeenCalled(); + expect(DialogService.instance.networkError).toHaveBeenCalled(); }); test('can set the state of a simulation', async () => { let returnValuePUT = undefined; - jest.spyOn(ErrorHandlerService.instance, 'updateSimulationError').mockImplementation(); + jest.spyOn(DialogService.instance, 'simulationError').mockImplementation(); jest.spyOn(RunningSimulationService.instance, 'httpRequestPUT').mockImplementation(() => { if (RunningSimulationService.instance.httpRequestGET.mock.calls.length === 1) { returnValuePUT = {}; @@ -184,5 +184,5 @@ test('can set the state of a simulation', async () => { // call 2 => rejected returnValue = await RunningSimulationService.instance.updateState('test-url', 1, EXPERIMENT_STATE.PAUSED); - expect(ErrorHandlerService.instance.updateSimulationError).toHaveBeenCalled(); + expect(DialogService.instance.simulationError).toHaveBeenCalled(); }); \ No newline at end of file diff --git a/src/services/experiments/execution/__tests__/server-resources-service.test.js b/src/services/experiments/execution/__tests__/server-resources-service.test.js index 92ecf5b8b0ccf30ff1e1155aec4540ef8791a484..688b7075a9d903ab26fe4a7677ef25d3fa8798ae 100644 --- a/src/services/experiments/execution/__tests__/server-resources-service.test.js +++ b/src/services/experiments/execution/__tests__/server-resources-service.test.js @@ -7,7 +7,7 @@ import 'jest-fetch-mock'; import MockServerconfig from '../../../../mocks/mock_server-config.json'; import ServerResourcesService from '../../../../services/experiments/execution/server-resources-service'; -import ErrorHandlerService from '../../../error-handler-service'; +import DialogService from '../../../dialog-service'; jest.setTimeout(10000); @@ -67,9 +67,9 @@ test('can get a server config', async () => { jest.spyOn(ServerResourcesService.instance, 'httpRequestGET').mockImplementation(() => { return Promise.reject(); }); - jest.spyOn(ErrorHandlerService.instance, 'networkError').mockImplementation(); + jest.spyOn(DialogService.instance, 'networkError').mockImplementation(); config = await ServerResourcesService.instance.getServerConfig('test-server-id'); - expect(ErrorHandlerService.instance.networkError).toHaveBeenCalled(); + expect(DialogService.instance.networkError).toHaveBeenCalled(); }); test('should stop polling updates when window is unloaded', async () => { diff --git a/src/services/experiments/execution/experiment-execution-service.js b/src/services/experiments/execution/experiment-execution-service.js index 0076fbf602d0a6d4ab7e959881ac7265aee4fa31..4858fb3e08d5419b91f128323198b2d3112625a3 100644 --- a/src/services/experiments/execution/experiment-execution-service.js +++ b/src/services/experiments/execution/experiment-execution-service.js @@ -3,7 +3,7 @@ import _ from 'lodash'; //import NrpAnalyticsService from '../../nrp-analytics-service.js'; import ServerResourcesService from './server-resources-service.js'; import SimulationService from './running-simulation-service.js'; -import ErrorHandlerService from '../../error-handler-service'; +import DialogService from '../../dialog-service'; import { HttpService } from '../../http-service.js'; import { EXPERIMENT_STATE } from '../experiment-constants.js'; @@ -61,8 +61,8 @@ class ExperimentExecutionService extends HttpService { let brainProcesses = launchSingleMode ? 1 : experiment.configuration.brainProcesses; //TODO: placeholder, register actual progress callback later - let progressCallback = (msg) => { - console.info(msg); + let progressCallback = () => { + DialogService.instance.progressNotification({message:'The experiment is loading'}); }; let launchOnNextServer = async () => { @@ -90,7 +90,7 @@ class ExperimentExecutionService extends HttpService { progressCallback ).catch((failure) => { if (failure.error) { - ErrorHandlerService.instance.startSimulationError(failure.error); + DialogService.instance.simulationError(failure.error); } fatalErrorOccurred = fatalErrorOccurred || failure.isFatal; diff --git a/src/services/experiments/execution/running-simulation-service.js b/src/services/experiments/execution/running-simulation-service.js index 754ccc8d96d54ab3e014630e03654ce5905ea010..2ef55859c3ec42302871b772c9d3c58a5641e68c 100644 --- a/src/services/experiments/execution/running-simulation-service.js +++ b/src/services/experiments/execution/running-simulation-service.js @@ -1,4 +1,4 @@ -import ErrorHandlerService from '../../error-handler-service.js'; +import DialogService from '../../dialog-service.js'; import RoslibService from '../../roslib-service.js'; import { HttpService } from '../../http-service.js'; import { EXPERIMENT_STATE } from '../experiment-constants.js'; @@ -44,7 +44,7 @@ class SimulationService extends HttpService { cachedConfigFiles = response.resources; } catch (error) { - ErrorHandlerService.instance.networkError(error); + DialogService.instance.networkError(error); } return cachedConfigFiles; @@ -151,7 +151,7 @@ class SimulationService extends HttpService { return response; } catch (error) { - ErrorHandlerService.instance.networkError(error); + DialogService.instance.networkError(error); } } @@ -168,7 +168,7 @@ class SimulationService extends HttpService { return response; } catch (error) { - ErrorHandlerService.instance.updateSimulationError(error); + DialogService.instance.simulationError(error); } } } diff --git a/src/services/experiments/execution/server-resources-service.js b/src/services/experiments/execution/server-resources-service.js index 8a2eb78c48f3f299c872187fbd239b18279c2003..8499b472a8619e251ef7769cfb28561b6704809e 100644 --- a/src/services/experiments/execution/server-resources-service.js +++ b/src/services/experiments/execution/server-resources-service.js @@ -1,4 +1,4 @@ -import ErrorHandlerService from '../../error-handler-service.js'; +import DialogService from '../../dialog-service.js'; import { HttpService } from '../../http-service.js'; import endpoints from '../../proxy/data/endpoints.json'; @@ -79,7 +79,7 @@ class ServerResourcesService extends HttpService { .then(async (response) => { return await response.json(); }) - .catch(ErrorHandlerService.instance.networkError); + .catch(DialogService.instance.networkError); } } diff --git a/src/services/experiments/files/experiment-storage-service.js b/src/services/experiments/files/experiment-storage-service.js index 0cf994dac08ab916701831c86cfe1e71e75fe1f8..8d6326388acea05cc17ed61d00daeb35c439b230 100644 --- a/src/services/experiments/files/experiment-storage-service.js +++ b/src/services/experiments/files/experiment-storage-service.js @@ -3,7 +3,7 @@ import { EXPERIMENT_RIGHTS } from '../experiment-constants'; import endpoints from '../../proxy/data/endpoints.json'; import config from '../../../config.json'; -import ErrorHandlerService from '../../error-handler-service.js'; +import DialogService from '../../dialog-service.js'; const storageURL = `${config.api.proxy.url}${endpoints.proxy.storage.url}`; const storageExperimentsURL = `${config.api.proxy.url}${endpoints.proxy.storage.experiments.url}`; @@ -79,7 +79,7 @@ class ExperimentStorageService extends HttpService { this.emit(ExperimentStorageService.EVENTS.UPDATE_EXPERIMENTS, this.experiments); } catch (error) { - ErrorHandlerService.instance.networkError(error); + DialogService.instance.networkError(error); } } diff --git a/src/services/experiments/files/import-experiment-service.js b/src/services/experiments/files/import-experiment-service.js index 341e191e8dbfe51c5836397d79c329b6eaabcee2..e726c3a9c3dffcf6e1b8713634c4c9000275d842 100644 --- a/src/services/experiments/files/import-experiment-service.js +++ b/src/services/experiments/files/import-experiment-service.js @@ -3,7 +3,7 @@ import JSZip from 'jszip'; import endpoints from '../../proxy/data/endpoints.json'; import config from '../../../config.json'; -import ErrorHandlerService from '../../error-handler-service.js'; +import DialogService from '../../dialog-service.js'; const importExperimentURL = `${config.api.proxy.url}${endpoints.proxy.storage.importExperiment.url}`; const scanStorageURL = `${config.api.proxy.url}${endpoints.proxy.storage.scanStorage.url}`; @@ -80,7 +80,7 @@ export default class ImportExperimentService extends HttpService { async scanStorage() { return this.httpRequestPOST(scanStorageURL) .then(response => this.getScanStorageResponse(response)) - .catch(error => ErrorHandlerService.instance.networkError(error)); + .catch(error => DialogService.instance.networkError(error)); } async zipExperimentFolder(event) { @@ -117,7 +117,7 @@ export default class ImportExperimentService extends HttpService { ) ) .catch(error => { - ErrorHandlerService.instance.dataError(error); + DialogService.instance.dataError(error); return Promise.reject(error); }) ); @@ -126,7 +126,7 @@ export default class ImportExperimentService extends HttpService { return Promise.all(promises) .then(() => zip.generateAsync({ type: 'blob' })) .catch(error => { - ErrorHandlerService.instance.dataError(error); + DialogService.instance.dataError(error); return Promise.reject(error); }); } @@ -135,7 +135,7 @@ export default class ImportExperimentService extends HttpService { return this.zipExperimentFolder(event).then(async zipContent => { return this.httpRequestPOST(importExperimentURL, zipContent, options) .then(response => response.json()) - .catch(error => ErrorHandlerService.instance.networkError(error) + .catch(error => DialogService.instance.networkError(error) ); }); } @@ -162,7 +162,7 @@ export default class ImportExperimentService extends HttpService { zipContents.map(zipContent => this.httpRequestPOST(importExperimentURL, zipContent, options) .catch(error => { - ErrorHandlerService.instance.networkError(error); + DialogService.instance.networkError(error); return Promise.reject(error); }) ) diff --git a/src/services/experiments/files/remote-experiment-files-service.js b/src/services/experiments/files/remote-experiment-files-service.js index c9625dfd3e824321a419847c877322edb857708e..7b85af51c074e92a6df2cfdcafec5eec66ae039d 100644 --- a/src/services/experiments/files/remote-experiment-files-service.js +++ b/src/services/experiments/files/remote-experiment-files-service.js @@ -1,6 +1,8 @@ import { HttpService } from '../../http-service.js'; import ExperimentStorageService from './experiment-storage-service'; import getMimeByExtension from '../../../utility/mime-type'; +import DialogService from '../../dialog-service'; +import browserName from '../../../utility/browser-name'; let _instance = null; const SINGLETON_ENFORCER = Symbol(); @@ -39,6 +41,14 @@ class RemoteExperimentFilesService extends HttpService { return window.showDirectoryPicker !== undefined && window.showDirectoryPicker !== null; } + notifyNotSupported() { + if (!this.isSupported()){ + DialogService.instance.warningNotification({ + message : 'The remote experiment file system is not supported on ' + browserName() + }); + } + } + toggleAutoSync() { this.autoSync = !this.autoSync; } diff --git a/src/services/models/__tests__/models-storage-service.test.js b/src/services/models/__tests__/models-storage-service.test.js new file mode 100644 index 0000000000000000000000000000000000000000..ab2aeff297ab7de12f1240f7fd4159509b967ac5 --- /dev/null +++ b/src/services/models/__tests__/models-storage-service.test.js @@ -0,0 +1,70 @@ +/** + * @jest-environment jsdom +*/ +import '@testing-library/jest-dom'; +import 'jest-fetch-mock'; + +import ModelsStorageService from '../models-storage-service'; + +test('makes sure that invoking the constructor fails with the right message', () => { + expect(() => { + new ModelsStorageService(); + }).toThrow(Error); + expect(() => { + new ModelsStorageService(); + }).toThrowError(Error('Use ModelsStorageService.instance')); +}); + +test('the service instance always refers to the same object', () => { + const instance1 = ModelsStorageService.instance; + const instance2 = ModelsStorageService.instance; + expect(instance1).toBe(instance2); +}); + + +test('getTemplateModels function', async () => { + + let modelsService = ModelsStorageService.instance; + + // fetch template robots + let response = await modelsService.getTemplateModels(true, 'robots', false); + expect(response.name).toBe('hbp_clearpath_robotics_husky_a200'); + expect(response.ownerName).toBe('nrpuser'); + expect(response.type).toBe('robots'); + + // fetch custom robots + response = await modelsService.getTemplateModels(true, 'robots', true); + expect(response.name).toBe('custom_hbp_clearpath_robotics_husky_a200'); + expect(response.ownerName).toBe('nrpuser'); + expect(response.type).toBe('robots'); +}); + +test('getCustomModelsByUser function', async () => { + + let modelsService = ModelsStorageService.instance; + + // fetch template robots + let response = await modelsService.getCustomModelsByUser('robots'); + expect(response.name).toBe('custom_hbp_clearpath_robotics_husky_a200'); + expect(response.ownerName).toBe('nrpuser'); + expect(response.type).toBe('robots'); + +}); + +test('verifyModelType function', async () => { + + let modelsService = ModelsStorageService.instance; + const expectedErrorPart = 'Error Type 400: Bad Request : The model type notRobots'; + // fetch template robots + expect(() => modelsService.verifyModelType('notRobots')).toThrowError(expectedErrorPart); + +}); + +test('setCustomModel function', async () => { + let modelsService = ModelsStorageService.instance; + + let response = await modelsService.setCustomModel('robots', 'husky', 'fakeContent'); + expect(response.name).toBe('created_hbp_clearpath_robotics_husky_a200'); + expect(response.ownerName).toBe('nrpuser'); + expect(response.type).toBe('robots'); +}); diff --git a/src/services/models/models-storage-service.js b/src/services/models/models-storage-service.js new file mode 100644 index 0000000000000000000000000000000000000000..f9ae2bff1f24161f1d9e6912308873d4045b95ba --- /dev/null +++ b/src/services/models/models-storage-service.js @@ -0,0 +1,145 @@ +import { HttpService } from '../http-service.js'; + +import endpoints from '../proxy/data/endpoints.json'; +import config from '../../config.json'; +import ErrorHandlerService from '../error-handler-service'; + +const storageModelsURL = `${config.api.proxy.url}${endpoints.proxy.models.url}`; +const allCustomModelsURL = `${config.api.proxy.url}${endpoints.proxy.storage.allCustomModels.url}`; +const userModelsURL = `${config.api.proxy.url}${endpoints.proxy.storage.userModels.url}`; + +let _instance = null; +const SINGLETON_ENFORCER = Symbol(); +const availableModels = ['robots', 'brains', 'environments']; +/** + * Service that manages the fetching and setting of custom and template + * models from the proxy. + */ +class ModelsStorageService extends HttpService { + constructor(enforcer) { + super(); + if (enforcer !== SINGLETON_ENFORCER) { + throw new Error('Use ' + this.constructor.name + '.instance'); + } + } + + static get instance() { + if (_instance == null) { + _instance = new ModelsStorageService(SINGLETON_ENFORCER); + } + + return _instance; + } + + /** + * Retrieves the list of template or custom models from the proxy and stores + * them in the models class property. If the models are already + * there it just returns them, else does an HTTP request. + * + * @param {boolean} forceUpdate forces an update of the list + * @param {string} modelType one of the types + * ['robots', 'brains', 'environments'] + * @param {boolean} allCustomModels if true fetch custom(user) models intead of templates + * @return models - the list of template models + */ + async getTemplateModels(forceUpdate = false, modelType, allCustomModels = false) { + if (!this.models || forceUpdate) { + try { + this.verifyModelType(modelType); + } + catch (error) { + ErrorHandlerService.instance.dataError(error); + } + + try { + const modelsWithTypeURL = allCustomModels ? + `${allCustomModelsURL}/${modelType}` : + `${storageModelsURL}/${modelType}`; + this.models = await (await this.httpRequestGET(modelsWithTypeURL)).json(); + } + catch (error) { + ErrorHandlerService.instance.networkError(error); + } + + } + + return this.models; + } + + /** + * Retrieves the list of custom models per user from the storage + * + * @param {string} modelType one of the types + * ['robots', 'brains', 'environments'] + * @return models - the list of custom user models + */ + async getCustomModelsByUser(modelType) { + try { + this.verifyModelType(modelType); + const customModelsURL = `${userModelsURL}/${modelType}`; + return (await this.httpRequestGET(customModelsURL)).json(); + } + catch (error) { + ErrorHandlerService.instance.networkError(error); + } + } + + /** + * Helper function that checks whether a specific type + * of model is in the list of available models. + * + * @param {string} modelType one of the types + * ['robots', 'brains', 'environments'] + */ + verifyModelType(modelType) { + if (!availableModels.includes(modelType)) { + throw new Error( + `Error Type 400: Bad Request : The model type ${modelType} + type that was requested is not one of brains, robots, environments.`); + } + } + + /** + * Deletes a custom model from the storage + * + * @param {string} modelType one of the types + * ['robots', 'brains', 'environments'] + * @param {string} modelName the name of the model to delete + * @return the response of the request + */ + + async deleteCustomModel(modelType, modelName) { + try { + this.verifyModelType(modelType); + const deleteCustomModelURL = `${userModelsURL}/${modelType}/${modelName}`; + return (await this.httpRequestDELETE(deleteCustomModelURL)).json(); + } + catch (error) { + ErrorHandlerService.instance.dataError(error); + } + } + + /** + * Sets a custom model to the storage + * + * @param {string} modelType one of the types + * ['robots', 'brains', 'environments'] + * @param {string} modelName the name of the model to delete + * @param fileContent the data of the model to upload + * @return the response of the request + */ + async setCustomModel(modelType, modelName, fileContent) { + + try { + this.verifyModelType(modelType); + const setCustomModelURL = `${storageModelsURL}/${modelType}/${modelName}`; + return (await this.httpRequestPOST(setCustomModelURL, fileContent)).json(); + } + catch (error) { + ErrorHandlerService.instance.networkError(error); + } + } +} + + +export default ModelsStorageService; diff --git a/src/services/proxy/data/endpoints.json b/src/services/proxy/data/endpoints.json index aa5c143975c1d800ae711fe54b13a30784c8122c..a2c1acd09cb0a2cd4a9ad8429d89db43003fe87c 100644 --- a/src/services/proxy/data/endpoints.json +++ b/src/services/proxy/data/endpoints.json @@ -9,6 +9,9 @@ "experiments": { "url": "/experiments" }, + "models": { + "url": "/models" + }, "identity": { "url": "/identity", "me": { @@ -37,6 +40,12 @@ }, "scanStorage": { "url": "/storage/scanStorage" + }, + "allCustomModels": { + "url": "/storage/models/all" + }, + "userModels": { + "url": "/storage/models/user" } } } diff --git a/src/utility/browser-name.js b/src/utility/browser-name.js new file mode 100644 index 0000000000000000000000000000000000000000..af1274f248c922cbd27dd58a94f908d96325f1bf --- /dev/null +++ b/src/utility/browser-name.js @@ -0,0 +1,37 @@ + +function browserName(){ + // CHROME + if (navigator.userAgent.indexOf('Chrome') !== -1 ) { + return 'Chrome'; + } + // FIREFOX + else if (navigator.userAgent.indexOf('Firefox') !== -1 ) { + return 'Firefox'; + } + // INTERNET EXPLORER + else if (navigator.userAgent.indexOf('MSIE') !== -1 ) { + return 'Internet Explorer'; + } + // EDGE + else if (navigator.userAgent.indexOf('Edge') !== -1 ) { + return 'Edge'; + } + // SAFARI + else if (navigator.userAgent.indexOf('Safari') !== -1 ) { + return 'Safari'; + } + // OPERA + else if (navigator.userAgent.indexOf('Opera') !== -1 ) { + return 'Opera'; + } + // YANDEX + else if (navigator.userAgent.indexOf('YaBrowser') !== -1 ) { + return 'YaBrowser'; + } + // OTHER + else { + return 'Other'; + } +} + +export default browserName; \ No newline at end of file