diff --git a/package-lock.json b/package-lock.json index 96c996405754fb834e6f3fd62590c3de693e0992..f4f72430163cf4f2afb3f02a08a46530b61a3457 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "nrp-frontend", - "version": "0.1.0", + "version": "4.0.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -10048,6 +10048,12 @@ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", "dev": true + }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true } } }, @@ -12005,6 +12011,12 @@ "which": "^2.0.2" }, "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "optional": true + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -15972,6 +15984,13 @@ "faye-websocket": "^0.11.3", "uuid": "^8.3.2", "websocket-driver": "^0.7.4" + }, + "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + } } }, "sockjs-client": { @@ -17231,9 +17250,9 @@ "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" }, "uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==" }, "v8-compile-cache": { "version": "2.3.0", diff --git a/package.json b/package.json index b8ed19bbae254e10428b48e902ab98b5f2b7f564..f8202c9354f2fa522358b59c6c6f857f5e98dbdc 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "src/services/experiments/execution/running-simulation-service.js", "src/services/roslib-service.js", "src/services/experiments/files/import-experiment-service.js", - "src/services/experiments/files/remote-experiment-files-service.js" + "src/services/experiments/files/remote-experiment-files-service.js", + "src/services/nrp-analytics-service.js" ] }, "version": "4.0.0", @@ -44,6 +45,7 @@ "react-tabs": "3.1.2", "roslib": "1.1.0", "rxjs": "6.6.3", + "uuid": "9.0.0", "web-vitals": "^0.2.4" }, "devDependencies": { diff --git a/src/components/constants.js b/src/components/constants.js new file mode 100644 index 0000000000000000000000000000000000000000..d73d9f7ab9cb121d7dcd55cb7f9c46d9f47bfdb3 --- /dev/null +++ b/src/components/constants.js @@ -0,0 +1,14 @@ +const CONSTANTS = Object.freeze({ + SIM_TOOL: { + CATEGORY: { + EXTERNAL_IFRAME: 'EXTERNAL_IFRAME', + REACT_COMPONENT: 'REACT_COMPONENT' + }, + TOOL_TYPE: { + FLEXLAYOUT_TAB: 'flexlayout-tab', + EXTERNAL_TAB: 'external-tab' + }} +}); + + +module.exports = CONSTANTS; \ No newline at end of file diff --git a/src/components/entry-page/entry-page.js b/src/components/entry-page/entry-page.js index 6a3c88def7eb9d53b2e168d714ac706ea216a3e1..bb74203fe318c206d1cc0d008e4e694712675320 100644 --- a/src/components/entry-page/entry-page.js +++ b/src/components/entry-page/entry-page.js @@ -1,5 +1,4 @@ import React from 'react'; -import Grid from '@material-ui/core/Grid'; import NrpHeader from '../nrp-header/nrp-header.js'; @@ -35,7 +34,8 @@ export default class EntryPage extends React.Component { <div className='nrp-core-dashboard sidebar-left'> <NrpCoreDashboard /> </div > - <iframe className='nrp-news sidebar-right' src='https://neurorobotics.net/latest.html' /> + <iframe title='neurorobotics-news' className='nrp-news sidebar-right' + src='https://neurorobotics.net/latest.html' /> {/*<TransceiverFunctionEditor experimentId='mqtt_simple_1'/>*/} </div> ); diff --git a/src/components/experiment-list/experiment-list-element.js b/src/components/experiment-list/experiment-list-element.js index 5e690b7f26659a443bbbe28ea8ff77d84dfd53c8..3180a68655d1d01bfe0f85f8b0dd7976277ae9d5 100644 --- a/src/components/experiment-list/experiment-list-element.js +++ b/src/components/experiment-list/experiment-list-element.js @@ -1,7 +1,7 @@ import React from 'react'; // import { Link, useHistory } from 'react-router-dom'; import { withRouter } from 'react-router-dom'; -import { FaTrash, FaFileExport, FaShareAlt, FaClone, FaBullseye, FaLastfmSquare } from 'react-icons/fa'; +import { FaTrash, FaFileExport, FaShareAlt, FaClone } from 'react-icons/fa'; // import { MdOutlineDownloadDone } from 'react-icons/md'; // import { RiPlayFill, RiPlayLine, RiPlayList2Fill } from 'react-icons/ri'; // import { GoX } from 'react-icons/go'; @@ -49,7 +49,7 @@ class ExperimentListElement extends React.Component { } async componentDidMount() { - this.state.edibleName = this.state.visibleName; + this.setState({ edibleName: this.state.visibleName }); this.handleClickOutside = this.handleClickOutside.bind(this); document.addEventListener('mousedown', this.handleClickOutside); } diff --git a/src/components/experiment-list/simulation-details.js b/src/components/experiment-list/simulation-details.js index fba5b183b81a0a1ea8f12d6627f8a84ca674a9ea..66d2973756d5df0b034919a8f6bb0ebbde0bb643 100644 --- a/src/components/experiment-list/simulation-details.js +++ b/src/components/experiment-list/simulation-details.js @@ -1,5 +1,5 @@ import React from 'react'; -import { FaStop, FaStopCircle } from 'react-icons/fa'; +import { FaStop } from 'react-icons/fa'; import { ImEnter } from 'react-icons/im'; import { withRouter } from 'react-router-dom'; diff --git a/src/components/experiment-workbench/experiment-tools-service.js b/src/components/experiment-workbench/experiment-tools-service.js index bf3d401277b628bda94e602c9dde1e2cac7cf773..aa98743f45db90fbb5a9d099c3ec62c9a4b3cd36 100644 --- a/src/components/experiment-workbench/experiment-tools-service.js +++ b/src/components/experiment-workbench/experiment-tools-service.js @@ -1,10 +1,13 @@ -import { Description } from '@material-ui/icons'; -import NrpCoreDashboard from '../nrp-core-dashboard/nrp-core-dashboard'; -import TransceiverFunctionEditor from '../tf-editor/tf-editor'; +import FlexLayout from 'flexlayout-react'; import DescriptionIcon from '@material-ui/icons/Description'; import ListAltIcon from '@material-ui/icons/ListAlt'; +import NrpCoreDashboard from '../nrp-core-dashboard/nrp-core-dashboard'; +import TransceiverFunctionEditor from '../tf-editor/tf-editor'; +import XpraView from '../xpra/xpra-view'; +import { SIM_TOOL } from '../constants'; + let _instance = null; const SINGLETON_ENFORCER = Symbol(); @@ -23,6 +26,7 @@ class ExperimentToolsService { this.registerToolConfig(ExperimentToolsService.TOOLS[toolEntry]); } this.registerToolConfig(NrpCoreDashboard.CONSTANTS.TOOL_CONFIG); + this.registerToolConfig(XpraView.CONSTANTS.TOOL_CONFIG); } static get instance() { @@ -33,6 +37,10 @@ class ExperimentToolsService { return _instance; } + setFlexLayoutModel(model) { + this.flexLayoutModel = model; + } + registerToolConfig(toolConfig) { let id = toolConfig.flexlayoutNode.component; if (this.tools.has(id)) { @@ -49,28 +57,57 @@ class ExperimentToolsService { if (toolConfig && toolConfig.flexlayoutFactoryCb) { return toolConfig.flexlayoutFactoryCb(); } - - if (component === 'button') { - return <button>{node.getName()}</button>; + else { + console.error('tool config for "' + component + '" is missing a callback for creating a flex-layout window!'); } - else if (component === 'tab') { - return component.flexlayoutFactoryCb(); + } + + startToolDrag(toolConfig, layoutReference) { + let instances = this.getComponentInstanceList( + toolConfig.flexlayoutNode.component, + layoutReference.current.previousModel); + if (toolConfig.singleton && instances.length > 0) { + layoutReference.current.doAction(FlexLayout.Actions.selectTab(instances[0].getId())); } - else if (component === 'nest_wiki') { - return <iframe src='https://en.wikipedia.org/wiki/NEST_(software)' title='nest_wiki' - className='flexlayout-iframe'></iframe>; + else { + layoutReference.current.addTabWithDragAndDrop(toolConfig.flexlayoutNode.name, toolConfig.flexlayoutNode); } } - startToolDrag(flexlayoutNode, layoutReference) { - layoutReference.current.addTabWithDragAndDrop(flexlayoutNode.name, flexlayoutNode); + addTool(toolConfig, layoutReference) { + let instances = this.getComponentInstanceList( + toolConfig.flexlayoutNode.component, + layoutReference.current.previousModel); + if (toolConfig.singleton && instances.length > 0) { + layoutReference.current.doAction(FlexLayout.Actions.selectTab(instances[0].getId())); + } + else { + layoutReference.current.addTabToActiveTabSet(toolConfig.flexlayoutNode); + } } - addTool(flexlayoutNode, layoutReference) { - layoutReference.current.addTabToActiveTabSet(flexlayoutNode); + getComponentInstanceList(flexLayoutComponent, flexLayoutModel) { + let list = []; + flexLayoutModel.visitNodes((node, level) => { + if (node._attributes.component === flexLayoutComponent) { + list.push(node); + } + }); + return list; } } +ExperimentToolsService.CONSTANTS = Object.freeze({ + /*CATEGORY: { + EXTERNAL_IFRAME: 'EXTERNAL_IFRAME', + REACT_COMPONENT: 'REACT_COMPONENT' + }, + TOOL_TYPE: { + FLEXLAYOUT_TAB: 'flexlayout-tab', + EXTERNAL_TAB: 'external-tab' + }*/ +}); + ExperimentToolsService.TOOLS = Object.freeze({ // NEST_DESKTOP: { // singleton: true, @@ -91,9 +128,9 @@ ExperimentToolsService.TOOLS = Object.freeze({ // } // }, TEST_NRP_CORE_DOCU: { - singleton: true, + singleton: false, + type: SIM_TOOL.TOOL_TYPE.FLEXLAYOUT_TAB, flexlayoutNode: { - 'type': 'tab', 'name': 'NRP-Core Docs', 'component': 'nrp-core-docu' }, @@ -103,12 +140,50 @@ ExperimentToolsService.TOOLS = Object.freeze({ }, getIcon: () => { return <DescriptionIcon/>; + }, + isShown: () => { + return true; } }, + /*XPRA_EXTERNAL_TAB: { + singleton: true, + type: ExperimentToolsService.CONSTANTS.TOOL_TYPE.EXTERNAL_TAB, + flexlayoutNode: { + 'name': 'Xpra', + 'component': 'xpra-external' + }, + getIcon: () => { + return <div> + <a href='http://localhost:9000/xpra/index.html' target='_blank' rel="noreferrer"> + <img src={'https://www.xpra.org/icons/xpra-logo.png'} + alt="Xpra" + style={{width: 40+ 'px', height: 20 + 'px'}} /> + </a> + </div>; + } + },*/ + /*XPRA: { + singleton: true, + type: ExperimentToolsService.CONSTANTS.TOOL_TYPE.FLEXLAYOUT_TAB, + flexlayoutNode: { + 'name': 'Server Videostream (Xpra)', + 'component': 'xpra' + }, + flexlayoutFactoryCb: () => { + return <XpraView />; + }, + getIcon: () => { + return <OndemandVideoIcon />; + }, + isShown: () => { + const xpra = ExperimentWorkbenchService.instance.xpraUrls; + return xpra && xpra.length > 0; + } + },*/ TRANSCEIVER_FUNCTIONS_EDITOR: { singleton: true, + type: SIM_TOOL.TOOL_TYPE.FLEXLAYOUT_TAB, flexlayoutNode: { - 'type': 'tab', 'name': 'Edit experiment files', 'component': 'TransceiverFunctionEditor' }, @@ -117,13 +192,10 @@ ExperimentToolsService.TOOLS = Object.freeze({ }, getIcon: () => { return <ListAltIcon/>; + }, + isShown: () => { + return true; } - }}); - -ExperimentToolsService.CONSTANTS = Object.freeze({ - CATEGORY: { - EXTERNAL_IFRAME: 'EXTERNAL_IFRAME', - REACT_COMPONENT: 'REACT_COMPONENT' } }); diff --git a/src/components/experiment-workbench/experiment-workbench-service.js b/src/components/experiment-workbench/experiment-workbench-service.js index 4a9edc07d3812bd5ebe107089f468baf250c0437..362c13dd306476ebddc5e6448df1b621e8b97b03 100644 --- a/src/components/experiment-workbench/experiment-workbench-service.js +++ b/src/components/experiment-workbench/experiment-workbench-service.js @@ -2,6 +2,9 @@ import { EventEmitter } from 'events'; import MqttClientService from '../../services/mqtt-client-service'; import DialogService from '../../services/dialog-service'; +import ExperimentStorageService from '../../services/experiments/files/experiment-storage-service'; +import ServerResourcesService from '../../services/experiments/execution/server-resources-service.js'; +import { EXPERIMENT_STATE } from '../../services/experiments/experiment-constants'; let _instance = null; const SINGLETON_ENFORCER = Symbol(); @@ -19,6 +22,8 @@ class ExperimentWorkbenchService extends EventEmitter { this._serverURL = undefined; this._errorToken = undefined; this._statusToken = undefined; + this._xpraUrlsConfig = []; + this._xpraUrlsConfirmed = []; } static get instance() { @@ -34,7 +39,7 @@ class ExperimentWorkbenchService extends EventEmitter { } set experimentInfo(info) { this._expInfo = info; - console.info(['ExperimentWorkbenchService - experimentInfo', this._expInfo]); + //console.info(['ExperimentWorkbenchService - experimentInfo', this._expInfo]); } get experimentID() { @@ -42,7 +47,7 @@ class ExperimentWorkbenchService extends EventEmitter { } set experimentID(experimentID) { this._experimentID = experimentID; - console.info(['ExperimentWorkbenchService - experimentID', this._experimentID]); + //console.info(['ExperimentWorkbenchService - experimentID', this._experimentID]); } get serverURL() { @@ -50,7 +55,14 @@ class ExperimentWorkbenchService extends EventEmitter { } set serverURL(serverURL) { this._serverURL = serverURL; - console.info(['ExperimentWorkbenchService - serverURL', this._serverURL]); + //console.info(['ExperimentWorkbenchService - serverURL', this._serverURL]); + } + + get simulationState() { + return this._simulationState; + } + set simulationState(state) { + this._simulationState = state; } /** @@ -64,7 +76,7 @@ class ExperimentWorkbenchService extends EventEmitter { } set simulationInfo(simulationInfo) { this._simulationInfo = simulationInfo; - console.info(['ExperimentWorkbenchService - simulationInfo', this._simulationInfo]); + //console.info(['ExperimentWorkbenchService - simulationInfo', this._simulationInfo]); ExperimentWorkbenchService.instance.emit( ExperimentWorkbenchService.EVENTS.SIMULATION_SET, this._simulationInfo @@ -72,6 +84,61 @@ class ExperimentWorkbenchService extends EventEmitter { this.setTopics(this._simulationInfo); } + get xpraConfigUrls() { + return this._xpraUrlsConfig; + } + set xpraConfigUrls(urls) { + this._xpraUrlsConfig = urls; + //console.info(['ExperimentWorkbenchService - xpraUrls', this._xpraConfigUrls]); + } + + get xpraUrls() { + return this._xpraUrlsConfirmed; + } + + async confirmXpraUrls() { + const simState = this.simulationState; + if (!this._xpraUrlsConfig || this._xpraUrlsConfig.length === 0 + || !simState || simState === EXPERIMENT_STATE.CREATED + || simState === EXPERIMENT_STATE.UNDEFINED || simState === EXPERIMENT_STATE.FAILED) { + return; + } + else { + let confirmedUrls = []; + for (let url of this._xpraUrlsConfig) { + const response = await fetch(url, { method: 'GET' }); + if (response.ok) { + confirmedUrls.push(url); + } + } + this._xpraUrlsConfirmed = confirmedUrls; + } + } + + /** + * Gather experiment information. + * @param experimentID The experiment's ID + */ + async initExperimentInformation(experimentID) { + let experiments = await ExperimentStorageService.instance.getExperiments(); + const experimentInfo = experiments.find(experiment => experiment.id === experimentID); + this.experimentInfo = experimentInfo; + + if (experimentInfo.joinableServers.length === 1) { + const runningSimulation = experimentInfo.joinableServers[0].runningSimulation; + if (runningSimulation) { + let serverConfig = await ServerResourcesService.instance.getServerConfig( + experimentInfo.joinableServers[0].server); + this.serverURL = serverConfig['nrp-services']; + this.xpraConfigUrls = [serverConfig.xpra]; + this.simulationInfo = { + ID: runningSimulation.simulationID, + MQTTPrefix: runningSimulation.MQTTPrefix + }; + } + } + } + /** * Returns the MQTT broker connection status * @returns {boolean} the MQTT broker connection status @@ -144,6 +211,9 @@ class ExperimentWorkbenchService extends EventEmitter { try { const status = JSON.parse(msg); if (status.state) { + this.simulationState = status.state; + this.confirmXpraUrls(); + ExperimentWorkbenchService.instance.emit( ExperimentWorkbenchService.EVENTS.SIMULATION_STATUS_UPDATED, status diff --git a/src/components/experiment-workbench/experiment-workbench.js b/src/components/experiment-workbench/experiment-workbench.js index 58bbed9ed9e5336101691900302180631776a96a..6b2c1677d6526aacd6e47f82f8567673c8b64364 100644 --- a/src/components/experiment-workbench/experiment-workbench.js +++ b/src/components/experiment-workbench/experiment-workbench.js @@ -4,15 +4,13 @@ import FlexLayout from 'flexlayout-react'; import ExperimentToolsService from './experiment-tools-service'; import ExperimentWorkbenchService from './experiment-workbench-service'; import ExperimentTimeBox from './experiment-time-box'; -import ExperimentStorageService from '../../services/experiments/files/experiment-storage-service'; import SimulationService from '../../services/experiments/execution/running-simulation-service'; import ExperimentExecutionService from '../../services/experiments/execution/experiment-execution-service'; import ServerResourcesService from '../../services/experiments/execution/server-resources-service.js'; import DialogService from '../../services/dialog-service'; import { EXPERIMENT_STATE, EXPERIMENT_FINAL_STATE } from '../../services/experiments/experiment-constants'; -import timeDDHHMMSS from '../../utility/time-filter'; - import LeaveWorkbenchDialog from './leave-workbench-dialog'; +import { SIM_TOOL } from '../constants'; import '../../../node_modules/flexlayout-react/style/light.css'; import './experiment-workbench.css'; @@ -33,6 +31,7 @@ import Divider from '@material-ui/core/Divider'; import List from '@material-ui/core/List'; import Grid from '@material-ui/core/Grid'; import Paper from '@material-ui/core/Paper'; +import Tooltip from '@material-ui/core/Tooltip'; import ListItem from '@material-ui/core/ListItem'; import ListItemIcon from '@material-ui/core/ListItemIcon'; @@ -180,6 +179,7 @@ class ExperimentWorkbench extends React.Component { const {experimentID} = props.match.params; this.experimentID = experimentID; + ExperimentWorkbenchService.instance.experimentID = this.experimentID; this.serverURL = ExperimentWorkbenchService.instance.serverURL; this.state = { @@ -196,33 +196,28 @@ class ExperimentWorkbench extends React.Component { availableServers: [] }; - ExperimentWorkbenchService.instance.experimentID = this.experimentID; - this.refLayout = React.createRef(); this.state.modelFlexLayout.doAction(FlexLayout.Actions.setActiveTabset('defaultTabset')); + ExperimentToolsService.instance.setFlexLayoutModel(this.state.modelFlexLayout); } - async UNSAFE_componentWillMount() { - let experiments = await ExperimentStorageService.instance.getExperiments(); - const experimentInfo = experiments.find(experiment => experiment.id === this.experimentID); - ExperimentWorkbenchService.instance.experimentInfo = experimentInfo; - - this.setState({experimentConfiguration: experimentInfo.configuration}); - }; - async componentDidMount() { - // Get the simulation ID from ExperimentWorkbenchService, if is defined (for joining the simulation) + await ExperimentWorkbenchService.instance.initExperimentInformation(this.experimentID); + + if (ExperimentWorkbenchService.instance.experimentInfo) { + this.setState({experimentConfiguration: ExperimentWorkbenchService.instance.experimentInfo.configuration}); + } if (ExperimentWorkbenchService.instance.simulationInfo !== undefined) { - this.state.runningSimulationID = ExperimentWorkbenchService.instance.simulationInfo.ID; + this.setState({ runningSimulationID: ExperimentWorkbenchService.instance.simulationInfo.ID }); } // Update simulation state, if it is defined if (this.state.runningSimulationID !== undefined) { await SimulationService.instance.getInfo( - this.serverURL, + ExperimentWorkbenchService.instance.serverURL, this.state.runningSimulationID ).then((simInfo) => { - this.setState({ simulationState: simInfo.state}); + simInfo && this.setState({ simulationState: simInfo.state}); }); } @@ -233,7 +228,7 @@ class ExperimentWorkbench extends React.Component { ); // update the list of available servers - this.state.availableServers = await ServerResourcesService.instance.getServerAvailability(); + this.setState({ availableServers: await ServerResourcesService.instance.getServerAvailability() }); // subscribe to server availablility ServerResourcesService.instance.addListener( @@ -318,8 +313,13 @@ class ExperimentWorkbench extends React.Component { this.setState({ simulationState: undefined }); await ExperimentExecutionService.instance.startNewExperiment( ExperimentWorkbenchService.instance.experimentInfo - ).then(async (simRespose) => { - const simInfo = await simRespose['simulation'].json(); + ).then(async (simResponse) => { + if (typeof simResponse === 'undefined') { + console.error('startNewExperiment() returned with simResponse === undefined'); + return; + } + + const simInfo = await simResponse.simulation.json(); // TODO: get proper simulation information if (simInfo) { ExperimentWorkbenchService.instance.simulationInfo = { @@ -333,8 +333,8 @@ class ExperimentWorkbench extends React.Component { else { throw new Error('Could not parse the response from the backend after initializing the simulation'); } - ExperimentWorkbenchService.instance.serverURL = simRespose['serverURL']; - this.serverURL = simRespose['serverURL']; + ExperimentWorkbenchService.instance.serverURL = simResponse['serverURL']; + this.serverURL = simResponse['serverURL']; }).catch((failure) => { DialogService.instance.simulationError({ message: failure }); }); @@ -375,7 +375,7 @@ class ExperimentWorkbench extends React.Component { if (this.state.runningSimulationID !== undefined) { this.setState({ simStateLoading: true }); await SimulationService.instance.updateState( - this.serverURL, + ExperimentWorkbenchService.instance.serverURL, this.state.runningSimulationID, newState ).then((simInfo) => { @@ -542,25 +542,35 @@ class ExperimentWorkbench extends React.Component { <Divider /> <List> {Array.from(ExperimentToolsService.instance.tools.values()).map((tool, index) => { - return ( - <ListItem button key={index} - onMouseDown={() => { - ExperimentToolsService.instance.startToolDrag( - tool.flexlayoutNode, - this.refLayout); - }} - onClick={() => { - ExperimentToolsService.instance.addTool( - tool.flexlayoutNode, - this.refLayout); - }} - > - <ListItemIcon > - {tool.getIcon()} - </ListItemIcon> - <ListItemText primary={tool.flexlayoutNode.name} /> - </ListItem> - ); + if (typeof tool.isShown !== 'undefined' && tool.isShown()) { + return ( + tool.type === SIM_TOOL.TOOL_TYPE.EXTERNAL_TAB ? + <ListItem button key={index} + disabled={typeof tool.isDisabled !== 'undefined' && tool.isDisabled()}> + <ListItemIcon >{tool.getIcon()}</ListItemIcon> + <ListItemText primary={tool.flexlayoutNode.name} /> + </ListItem> + : + <ListItem button key={index} + disabled={typeof tool.isDisabled !== 'undefined' && tool.isDisabled()} + onMouseDown={() => { + ExperimentToolsService.instance.startToolDrag( + tool, + this.refLayout); + }} + onClick={() => { + ExperimentToolsService.instance.addTool( + tool, + this.refLayout); + }} + > + <Tooltip title={tool.flexlayoutNode.name} placement="right"> + <ListItemIcon >{tool.getIcon()}</ListItemIcon> + </Tooltip> + <ListItemText primary={tool.flexlayoutNode.name} /> + </ListItem> + ); + } })} </List> </Drawer> diff --git a/src/components/experiments-overview/experiments-overview.js b/src/components/experiments-overview/experiments-overview.js index abebaaf199e49d6bc7e908440f1104404514bb11..bb2a4dbd27903b3219046ade0016fdc1e9e5d0fa 100644 --- a/src/components/experiments-overview/experiments-overview.js +++ b/src/components/experiments-overview/experiments-overview.js @@ -71,7 +71,7 @@ export default class ExperimentsOverview extends React.Component { this.onUpdateServerAvailability ); - ServerResourcesService.instance.removeListener( + ExperimentExecutionService.instance.removeListener( ExperimentExecutionService.EVENTS.START_EXPERIMENT, this.onStartExperiment ); diff --git a/src/components/xpra/xpra-view.css b/src/components/xpra/xpra-view.css new file mode 100644 index 0000000000000000000000000000000000000000..4b01ed1ad9f85b167e103aa0b536d8ad39783993 --- /dev/null +++ b/src/components/xpra/xpra-view.css @@ -0,0 +1,18 @@ +.xpra-view-wrapper { + display: flex; + flex-direction: column; + height: 100%; +} + +.xpra-url-selector-header { + display: flex; + flex-direction: row; +} + +.xpra-url-dropdown-selector { + margin-left: 10px; +} + +.note-no-streams { + margin: 20px; +} \ No newline at end of file diff --git a/src/components/xpra/xpra-view.js b/src/components/xpra/xpra-view.js new file mode 100644 index 0000000000000000000000000000000000000000..4dc439b72a5ea60cc8507a745ffe9cca8b3c24b3 --- /dev/null +++ b/src/components/xpra/xpra-view.js @@ -0,0 +1,114 @@ +import React from 'react'; +import OndemandVideoIcon from '@material-ui/icons/OndemandVideo'; + +import ExperimentWorkbenchService from '../experiment-workbench/experiment-workbench-service'; +import RunningSimulationService from '../../services/experiments/execution/running-simulation-service'; +import { SIM_TOOL } from '../constants'; +import { EXPERIMENT_STATE } from '../../services/experiments/experiment-constants'; + +import './xpra-view.css'; + +export default class XpraView extends React.Component { + constructor() { + super(); + + this.state = { + xpraUrls: ExperimentWorkbenchService.instance.xpraUrls, + currentUrl: undefined + }; + if (this.state.xpraUrls.length > 0) { + this.state.currentUrl = this.state.xpraUrls[0]; + } + + ExperimentWorkbenchService.instance.on( + ExperimentWorkbenchService.EVENTS.SIMULATION_STATUS_UPDATED, + (status) => { + this.simulationState = status.state; + if (status.state === EXPERIMENT_STATE.PAUSED || status.state === EXPERIMENT_STATE.STARTED) { + if (ExperimentWorkbenchService.instance.xpraUrls.length > 0) { + this.setState({ + xpraUrls: ExperimentWorkbenchService.instance.xpraUrls, + currentUrl: ExperimentWorkbenchService.instance.xpraUrls[0] + }); + } + } + } + ); + } + + onChangeSelectedXpraUrl(event) { + this.setState({ + currentUrl: event.target.value + }); + } + + render() { + return ( + <div className='xpra-view-wrapper'> + {this.state.currentUrl ? + <div style={{height: '100%'}}> + <div className='xpra-url-selector-header'> + <div>Streaming Engine:</div> + <select + className='xpra-url-dropdown-selector' + name="selectXpraUrl" + value={this.state.currentUrl} + onChange={(event) => this.onChangeSelectedXpraUrl(event)}> + {this.state.xpraUrls.map(url => { + return (<option key={url} value={url}>{url}</option>); + })} + </select> + </div> + <iframe src={this.state.currentUrl + '?printing=No&file_transfer=No&floating_menu=No&sound=No'} + title='Xpra' /> + </div> + : + <div className='note-no-streams'>No streams available, maybe no simulation has been started yet?</div> + } + </div> + ); + } +} + +XpraView.CONSTANTS = Object.freeze({ + TOOL_CONFIG: { + singleton: true, + type: SIM_TOOL.TOOL_TYPE.FLEXLAYOUT_TAB, + flexlayoutNode: { + 'name': 'Server Videostream (Xpra)', + 'component': 'xpra' + }, + flexlayoutFactoryCb: () => { + return <XpraView />; + }, + getIcon: () => { + return <OndemandVideoIcon />; + }, + isShown: () => { + if (!ExperimentWorkbenchService.instance.experimentInfo) { + return false; + } + + // this is not really clean to parse the engine configs here in the frontend, + // but necessary until the proxy can provide information with the experiment config / simulation start + const engineConfigs = ExperimentWorkbenchService.instance.experimentInfo.configuration.EngineConfigs; + let show = false; + for (let config of engineConfigs) { + if (config.EngineProcCmd && config.EngineProcCmd.includes('/usr/xpra-entrypoint.sh')) { + show = true; + } + } + + return show; + }, + isDisabled: () => { + const xpraUrls = ExperimentWorkbenchService.instance.xpraUrls; + if (!xpraUrls || xpraUrls.length === 0) { + return true; + } + else { + return false; + } + } + } +}); diff --git a/src/mocks/mock_available-servers.json b/src/mocks/mock_available-servers.json index 7b0c1d99e405d23217e08c0d0268525eee976913..c3a26979cc6779cc38531f8e2a8f933b7dcd8cda 100644 --- a/src/mocks/mock_available-servers.json +++ b/src/mocks/mock_available-servers.json @@ -3,12 +3,14 @@ "internalIp": "http://localhost:8080", "nrp-services": "http://localhost:8080", "serverJobLocation": "local", - "id": "localhost" + "id": "localhost", + "xpra": "localhost:1234/xpra/index.html" }, { "internalIp": "http://1.2.3.4:8080", "nrp-services": "http://1.2.3.4:8080", "serverJobLocation": "1.2.3.4", - "id": "1.2.3.4-port-8080" + "id": "1.2.3.4-port-8080", + "xpra": "http://1.2.3.4:8080/xpra/index.html" } ] \ No newline at end of file diff --git a/src/services/experiments/execution/__tests__/experiment-execution-service.test.js b/src/services/experiments/execution/__tests__/experiment-execution-service.test.js index 239e09c514381b038966c1b02826ec98b7145b31..eae0de05d3f10df6068f11cc6de14fbd2b864490 100644 --- a/src/services/experiments/execution/__tests__/experiment-execution-service.test.js +++ b/src/services/experiments/execution/__tests__/experiment-execution-service.test.js @@ -13,18 +13,8 @@ import ExperimentExecutionService from '../../../../services/experiments/executi import SimulationService from '../../../../services/experiments/execution/running-simulation-service'; import ServerResourcesService from '../../../../services/experiments/execution/server-resources-service'; import { EXPERIMENT_STATE } from '../../../../services/experiments/experiment-constants.js'; -import config from '../../../../config.json'; jest.mock('../../../authentication-service.js'); -//jest.setTimeout(10000); - -beforeEach(() => { - //jest.genMockFromModule('AuthenticationService'); - //jest.mock('AuthenticationService'); - if (config.auth.enableOIDC) { - jest.mock('../../../authentication-service.js'); - } -}); afterEach(() => { jest.restoreAllMocks(); @@ -67,34 +57,37 @@ describe('ExperimentExecutionService', () => { ); }); - test('should go through the list of available servers when trying to start an experiment', (done) => { + test('should go through the list of available servers when trying to start an experiment', async () => { + const targetServer = MockAvailableServers[MockAvailableServers.length-1]; jest.spyOn(console, 'error').mockImplementation(); ServerResourcesService.instance.availableServers = MockAvailableServers; - jest.spyOn(ServerResourcesService.instance, 'getServerConfig').mockImplementation((server) =>{ - if (server===MockAvailableServers[-1]) { - return Promise.resolve(); + jest.spyOn(ServerResourcesService.instance, 'getServerAvailability').mockImplementation((server) =>{ + return Promise.resolve(MockAvailableServers); + }); + jest.spyOn(ServerResourcesService.instance, 'getServerConfig').mockImplementation((serverId) =>{ + if (serverId === targetServer.id) { + return Promise.resolve(targetServer); } else { return Promise.reject(); } - } - ); - let properServerID; + }); + let properServerID; jest.spyOn(ExperimentExecutionService.instance, 'launchExperimentOnServer').mockImplementation( // only the last server in the list will return a successful launch - (id,privateparam,configFile,serverID,serverConfig, progressCallback) => { + (id, privateparam, configFile, serverID, serverConfig, progressCallback) => { properServerID = serverID; return Promise.resolve(); } ); + let experiment = MockExperiments[0]; - ExperimentExecutionService.instance.startNewExperiment(experiment).then(() => { + await ExperimentExecutionService.instance.startNewExperiment(experiment).then(() => { MockAvailableServers.forEach(server => { expect(ServerResourcesService.instance.getServerConfig).toHaveBeenCalledWith(server.id); }); - expect(properServerID).toBe(MockAvailableServers[-1]); - done(); + expect(properServerID).toBe(targetServer.id); }); }); diff --git a/src/services/experiments/execution/experiment-execution-service.js b/src/services/experiments/execution/experiment-execution-service.js index 6f5af6c992472f29c09a94847d70efdacb831bfc..027e8607de69a1714cf10d17bf8a16ced349f1d5 100644 --- a/src/services/experiments/execution/experiment-execution-service.js +++ b/src/services/experiments/execution/experiment-execution-service.js @@ -6,6 +6,7 @@ import SimulationService from './running-simulation-service.js'; import DialogService from '../../dialog-service'; import { HttpService } from '../../http-service.js'; import { EXPERIMENT_STATE } from '../experiment-constants.js'; +import ExperimentWorkbenchService from '../../../components/experiment-workbench/experiment-workbench-service.js'; let _instance = null; const SINGLETON_ENFORCER = Symbol(); @@ -43,7 +44,7 @@ class ExperimentExecutionService extends HttpService { //NrpAnalyticsService.instance.tickDurationEvent('Server-initialization'); ExperimentExecutionService.instance.emit(ExperimentExecutionService.EVENTS.START_EXPERIMENT, experiment); - let serversToTry = experiment.devServer + let serverIdsToTry = experiment.devServer ? [experiment.devServer] : (await ServerResourcesService.instance.getServerAvailability(true)) .map(s => s.id); @@ -64,11 +65,11 @@ class ExperimentExecutionService extends HttpService { }; let serverConfig; let serverFound = false; - let serverID; - for (let server of serversToTry){ + let targetServerID; + for (let serverId of serverIdsToTry){ try { - serverConfig = await ServerResourcesService.instance.getServerConfig(server); - serverID = server; + serverConfig = await ServerResourcesService.instance.getServerConfig(serverId); + targetServerID = serverId; serverFound = true; break; } @@ -81,10 +82,13 @@ class ExperimentExecutionService extends HttpService { experiment.id, experiment.private, experiment.configuration.configFile, - serverID, + targetServerID, serverConfig, progressCallback - ).catch((failure) => { + ).then(success => { + ExperimentWorkbenchService.instance.xpraConfigUrls = [serverConfig.xpra]; + return success; + }).catch((failure) => { if (failure && failure.isFatal) { return Promise.reject(ExperimentExecutionService.ERRORS.LAUNCH_FATAL_ERROR); @@ -109,7 +113,7 @@ class ExperimentExecutionService extends HttpService { * Try launching an experiment on a specific server. * @param {string} experimentID - ID of the experiment to launch * @param {boolean} privateExperiment - whether the experiment is private or not - * @param {string} configFile - experiment configuration file name + * @param {string} experimentConfigFileName - experiment configuration file name * @param {string} serverID - server ID * @param {object} serverConfiguration - configuration of server * @param {function} progressCallback - a callback for progress updates @@ -118,7 +122,7 @@ class ExperimentExecutionService extends HttpService { launchExperimentOnServer( experimentID, privateExperiment, - configFile, + experimentConfigFileName, serverID, serverConfiguration, progressCallback @@ -130,6 +134,7 @@ class ExperimentExecutionService extends HttpService { }); //called once caller has the promise let serverURL = serverConfiguration['nrp-services']; + //let serverURL = serverConfiguration.id; // Create a new simulation. // >>Request: @@ -142,13 +147,13 @@ class ExperimentExecutionService extends HttpService { // } let simInitData = { experimentID: experimentID, - experimentConfiguration: configFile, + experimentConfiguration: experimentConfigFileName, state: EXPERIMENT_STATE.CREATED, private: privateExperiment }; this.httpRequestPOST(serverURL + '/simulation', JSON.stringify(simInitData)) - .then((simulation) => { - resolve({'simulation': simulation, 'serverURL': serverURL}); + .then((response) => { + resolve({'simulation': response, 'serverURL': serverURL}); }) .catch(reject); // <<Response: simulation diff --git a/src/services/mqtt-client-service.js b/src/services/mqtt-client-service.js index 2f5dcfd2942bee58086bab57614b244e1f1bb121..b72f0cb96797e719b876528614ccd7bf4e0c7106 100644 --- a/src/services/mqtt-client-service.js +++ b/src/services/mqtt-client-service.js @@ -1,4 +1,5 @@ import mqtt from 'mqtt'; +import { v4 as uuidv4 } from 'uuid'; import { EventEmitter } from 'events'; //import { DataPackMessage } from 'nrp-jsproto/engine_grpc_pb'; @@ -28,7 +29,7 @@ export default class MqttClientService extends EventEmitter { this.subTokensMap = new Map(); - // Since it's a- singleton, shoud the url be defined here? + // Since it's a singleton, shoud the url be defined here? const websocket_s = frontendConfig.mqtt.websocket ? frontendConfig.mqtt.websocket : 'ws'; this.mqttBrokerUrl = websocket_s + '://' + frontendConfig.mqtt.url + ':' + frontendConfig.mqtt.port; @@ -57,7 +58,7 @@ export default class MqttClientService extends EventEmitter { connect() { console.info('MQTT connecting to ' + this.mqttBrokerUrl + ' ...'); - this.client = mqtt.connect(this.mqttBrokerUrl, { clientId: 'nrp-frontend'}); + this.client = mqtt.connect(this.mqttBrokerUrl, { clientId: 'nrp-frontend_' + uuidv4() }); this.client.on('connect', () => { this.onConnect(); }); diff --git a/src/setupTests.js b/src/setupTests.js index e3fec7fd9dc3109840fb06f3b5580dd71ec02f86..b4dbbec28c208da85329240f724d49bd565c89c5 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -11,7 +11,6 @@ beforeAll(() => { // Enable the mocking in tests. server.listen(); jest.mock('./services/authentication-service.js'); - // AuthenticationService.instance.mockClear(); }); afterEach(() => {