diff --git a/src/App.js b/src/App.js index dad8a107abcd43ff12c53dfb175906423ba6e7a2..e2aa82627f3d8544c7612e1ea26a204a06c701ff 100644 --- a/src/App.js +++ b/src/App.js @@ -4,7 +4,8 @@ import { BrowserRouter, Switch, Route } from 'react-router-dom'; import EntryPage from './components/entry-page/entry-page'; import ErrorDialog from './components/dialog/error-dialog.js'; -import ExperimentOverview from './components/experiment-overview/experiment-overview'; +import ExperimentsOverview from './components/experiments-overview/experiments-overview'; +import ExperimentWorkbench from './components/experiment-workbench/experiment-workbench'; import SimulationView from './components/simulation-view/simulation-view'; import NotificationDialog from './components/dialog/notification-dialog.js'; //import MqttClientService from './services/nrp-core/mqtt-client-service'; @@ -22,7 +23,8 @@ class App extends React.Component { <NotificationDialog/> <BrowserRouter> <Switch> - <Route path='/experiments-overview' component={ExperimentOverview} /> + <Route path='/experiments-overview' component={ExperimentsOverview} /> + <Route path='/experiment/:experimentID' component={ExperimentWorkbench} /> <Route path='/simulation-view/:serverIP/:simulationID' component={SimulationView} /> <Route path='/' component={EntryPage} /> </Switch> diff --git a/src/components/experiment-list/experiment-list-element.js b/src/components/experiment-list/experiment-list-element.js index 01264d6cddb9f8c76ead5af1f93a6d7529375389..546f6ffe5bec7d450acf28e0fdb611f666b494ab 100644 --- a/src/components/experiment-list/experiment-list-element.js +++ b/src/components/experiment-list/experiment-list-element.js @@ -11,7 +11,7 @@ import PublicExperimentsService from '../../services/experiments/files/public-ex import ExperimentStorageService from '../../services/experiments/files/experiment-storage-service.js'; import SimulationDetails from './simulation-details'; -import ExperimentOverview from '../experiment-overview/experiment-overview.js'; +import ExperimentOverview from '../experiments-overview/experiments-overview.js'; import './experiment-list-element.css'; import '../main.css'; diff --git a/src/components/experiment-workbench/experiment-tools-service.js b/src/components/experiment-workbench/experiment-tools-service.js new file mode 100644 index 0000000000000000000000000000000000000000..1d16e533f7f3722ea40880c881adce782605bee0 --- /dev/null +++ b/src/components/experiment-workbench/experiment-tools-service.js @@ -0,0 +1,109 @@ +import NrpCoreDashboard from '../nrp-core-dashboard/nrp-core-dashboard'; + + +let _instance = null; +const SINGLETON_ENFORCER = Symbol(); + +/** + * Service handling server resources for simulating experiments. + */ +class SimulationToolsService { + constructor(enforcer) { + if (enforcer !== SINGLETON_ENFORCER) { + throw new Error('Use ' + this.constructor.name + '.instance'); + } + + this.tools = new Map(); + for (const toolEntry in SimulationToolsService.TOOLS) { + this.registerToolConfig(SimulationToolsService.TOOLS[toolEntry]); + } + this.registerToolConfig(NrpCoreDashboard.CONSTANTS.TOOL_CONFIG); + } + + static get instance() { + if (_instance == null) { + _instance = new SimulationToolsService(SINGLETON_ENFORCER); + } + + return _instance; + } + + registerToolConfig(toolConfig) { + console.info('registerToolConfig'); + console.info(toolConfig); + let id = toolConfig.flexlayoutNode.component; + if (this.tools.has(id)) { + console.warn('SimulationToolsService.registerToolConfig() - tool with ID ' + id + ' already exists'); + return; + } + + this.tools.set(id, toolConfig); + } + + flexlayoutNodeFactory(node) { + var component = node.getComponent(); + + let toolConfig = this.tools.get(component); + if (toolConfig && toolConfig.flexlayoutFactoryCb) { + return toolConfig.flexlayoutFactoryCb(); + } + + if (component === 'button') { + return <button>{node.getName()}</button>; + } + else if (component === 'nest_wiki') { + return <iframe src='https://en.wikipedia.org/wiki/NEST_(software)' title='nest_wiki' + className='flexlayout-iframe'></iframe>; + } + } + + startToolDrag(flexlayoutNode, layoutReference) { + layoutReference.current.addTabWithDragAndDrop(flexlayoutNode.name, flexlayoutNode); + } +} + +SimulationToolsService.TOOLS = Object.freeze({ + NEST_DESKTOP: { + singleton: true, + flexlayoutNode: { + 'type': 'tab', + 'name': 'NEST Desktop', + 'component': 'nest-desktop' + }, + flexlayoutFactoryCb: () => { + return <iframe src='http://localhost:8000' title='NEST Desktop' />; + }, + getIcon: () => { + return <div> + <img src={'https://www.nest-simulator.org/wp-content/uploads/2015/03/nest_logo.png'} + alt="NEST Desktop" + style={{width: 40+ 'px', height: 20 + 'px'}} /> + <span>Desktop</span> + </div>; + } + }, + TEST_NRP_CORE_DOCU: { + singleton: true, + flexlayoutNode: { + 'type': 'tab', + 'name': 'NRP-Core Docs', + 'component': 'nrp-core-docu' + }, + flexlayoutFactoryCb: () => { + return <iframe src='https://hbpneurorobotics.bitbucket.io/index.html' + title='NRP-Core Documentation' />; + }, + getIcon: () => { + return <span>NRP-Core Docs</span>; + } + } +}); + +SimulationToolsService.CONSTANTS = Object.freeze({ + CATEGORY: { + EXTERNAL_IFRAME: 'EXTERNAL_IFRAME', + REACT_COMPONENT: 'REACT_COMPONENT' + } +}); + +export default SimulationToolsService; diff --git a/src/components/experiment-workbench/experiment-workbench.css b/src/components/experiment-workbench/experiment-workbench.css new file mode 100644 index 0000000000000000000000000000000000000000..ca655e87f8942a6b32574ed5312e886ec5e0bf54 --- /dev/null +++ b/src/components/experiment-workbench/experiment-workbench.css @@ -0,0 +1,80 @@ +.simulation-view-wrapper { + display: grid; + grid-template-rows: auto auto; + grid-template-columns: 68px auto; + grid-template-areas: + "simulation-view-header simulation-view-header" + "simulation-view-sidebar simulation-view-mainview"; +} + +.simulation-view-header { + grid-area: simulation-view-header; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + background-color: yellow; + padding: 3px; +} + +.simulation-view-sidebar { + padding: 2px; + grid-area: simulation-view-sidebar; + background-color: green; +} + +.simulation-view-mainview { + grid-area: simulation-view-mainview; + background-color: red; +} + +.flexlayout__layout { + position: relative; + height: calc(100vh - 63px); +} + +.flexlayout__tab { + overflow: hidden; +} + +.simulation-view-controls { + display: flex; + flex-direction: row; + align-items: center; +} + +.simulation-view-control-buttons { + padding: 0px 10px 0px 10px; +} + +.simulation-view-time-info { + display: grid; + gap: 3px; + grid-template-rows: repeat(3, 1fr); + grid-template-columns: repeat(2, 1fr); + + padding: 0px 10px 0px 10px; + + font-size: 0.7em; +} + +.simulation-view-experiment-title { + padding: 0px 10px 0px 10px; + font-weight: bold; +} + +.simulation-tool-button { + width: 60px; + height: 60px; + margin: 2px; + padding: 2px; + font-size: 0.7em; + color: black; + font-weight: bold; + background-color: lightgray; +} + +iframe { + width: 100%; + height: 100%; +} \ No newline at end of file diff --git a/src/components/experiment-workbench/experiment-workbench.js b/src/components/experiment-workbench/experiment-workbench.js new file mode 100644 index 0000000000000000000000000000000000000000..771a93765d71e48d2f4a7183e9ad187e31b0199a --- /dev/null +++ b/src/components/experiment-workbench/experiment-workbench.js @@ -0,0 +1,205 @@ +import React from 'react'; +import FlexLayout from 'flexlayout-react'; +import { OverlayTrigger, Tooltip, Button } from 'react-bootstrap'; +import { RiPlayFill, RiPauseFill, RiLayout6Line } from 'react-icons/ri'; +import { GiExitDoor } from 'react-icons/gi'; +import { TiMediaRecord } from 'react-icons/ti'; +import { VscDebugRestart } from 'react-icons/vsc'; + +import ExperimentToolsService from './experiment-tools-service'; +import ServerResourcesService from '../../services/experiments/execution/server-resources-service.js'; +import ExperimentStorageService from '../../services/experiments/files/experiment-storage-service'; +import RunningSimulationService from '../../services/experiments/execution/running-simulation-service'; +import { EXPERIMENT_STATE } from '../../services/experiments/experiment-constants'; +import timeDDHHMMSS from '../../utility/time-filter'; + +import LeaveWorkbenchDialog from './leave-workbench-dialog'; + +import '../../../node_modules/flexlayout-react/style/light.css'; +import './experiment-workbench.css'; + +const jsonBaseLayout = { + global: {}, + borders: [], + layout:{ + 'type': 'row', + 'weight': 100, + 'children': [ + { + 'type': 'tabset', + 'weight': 50, + 'selected': 0, + 'children': [ + { + 'type': 'tab', + 'name': 'NEST wiki page', + 'component':'nest_wiki' + } + ] + } + ] + } +}; + +export default class ExperimentWorkbench extends React.Component { + constructor(props) { + super(props); + + const {experimentID} = props.match.params; + //console.info('SimulationView ' + serverIP + ' ' + simulationID); + this.experimentID = experimentID; + this.serverURL = 'http://' + this.serverIP + ':8080'; // this should probably be part of some config + + this.state = { + modelFlexLayout: FlexLayout.Model.fromJson(jsonBaseLayout), + showLeaveDialog: false + }; + + this.refLayout = React.createRef(); + } + + async componentDidMount() { + await this.updateSimulationInfo(); + let experiments = await ExperimentStorageService.instance.getExperiments(); + this.experimentInfo = experiments.find(experiment => experiment.id === this.experimentID); + console.info('ExperimentWorkbench - experimentInfo'); + console.info(this.experimentInfo); + + let experimentName = this.experimentInfo.configuration.name; + this.setState({experimentName: experimentName}); + + let server = this.experimentInfo.joinableServers.find( + server => server.runningSimulation.creationUniqueID === this.state.simulationInfo.creationUniqueID); + this.serverConfig = await ServerResourcesService.instance.getServerConfig(server.server); + console.info('this.serverConfig'); + console.info(this.serverConfig); + RunningSimulationService.instance.addRosStatusInfoCallback( + this.serverConfig.rosbridge.websocket, + (data) => { + this.onStatusInfoROS(data); + } + ); + } + + async updateSimulationInfo() { + let simInfo = await RunningSimulationService.instance.getInfo(this.serverURL, this.simulationID); + this.setState({simulationInfo: simInfo}); + console.info('SimulationView.updateSimulationInfo - simulationInfo'); + console.info(this.state.simulationInfo); + } + + onStatusInfoROS(message) { + this.setState({ + timingRealtime: timeDDHHMMSS(message.realTime), + timingSimulationTime: timeDDHHMMSS(message.simulationTime), + timingTimeout: timeDDHHMMSS(message.timeout) + }); + } + + async onButtonStartPause() { + let newState = this.state.simulationInfo.state === EXPERIMENT_STATE.PAUSED + ? EXPERIMENT_STATE.STARTED + : EXPERIMENT_STATE.PAUSED; + await RunningSimulationService.instance.updateState(this.serverURL, this.simulationID, newState); + + this.updateSimulationInfo(); + } + + onButtonLayout() { + console.info(this.state.modelFlexLayout.toJson()); + } + + showLeaveDialog(show) { + this.setState({showLeaveDialog: show}); + } + + leaveWorkbench() { + this.props.history.push({ + pathname: '/experiments-overview' + }); + } + + render() { + return ( + <div> + <LeaveWorkbenchDialog visible={this.state.showLeaveDialog} + setVisibility={(visible) => this.showLeaveDialog(visible)} + stopSimulation={async () => { + await RunningSimulationService.instance.updateState(this.serverURL, this.simulationID, + EXPERIMENT_STATE.STOPPED); + this.leaveWorkbench(); + }} + leaveWorkbench={() => { + this.leaveWorkbench(); + }} /> + <div className='simulation-view-wrapper'> + <div className='simulation-view-header'> + <div className='simulation-view-controls'> + <div className='simulation-view-control-buttons'> + <button className='nrp-btn btn-default' onClick={() => this.showLeaveDialog(true)}> + <GiExitDoor className='icon' /> + </button> + <button disabled={true} className='nrp-btn btn-default'><VscDebugRestart className='icon' /></button> + <button className='nrp-btn btn-default' onClick={() => { + this.onButtonStartPause(); + }}> + {this.state.simulationInfo && this.state.simulationInfo.state === EXPERIMENT_STATE.PAUSED + ? <RiPlayFill className='icon' /> + : <RiPauseFill className='icon' />} + </button> + <button disabled={true} className='nrp-btn btn-default'><TiMediaRecord className='icon' /></button> + </div> + + <div className='simulation-view-time-info'> + <div>Simulation time:</div> + <div>{this.state.timingSimulationTime}</div> + <div>Real time:</div> + <div>{this.state.timingRealtime}</div> + <div>Real timeout:</div> + <div>{this.state.timingTimeout}</div> + </div> + </div> + + <div className='simulation-view-experiment-title'>{this.state.experimentName}</div> + <button className='nrp-btn btn-default' onClick={() => { + this.onButtonLayout(); + }}><RiLayout6Line className='icon' /></button> + </div> + <div className='simulation-view-sidebar'> + {Array.from(ExperimentToolsService.instance.tools.values()).map(tool => { + return ( + <OverlayTrigger + key={`overlaytrigger-${tool.flexlayoutNode.component}`} + placement={'right'} + overlay={ + <Tooltip id={`tooltip-${tool.flexlayoutNode.component}`}> + {tool.flexlayoutNode.name} + </Tooltip> + } + > + <Button key={tool.flexlayoutNode.component} + className="simulation-tool-button" + onMouseDown={() => { + ExperimentToolsService.instance.startToolDrag( + tool.flexlayoutNode, + this.refLayout); + }}>{tool.getIcon && tool.getIcon()}</Button> + </OverlayTrigger> + ); + })} + </div> + <div className='simulation-view-mainview'> + <FlexLayout.Layout ref={this.refLayout} model={this.state.modelFlexLayout} + factory={(node) => { + return ExperimentToolsService.instance.flexlayoutNodeFactory(node); + }} /> + </div> + </div> + </div> + ); + } +} + +ExperimentWorkbench.CONSTANTS = Object.freeze({ + INTERVAL_INTERNAL_UPDATE_MS: 1000 +}); diff --git a/src/components/experiment-workbench/leave-workbench-dialog.css b/src/components/experiment-workbench/leave-workbench-dialog.css new file mode 100644 index 0000000000000000000000000000000000000000..17673cef3053f4308b70ab85226ef78e1932c781 --- /dev/null +++ b/src/components/experiment-workbench/leave-workbench-dialog.css @@ -0,0 +1,4 @@ +.leave-simulation-dialog-header { + color: black; + background-color: white; +} \ No newline at end of file diff --git a/src/components/experiment-workbench/leave-workbench-dialog.js b/src/components/experiment-workbench/leave-workbench-dialog.js new file mode 100644 index 0000000000000000000000000000000000000000..dafd51bf9716e46f04a783ac586a49aee407ee57 --- /dev/null +++ b/src/components/experiment-workbench/leave-workbench-dialog.js @@ -0,0 +1,32 @@ +import React from 'react'; +import {Button } from 'react-bootstrap'; +import Modal from 'react-bootstrap/Modal'; + +import './leave-workbench-dialog.css'; + +export default class LeaveWorkbenchDialog extends React.Component{ + render(){ + return ( + <div> + <div> + <Modal show={this.props.visible} onHide={() => this.props.setVisibility(false)}> + <Modal.Header closeButton className="leave-workbench-dialog-header"> + <Modal.Title>Exit menu</Modal.Title> + </Modal.Header> + <Modal.Body>Would you like to leave or stop the simulation?</Modal.Body> + <Modal.Footer> + <div> + <Button variant="light" onClick={() => this.props.leaveWorkbench()}> + Leave + </Button> + <Button variant="danger" onClick={() => this.props.stopSimulation()}> + Stop + </Button> + </div> + </Modal.Footer> + </Modal> + </div> + </div> + ); + } +} \ No newline at end of file diff --git a/src/components/experiment-overview/experiment-overview.css b/src/components/experiments-overview/experiments-overview.css similarity index 100% rename from src/components/experiment-overview/experiment-overview.css rename to src/components/experiments-overview/experiments-overview.css diff --git a/src/components/experiment-overview/experiment-overview.js b/src/components/experiments-overview/experiments-overview.js similarity index 97% rename from src/components/experiment-overview/experiment-overview.js rename to src/components/experiments-overview/experiments-overview.js index b43fb7a9d504792832640f57a7b35acfa5de78c6..8a95830d9cd565d86ab138fadd5203c1d69d6c43 100644 --- a/src/components/experiment-overview/experiment-overview.js +++ b/src/components/experiments-overview/experiments-overview.js @@ -13,9 +13,9 @@ import ExperimentList from '../experiment-list/experiment-list.js'; import NrpHeader from '../nrp-header/nrp-header.js'; import ExperimentFilesViewer from '../experiment-files-viewer/experiment-files-viewer.js'; -import './experiment-overview.css'; +import './experiments-overview.css'; -export default class ExperimentOverview extends React.Component { +export default class ExperimentsOverview extends React.Component { static CONSTANTS = { TAB_INDEX: { MY_EXPERIMENTS: 0, @@ -35,7 +35,7 @@ export default class ExperimentOverview extends React.Component { joinableExperiments: [], availableServers: [], startingExperiment: undefined, - selectedTabIndex: ExperimentOverview.CONSTANTS.TAB_INDEX.MY_EXPERIMENTS + selectedTabIndex: ExperimentsOverview.CONSTANTS.TAB_INDEX.MY_EXPERIMENTS }; } diff --git a/src/components/simulation-view/simulation-tools-service.js b/src/components/simulation-view/simulation-tools-service.js index 1d16e533f7f3722ea40880c881adce782605bee0..1f0f9b5d99411b25520ab1af94e7d0296513f367 100644 --- a/src/components/simulation-view/simulation-tools-service.js +++ b/src/components/simulation-view/simulation-tools-service.js @@ -7,22 +7,22 @@ const SINGLETON_ENFORCER = Symbol(); /** * Service handling server resources for simulating experiments. */ -class SimulationToolsService { +class ExperimentToolsService { constructor(enforcer) { if (enforcer !== SINGLETON_ENFORCER) { throw new Error('Use ' + this.constructor.name + '.instance'); } this.tools = new Map(); - for (const toolEntry in SimulationToolsService.TOOLS) { - this.registerToolConfig(SimulationToolsService.TOOLS[toolEntry]); + for (const toolEntry in ExperimentToolsService.TOOLS) { + this.registerToolConfig(ExperimentToolsService.TOOLS[toolEntry]); } this.registerToolConfig(NrpCoreDashboard.CONSTANTS.TOOL_CONFIG); } static get instance() { if (_instance == null) { - _instance = new SimulationToolsService(SINGLETON_ENFORCER); + _instance = new ExperimentToolsService(SINGLETON_ENFORCER); } return _instance; @@ -62,7 +62,7 @@ class SimulationToolsService { } } -SimulationToolsService.TOOLS = Object.freeze({ +ExperimentToolsService.TOOLS = Object.freeze({ NEST_DESKTOP: { singleton: true, flexlayoutNode: { @@ -99,11 +99,11 @@ SimulationToolsService.TOOLS = Object.freeze({ } }); -SimulationToolsService.CONSTANTS = Object.freeze({ +ExperimentToolsService.CONSTANTS = Object.freeze({ CATEGORY: { EXTERNAL_IFRAME: 'EXTERNAL_IFRAME', REACT_COMPONENT: 'REACT_COMPONENT' } }); -export default SimulationToolsService; +export default ExperimentToolsService; diff --git a/src/components/simulation-view/simulation-view.js b/src/components/simulation-view/simulation-view.js index f2c5459f13bdbbc4a150ca3888942c5e5f78ced4..a09b475c77ea3497029e85d7928cdd5259104e1b 100644 --- a/src/components/simulation-view/simulation-view.js +++ b/src/components/simulation-view/simulation-view.js @@ -6,7 +6,7 @@ import { GiExitDoor } from 'react-icons/gi'; import { TiMediaRecord } from 'react-icons/ti'; import { VscDebugRestart } from 'react-icons/vsc'; -import SimulationToolsService from './simulation-tools-service'; +import ExperimentToolsService from './simulation-tools-service'; import ServerResourcesService from '../../services/experiments/execution/server-resources-service.js'; import ExperimentStorageService from '../../services/experiments/files/experiment-storage-service'; import RunningSimulationService from '../../services/experiments/execution/running-simulation-service'; @@ -167,7 +167,7 @@ export default class SimulationView extends React.Component { }}><RiLayout6Line className='icon' /></button> </div> <div className='simulation-view-sidebar'> - {Array.from(SimulationToolsService.instance.tools.values()).map(tool => { + {Array.from(ExperimentToolsService.instance.tools.values()).map(tool => { return ( <OverlayTrigger key={`overlaytrigger-${tool.flexlayoutNode.component}`} @@ -181,7 +181,7 @@ export default class SimulationView extends React.Component { <Button key={tool.flexlayoutNode.component} className="simulation-tool-button" onMouseDown={() => { - SimulationToolsService.instance.startToolDrag( + ExperimentToolsService.instance.startToolDrag( tool.flexlayoutNode, this.refLayout); }}>{tool.getIcon && tool.getIcon()}</Button> @@ -192,7 +192,7 @@ export default class SimulationView extends React.Component { <div className='simulation-view-mainview'> <FlexLayout.Layout ref={this.refLayout} model={this.state.modelFlexLayout} factory={(node) => { - return SimulationToolsService.instance.flexlayoutNodeFactory(node); + return ExperimentToolsService.instance.flexlayoutNodeFactory(node); }} /> </div> </div>