diff --git a/package-lock.json b/package-lock.json index 5427a9f29418634c9f5df80ccef7065c9364874c..0930943b179636984fc7469c445be3066636c6c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2974,6 +2974,11 @@ "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==" }, + "arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==" + }, "argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -4300,6 +4305,11 @@ } } }, + "clsx": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz", + "integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==" + }, "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -4646,6 +4656,11 @@ "sha.js": "^2.4.8" } }, + "create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==" + }, "cross-fetch": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.0.6.tgz", @@ -5256,6 +5271,11 @@ } } }, + "diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==" + }, "diff-sequences": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz", @@ -10462,6 +10482,11 @@ } } }, + "make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==" + }, "makeerror": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.11.tgz", @@ -13396,6 +13421,15 @@ "workbox-webpack-plugin": "5.1.4" } }, + "react-tabs": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/react-tabs/-/react-tabs-3.1.2.tgz", + "integrity": "sha512-OKS1l7QzSNcn+L2uFsxyGFHdXp9YsPGf/YOURWcImp7xLN36n0Wz+/j9HwlwGtlXCZexwshScR5BrcFbw/3P9Q==", + "requires": { + "clsx": "^1.1.0", + "prop-types": "^15.5.0" + } + }, "react-transition-group": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz", @@ -15709,6 +15743,19 @@ "resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz", "integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==" }, + "ts-node": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-9.1.1.tgz", + "integrity": "sha512-hPlt7ZACERQGf03M253ytLY3dHbGNGrAq9qIHWUY9XHYl1z7wYngSr3OQ5xmui8o2AaxsONxIzjafLUiWBo1Fg==", + "requires": { + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "source-map-support": "^0.5.17", + "yn": "3.1.1" + } + }, "ts-pnp": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz", @@ -17684,6 +17731,11 @@ "resolved": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz", "integrity": "sha1-AI4G2AlDIMNy28L47XagymyKxBk=" }, + "yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==" + }, "yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 69807f7a639b25bba10cb2e702ec938b1beba339..274d8c70db4d74fc7487f7aa9f8f4019a758d54e 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "react-dom": "^17.0.1", "react-router-dom": "5.2.0", "react-scripts": "4.0.0", + "react-tabs": "3.1.2", "roslib": "1.1.0", "rxjs": "6.6.3", "ts-node": "^9.1.1", @@ -66,7 +67,12 @@ "error", "consistent" ], - "max-len": ["error", { "code": 120 }] + "max-len": [ + "error", + { + "code": 120 + } + ] } }, "browserslist": { @@ -85,4 +91,4 @@ "jest-fetch-mock": "^3.0.3", "msw": "^0.23.0" } -} \ No newline at end of file +} diff --git a/src/components/experiment-list/experiment-list-element.js b/src/components/experiment-list/experiment-list-element.js index 9ae86d2ac15aebd2e83087f3d09bffc563af2e6c..9f7982ed5bd3477f3568ea39470eb1fcf74c2c07 100644 --- a/src/components/experiment-list/experiment-list-element.js +++ b/src/components/experiment-list/experiment-list-element.js @@ -2,9 +2,9 @@ import React from 'react'; import timeDDHHMMSS from '../../utility/time-filter.js'; import ExperimentStorageService from '../../services/experiments/storage/experiment-storage-service.js'; import ExperimentExecutionService from '../../services/experiments/execution/experiment-execution-service.js'; +import ExperimentServerService from '../../services/experiments/execution/experiment-server-service.js'; import './experiment-list-element.css'; -import ExperimentServerService from '../../services/experiments/execution/experiment-server-service.js'; const CLUSTER_THRESHOLDS = { UNAVAILABLE: 2, @@ -15,7 +15,7 @@ const SHORT_DESCRIPTION_LENGTH = 200; export default class ExperimentListElement extends React.Component { constructor(props) { super(props); - this.state = {}; + this.state = {availableServers: []}; this.canLaunchExperiment = (this.props.experiment.private && this.props.experiment.owned) || !this.props.experiment.private; @@ -29,13 +29,27 @@ export default class ExperimentListElement extends React.Component { let thumbnail = await ExperimentStorageService.instance.getThumbnail( this.props.experiment.name, this.props.experiment.configuration.thumbnail); - this.setState({ thumbnail: URL.createObjectURL(thumbnail) }); + this.setState({ + thumbnail: URL.createObjectURL(thumbnail) + }); document.addEventListener('mousedown', this.handleClickOutside); + + this.onUpdateServerAvailability = (availableServers) => { + this.setState({availableServers: availableServers}); + }; + ExperimentServerService.instance.addListener( + ExperimentServerService.EVENTS.UPDATE_SERVER_AVAILABILITY, + this.onUpdateServerAvailability + ); } componentWillUnmount() { document.removeEventListener('mousedown', this.handleClickOutside); + this.onUpdateServerAvailability && ExperimentServerService.instance.removeListener( + ExperimentServerService.EVENTS.UPDATE_SERVER_AVAILABILITY, + this.onUpdateServerAvailability + ); } handleClickOutside(event) { @@ -45,14 +59,13 @@ export default class ExperimentListElement extends React.Component { } getAvailabilityInfo() { - const experiment = this.props.experiment; const clusterAvailability = ExperimentServerService.instance.getClusterAvailability(); let status; if (clusterAvailability && clusterAvailability.free > CLUSTER_THRESHOLDS.AVAILABLE) { status = 'Available'; } - else if (!experiment.availableServers || experiment.availableServers.length === 0) { + else if (!this.state.availableServers || this.state.availableServers.length === 0) { status = 'Unavailable'; } else { @@ -60,20 +73,19 @@ export default class ExperimentListElement extends React.Component { } let cluster = `Cluster availability: ${clusterAvailability.free} / ${clusterAvailability.total}`; - let backends = `Backends: ${experiment.availableServers.length}`; + let backends = `Backends: ${this.state.availableServers.length}`; return `${status}\n${cluster}\n${backends}`; } getServerStatusClass() { - const experiment = this.props.experiment; const clusterAvailability = ExperimentServerService.instance.getClusterAvailability(); let status = ''; if (clusterAvailability && clusterAvailability.free > CLUSTER_THRESHOLDS.AVAILABLE) { status = 'server-status-available'; } - else if (!experiment.availableServers || experiment.availableServers.length === 0) { + else if (!this.state.availableServers || this.state.availableServers.length === 0) { status = 'server-status-unavailable'; } else { @@ -86,7 +98,7 @@ export default class ExperimentListElement extends React.Component { render() { const exp = this.props.experiment; const config = this.props.experiment.configuration; - const pageState = this.props.pageState; + const pageState = this.props.pageState; //TODO: to be removed, migrate to services return ( <div className='list-entry-wrapper flex-container left-right' @@ -136,27 +148,29 @@ export default class ExperimentListElement extends React.Component { return exp.id === pageState.selected; }}> <div className='btn-group' role='group' > - {this.canLaunchExperiment && exp.availableServers.length > 0 && + {this.canLaunchExperiment && this.state.availableServers.length > 0 && exp.configuration.experimentFile && exp.configuration.bibiConfSrc ? <button onClick={() => { - return ExperimentExecutionService.instance.startingExperiment === exp.id || - ExperimentExecutionService.instance.startNewExperiment(exp, false); + ExperimentExecutionService.instance.startNewExperiment(exp, false); }} - disabled={pageState.startingExperiment === exp.id || pageState.deletingExperiment} + //TODO: adjust disabled state to be reactive + disabled={ExperimentExecutionService.instance.startingExperiment === exp + || pageState.deletingExperiment} className='btn btn-default' > <i className='fa fa-plus'></i> Launch </button> : null} - {this.canLaunchExperiment && exp.availableServers.length === 0 - ? <button className='btn btn-default disabled enable-tooltip' + {this.canLaunchExperiment && this.state.availableServers.length === 0 + ? <button disabled={this.canLaunchExperiment && this.state.availableServers.length === 0} + className='btn btn-default disabled enable-tooltip' title='Sorry, no available servers.'> <i className='fa fa-plus'></i> Launch </button> : null} {this.canLaunchExperiment && config.brainProcesses > 1 && - exp.availableServers.length > 0 && + this.state.availableServers.length > 0 && exp.configuration.experimentFile && exp.configuration.bibiConfSrc ? <button className='btn btn-default'> @@ -164,7 +178,7 @@ export default class ExperimentListElement extends React.Component { </button> : null} - {this.canLaunchExperiment && exp.availableServers.length > 1 && + {this.canLaunchExperiment && this.state.availableServers.length > 1 && exp.configuration.experimentFile && exp.configuration.bibiConfSrc ? <button className='btn btn-default' > @@ -193,7 +207,7 @@ export default class ExperimentListElement extends React.Component { </button> : null} - {/* Join button */} + {/* Simulations button */} {this.canLaunchExperiment && exp.joinableServers.length > 0 ? <button className='btn btn-default' > <i className='fa fa-sign-in'></i> Simulations » diff --git a/src/components/experiment-list/experiment-list.js b/src/components/experiment-list/experiment-list.js index 9087576f77b2e770feba6aacabed97bb97a4099d..b9d0817220f9793b45807f961537e3db5696c8a7 100644 --- a/src/components/experiment-list/experiment-list.js +++ b/src/components/experiment-list/experiment-list.js @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'; import UserMenu from '../user-menu/user-menu.js'; import ExperimentStorageService from '../../services/experiments/storage/experiment-storage-service.js'; +import ExperimentServerService from '../../services/experiments/execution/experiment-server-service.js'; import ExperimentListElement from './experiment-list-element.js'; @@ -60,7 +61,8 @@ export default class ExperimentList extends React.Component { {this.state.experiments.map(experiment => { return ( <li key={experiment.id} className='nostyle'> - <ExperimentListElement experiment={experiment} pageState={this.state.pageState} /> + <ExperimentListElement experiment={experiment} + pageState={this.state.pageState} /> </li> ); } diff --git a/src/services/experiments/execution/experiment-execution-service.js b/src/services/experiments/execution/experiment-execution-service.js index e6ff4a1bc551a5b198e1bf4c50946673d46ad468..2708862a9dc4242ffb3beac6fae51512efccd253 100644 --- a/src/services/experiments/execution/experiment-execution-service.js +++ b/src/services/experiments/execution/experiment-execution-service.js @@ -47,10 +47,11 @@ class ExperimentExecutionService extends HttpService { this.startingExperiment = experiment; + console.info(ExperimentServerService.instance.getServerAvailability(true)); let fatalErrorOccurred = false, serversToTry = experiment.devServer ? [experiment.devServer] - : experiment.availableServers.map(s => s.id); + : ExperimentServerService.instance.getServerAvailability(true).map(s => s.id); let brainProcesses = launchSingleMode ? 1 : experiment.configuration.brainProcesses; diff --git a/src/services/experiments/execution/experiment-server-service.js b/src/services/experiments/execution/experiment-server-service.js index 8443c8d7e662bc47c28bbe2b3e479ddbef53deed..3ea09ac84e9e689cab2fbbbdcfcffa637e2cd3e9 100644 --- a/src/services/experiments/execution/experiment-server-service.js +++ b/src/services/experiments/execution/experiment-server-service.js @@ -11,12 +11,14 @@ import endpoints from '../../proxy/data/endpoints.json'; import config from '../../../config.json'; const proxyServerURL = `${config.api.proxy.url}${endpoints.proxy.server.url}`; const slurmMonitorURL = `${config.api.slurmmonitor.url}/api/v1/partitions/interactive`; +const availableServersURL = `${config.api.proxy.url}${endpoints.proxy.availableServers.url}`; let _instance = null; const SINGLETON_ENFORCER = Symbol(); let rosConnections = new Map(); const SLURM_MONITOR_POLL_INTERVAL = 5000; +const SERVER_AVAILABILITY_POLL_INTERVAL = 5000; let clusterAvailability = { free: 'N/A', total: 'N/A' }; /** @@ -49,7 +51,12 @@ class ExperimentServerService extends HttpService { .pipe(map(({ free, nodes }) => ({ free, total: nodes[3] }))) .pipe(multicast(new Subject())).refCount(); + this.listOfAvailableServers = []; + this.startUpdates(); + window.onbeforeunload = () => { + this.stopUpdates(); + }; } static get instance() { @@ -60,6 +67,13 @@ class ExperimentServerService extends HttpService { return _instance; } + /** + * Get available servers. + */ + get availableServers() { + return this.listOfAvailableServers; + } + /** * Start polling updates. */ @@ -67,14 +81,21 @@ class ExperimentServerService extends HttpService { this.clusterAvailabilitySubscription = this.clusterAvailabilityObservable.subscribe( availability => (clusterAvailability = availability) ); + + this.timerPollServerAvailability = setInterval( + () => { + this.getServerAvailability(true); + }, + SERVER_AVAILABILITY_POLL_INTERVAL + ); } /** * Stop polling updates. */ - //TODO: find proper place to call stopUpdates() { this.clusterAvailabilitySubscription && this.clusterAvailabilitySubscription.unsubscribe(); + this.timerPollServerAvailability && clearInterval(this.timerPollServerAvailability); } /** @@ -85,6 +106,19 @@ class ExperimentServerService extends HttpService { return clusterAvailability; } + getServerAvailability(forceUpdate = false) { + if (!this.listOfAvailableServers || forceUpdate) { + let update = async () => { + let response = await this.httpRequestGET(availableServersURL); + this.listOfAvailableServers = await response.json(); + }; + update(); + this.emit(ExperimentServerService.EVENTS.UPDATE_SERVER_AVAILABILITY, this.listOfAvailableServers); + } + + return this.listOfAvailableServers; + } + /** * Get the server config for a given server ID. * @param {string} serverID - ID of the server @@ -204,4 +238,8 @@ class ExperimentServerService extends HttpService { }; } +ExperimentServerService.EVENTS = Object.freeze({ + UPDATE_SERVER_AVAILABILITY: 'UPDATE_SERVER_AVAILABILITY' +}); + export default ExperimentServerService; diff --git a/src/services/experiments/storage/experiment-storage-service.js b/src/services/experiments/storage/experiment-storage-service.js index 8cc5cd6d41b75d6fc3c434160c375a6fc177a9d2..ca7cda948bdfd15eef9b44fe68a79eb825851fb3 100644 --- a/src/services/experiments/storage/experiment-storage-service.js +++ b/src/services/experiments/storage/experiment-storage-service.js @@ -3,7 +3,6 @@ import { HttpService } from '../../http-service.js'; import endpoints from '../../proxy/data/endpoints.json'; import config from '../../../config.json'; const storageExperimentsURL = `${config.api.proxy.url}${endpoints.proxy.storage.experiments.url}`; -const availableServersURL = `${config.api.proxy.url}${endpoints.proxy.availableServers.url}`; let _instance = null; const SINGLETON_ENFORCER = Symbol(); @@ -35,8 +34,9 @@ class ExperimentStorageService extends HttpService { * * @return experiments - the list of template experiments */ - async getExperiments() { - if (!this.experiments) { + async getExperiments(forceUpdate = false) { + console.info('getExperiments'); + if (!this.experiments || forceUpdate) { let response = await this.httpRequestGET(storageExperimentsURL); this.experiments = await response.json(); this.sortExperiments(); @@ -78,11 +78,7 @@ class ExperimentStorageService extends HttpService { } async fillExperimentDetails() { - let response = await this.httpRequestGET(availableServersURL); - let availableServers = await response.json(); - this.experiments.forEach(exp => { - exp.availableServers = availableServers; if (!exp.configuration.brainProcesses && exp.configuration.bibiConfSrc) { exp.configuration.brainProcesses = 1; } diff --git a/src/services/http-service.js b/src/services/http-service.js index 0e1e89cc33a895d836d1204c334ccb79a520967a..16cae76bcc5e68752e06f9d7ae04f9ef945b9731 100644 --- a/src/services/http-service.js +++ b/src/services/http-service.js @@ -1,3 +1,6 @@ + +import {EventEmitter} from 'events'; + import AuthenticationService from './authentication-service.js'; /** @@ -5,11 +8,13 @@ import AuthenticationService from './authentication-service.js'; * If children need other options they can override the options or the * http verb (GET, POST, PUT etc) functions. */ -export class HttpService { +export class HttpService extends EventEmitter { /** * Create a simple http request object with default options, default method is GET */ constructor() { + super(); + this.options = { method: 'GET', // *GET, POST, PUT, DELETE, etc. mode: 'cors', // no-cors, *cors, same-origin