diff --git a/package-lock.json b/package-lock.json index 4fffdb46cd..b93ae45534 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14149,28 +14149,19 @@ } }, "reactstrap": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/reactstrap/-/reactstrap-5.0.0.tgz", - "integrity": "sha512-y0eju/LAK7gbEaTFfq2iW92MF7/5Qh0tc1LgYr2mg92IX8NodGc03a+I+cp7bJ0VXHAiLy0bFL9UP89oSm4cBg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/reactstrap/-/reactstrap-8.0.0.tgz", + "integrity": "sha512-Rd7MyaFnZZXe1lbeBlgkxzyv+u7Z/ots0yYoO6ijT7/0uGsNwB1ch2h2t7Tf6u05jpbTFlLD+lyBEER0eibcQw==", "requires": { + "@babel/runtime": "^7.2.0", "classnames": "^2.2.3", "lodash.isfunction": "^3.0.9", "lodash.isobject": "^3.0.2", "lodash.tonumber": "^4.0.3", "prop-types": "^15.5.8", - "react-popper": "^0.8.3", - "react-transition-group": "^2.2.1" - }, - "dependencies": { - "react-popper": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-0.8.3.tgz", - "integrity": "sha1-D3MzMTfJ+wr27EB00tBYWgoEYeE=", - "requires": { - "popper.js": "^1.12.9", - "prop-types": "^15.6.0" - } - } + "react-lifecycles-compat": "^3.0.4", + "react-popper": "^1.3.3", + "react-transition-group": "^2.3.1" } }, "read-pkg": { diff --git a/package.json b/package.json index 2dcf48050e..102e9fb524 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "react-motion": "^0.5.2", "react-redux": "^5.0.6", "react-router-dom": "^4.2.2", - "reactstrap": "^5.0.0-alpha.4", + "reactstrap": "^8.0.0", "redux": "^3.7.2", "redux-thunk": "^2.2.0", "styled-jsx": "^2.2.6", diff --git a/src/api-client/notebook-servers.js b/src/api-client/notebook-servers.js index 5385fafc43..5377159c4f 100644 --- a/src/api-client/notebook-servers.js +++ b/src/api-client/notebook-servers.js @@ -43,37 +43,67 @@ function cleanAnnotations(annotations, domain) { } function addNotebookServersMethods(client) { - client.getNotebookServers = (id) => { - let headers = client.getBasicHeaders(); + client.getNotebookServers = (id, branch, commit) => { + // TODO: add filtering logic here, remove from Notebook + const headers = client.getBasicHeaders(); const url = `${client.baseUrl}/notebooks/servers`; return client.clientFetch(url, { method: 'GET', - headers: headers + headers }).then(resp => { - const { servers } = resp.data; + let { servers } = resp.data; if (id) { // TODO: remove this filter when this API will support projectId filtering - const filteredServers = Object.keys(servers) + servers = Object.keys(servers) .filter(server => servers[server].annotations["renku.io/projectId"] === id) .reduce((obj, key) => {obj[key] = servers[key]; return obj}, {}); - return { "data": filteredServers }; } return { "data": servers }; }); } client.stopNotebookServer = (serverName) => { - let headers = client.getBasicHeaders(); + const headers = client.getBasicHeaders(); const url = `${client.baseUrl}/notebooks/servers/${serverName}`; return client.clientFetch(url, { method: 'DELETE', - headers: headers + headers }, "text") .then(resp => { return true; }); } + + client.getNotebookServerOptions = (projectUrl, commitId) => { + const headers = client.getBasicHeaders(); + const url = `${client.baseUrl}/notebooks/${projectUrl}/${commitId}/server_options`; + + return client.clientFetch(url, { + method: 'GET', + headers + }).then((resp) => { + let { data } = resp; + Object.keys(data).forEach(key => { + data[key].selected = data[key].default; + }) + return data; + }); + } + + client.startNotebook = (projectUrl, branchName, commitId, options) => { + const headers = client.getBasicHeaders(); + const url = `${client.baseUrl}/notebooks/${projectUrl}/${commitId}`; + + return client.clientFetch(url, { + method: 'POST', + headers, + queryParams: { branch: branchName }, + body: JSON.stringify(options) + }).then((resp) => { + return resp.data; + }); + } } export { cleanAnnotations, ExpectedAnnotations }; diff --git a/src/model/Model.js b/src/model/Model.js index b39cb09ca4..a886578337 100644 --- a/src/model/Model.js +++ b/src/model/Model.js @@ -65,6 +65,12 @@ const SpecialPropVal = { UPDATING: 'is_updating' }; +const StatusHelper = { + isUpdating: (value) => { + return value === SpecialPropVal.UPDATING ? true : false; + } +} + class FieldSpec { constructor(spec) { Object.keys(spec).forEach((prop) => { @@ -271,6 +277,11 @@ class SubModel { this.baseModel.setUpdating(fullOptions); } + isUpdating(propAccessorString) { + const fullPropAccessorString = this.baseModelPath + (propAccessorString ? '.' + propAccessorString : ''); + return StatusHelper.isUpdating(this.baseModel.get(fullPropAccessorString)); + } + mapStateToProps = _mapStateToProps.bind(this); subModel = (path) => new SubModel(this.baseModel, this.baseModelPath + '.' + path); @@ -460,4 +471,4 @@ function _mapStateToProps(state, ownProps) { } -export { Schema, StateModel, StateKind , SubModel, SpecialPropVal} +export { Schema, StateModel, StateKind , SubModel, SpecialPropVal, StatusHelper } diff --git a/src/notebooks/Notebooks.container.js b/src/notebooks/Notebooks.container.js index f32eda2822..62cd94c031 100644 --- a/src/notebooks/Notebooks.container.js +++ b/src/notebooks/Notebooks.container.js @@ -18,163 +18,279 @@ import React, { Component } from 'react'; import { connect } from 'react-redux' -import { Col } from 'reactstrap'; -import { NotebookServerOptions, NotebookServers, LogOutUser } from './Notebooks.present'; +import { NotebookServers } from './Notebooks.present'; +import { StartNotebookServer as StartNotebookServerPresent } from './Notebooks.present'; import NotebooksModel from './Notebooks.state'; import { Notebooks as NotebooksPresent } from './Notebooks.present'; - -class LaunchNotebookServer extends Component { +import { StatusHelper } from '../model/Model' + +/** + * Displays a start page for new Jupiterlab servers. + * + * @param {Object[]} branches Branches as redurnet by gitlab "/branches" API + * @param {function} refreshBranches Function to invoke to refresh the list of branches + * @param {number} projectId id of the reference project + * @param {number} projectPath path of the reference project + * @param {Object[]} client api-client used to query the gateway + * @param {string} successUrl Optional: url to redirect when then notebook is succesfully started + * @param {Object} history Optional: used with successUrl to properly set the new url without reloading the page + */ +class StartNotebookServer extends Component { constructor(props) { - super(props) - this.state = { - serverOptions: {}, - serverRunning: false, - serverStarting: false, - doLogOut: false + super(props); + this.model = new NotebooksModel(props.client); + + this.handlers = { + refreshBranches: this.refreshBranches.bind(this), + refreshCommits: this.refreshCommits.bind(this), + setBranch: this.setBranchFromName.bind(this), + setCommit: this.setCommitFromId.bind(this), + toggleMergedBranches: this.toggleMergedBranches.bind(this), + setDisplayedCommits: this.setDisplayedCommits.bind(this), + setServerOption: this.setServerOptionFromEvent.bind(this), + startServer: this.startServer.bind(this) + } + + this.state = { + first: true, + startring: false }; - this._unmounting = false; + } + + componentDidMount() { + this._isMounted = true; + this.startNotebookPolling(); + this.refreshBranches(); } componentWillUnmount() { - this._unmounting = true; + this.stopNotebookPolling(); + this._isMounted = false; } - componentDidMount() { - if (this.state.doLogOut === false) - this.componentDidUpdate() + componentDidUpdate(previousProps) { + // TODO: this is a temporary fix, remove it once the component won't be + // rerendered multiple times at the first url load + if (this.state.first && + StatusHelper.isUpdating(previousProps.branches) && + !StatusHelper.isUpdating(this.props.branches)) { + this.autoselectBranch(this.props.branches); + this.setState({ first: false }); + } } - componentDidUpdate() { - if (this.state.doLogOut === true) return; // We are about to refresh anyway... + refreshBranches() { + const { branches } = this.props; + if (StatusHelper.isUpdating(branches)) return; + this.props.refreshBranches().then((branches) => { + this.autoselectBranch(branches); + }); + } - if (this.props.core.notebookServerAPI !== this.previousNotebookServerAPI) { - this.serverStatusSet = false; - this.serverOptionsSet = false; + setBranch(branch) { + const oldBranch = this.model.get("filters.branch"); + if (!branch.name || branch.name === oldBranch.name) + return; + this.model.setBranch(branch); + this.refreshCommits(branch); + } + + setBranchFromName(eventOrName) { + const { branches } = this.props; + const branchName = eventOrName.target ? + eventOrName.target.value : + eventOrName; + for (let branchCurrent of branches) { + if (branchName === branchCurrent.name) { + this.setBranch(branchCurrent); + } } - this.previousNotebookServerAPI = this.props.core.notebookServerAPI; - this.setServerStatus(); - this.setServerOptions(); - } - - - setServerStatus() { - if (this.serverStatusSet) return; - if (!this.props.core.notebookServerAPI) return; - if (!this.props.client) return; - // Check for already running servers - const headers = this.props.client.getBasicHeaders(); - this.props.client.clientFetch(this.props.core.notebookServerAPI, {headers}) - .then(response => { - const serverStatus = !(!response.data.pending && !response.data.ready); - if (!this._unmounting) { - this.setState({serverRunning: serverStatus}); - this.serverStatusSet = true; - } - }).catch(e => { - if (e.case === 'UNAUTHORIZED') { - this.setState({ doLogOut: true }); - } - }); } - setServerOptions() { - if (this.serverOptionsSet) return; - if (!this.props.core.notebookServerAPI) return; - if (!this.props.client) return; - // Load options and save them to state, - // set intial selection values to defaults. - const headers = this.props.client.getBasicHeaders(); - - // TODO: Move this code to a method getServerOptions in api client library. - this.props.client.clientFetch(`${this.props.core.notebookServerAPI}/server_options`, { - cache: 'no-store', - headers - }) - .then(response => { - const data = response.data; - Object.keys(data).forEach(key => { - data[key].selected = data[key].default; - }) - if (!this._unmounting) { - this.setState({serverOptions: data}); - this.serverOptionsSet = true; - } - }) - .catch(e => { - if (e.case === 'UNAUTHORIZED'){ - this.setState({ doLogOut: true }); - } - }); - } + validateBranch(updatedBranches, updatedBranch) { + const branch = updatedBranch ? + updatedBranch : + this.model.get("filters.branch"); + if (branch.name === undefined) + return true; + + const branches = updatedBranches ? + updatedBranches : + this.props.branches; + const filterBranches = !this.model.get("filters.includeMergedBranches"); + const filteredBranches = filterBranches ? + branches.filter(branch => !branch.merged ? branch : null ) : + branches; + for (let branchCurrent of filteredBranches) { + if (branch.name === branchCurrent.name) { + return true; + } + } - getChangeHandlers() { - const handlers = {}; - // Add all resource change handlers. - Object.keys(this.state.serverOptions).forEach(key => { - handlers[key] = (e) => { - const newValue = this.state.serverOptions[key].type === 'boolean' ? - e.target.checked : e.target.value; + this.setBranch({}); + this.setCommit({}); + return false; + } - this.setState((prevState) => { - const newState = {...prevState}; - newState.serverOptions[key].selected = newValue; - return newState; - }) + autoselectBranch(branches, defaultBranch = "master") { + if (this._isMounted) { + const branch = this.model.get("filters.branch"); + if (!branch.name) { + const autoSelect = branches.filter(branch => branch.name === defaultBranch); + if (autoSelect.length !== 1) return; // improve this logic if necessary when defaultBranch will be dynamic + this.setBranch(autoSelect[0]); } + else { + this.validateBranch(branches); + } + } + } + + refreshCommits(updatedBranch) { + const commits = this.model.get("data.commits"); + if (StatusHelper.isUpdating(commits)) return; + const { projectId } = this.props; + const branch = updatedBranch && typeof updatedBranch === "string" ? + updatedBranch : + this.model.get("filters.branch"); + this.model.fetchCommits(projectId, branch.name).then((commits) => { + this.autoselectCommit(commits); }); - return handlers; } + setCommit(commit) { + const oldCommit = this.model.get("filters.commit"); + if (commit.id === oldCommit.id) { + return; + } + this.model.setCommit(commit); + this.model.verifyIfRunning(this.props.projectId, this.props.projectPath); + } - onSubmit(event) { - event.preventDefault(); + setCommitFromId(eventOrId) { + const commits = this.model.get("data.commits"); + const commitId = eventOrId.target ? + eventOrId.target.value : + eventOrId; + for (let commitCurrent of commits) { + if (commitId === commitCurrent.id) { + this.setCommit(commitCurrent); + } + } + } - const postData = { - serverOptions: {} - }; - Object.keys(this.state.serverOptions).forEach(key => { - postData.serverOptions[key] = this.state.serverOptions[key].selected - }); + validateCommit(updatedCommits, updatedCommit) { + const commit = updatedCommit ? + updatedCommit : + this.model.get("filters.commit"); + if (commit.id === undefined) { + return true; + } - this.setState({serverStarting: true}); - const headers = this.props.client.getBasicHeaders(); - headers.set('Content-Type', 'application/json'); - this.props.client.clientFetch(this.props.core.notebookServerAPI, { - method: 'POST', - headers: headers, - body: JSON.stringify(postData) - }) - .then(() => { - this.props.onSuccess() - }) + const commits = updatedCommits ? + updatedCommits : + this.model.get("data.commits"); + + const maxCommits = this.model.get("filters.displayedCommits"); + const filteredCommits = maxCommits && maxCommits > 0 ? + commits.slice(0, maxCommits) : + commits; + for (let commitCurrent of filteredCommits) { + if (commit.id === commitCurrent.id) { + // necessary for istant refresh in the UI + this.model.verifyIfRunning(this.props.projectId, this.props.projectPath); + return true; + } + } - // Note that the opening of the new tab must happen - // on click and can not be delayed (pop-up blocking) - // window.open(this.props.core.notebookServerUrl); + this.setCommit({}); + return false; + } + + autoselectCommit(commits, defaultCommit = 0) { + if (this._isMounted) { + if (this._isMounted) { + const commit = this.model.get("filters.commit"); + if (!commit.id) { + const autoSelect = commits[defaultCommit]; + if (!autoSelect) return; // improve this logic if "latest" won't be the default anymore + this.setCommit(autoSelect); + } + else { + this.validateCommit(commits); + } + } + } } + setServerOptionFromEvent(option, event) { + const value = event.target.checked !== undefined ? + event.target.checked: + event.target.value; + this.model.setNotebookOptions(option, value); + } - render() { - if (!this.props.client) return null; - - if (this.state.doLogOut) { - return - } else if (this.state.serverRunning) { - return -

You already have a server running.

- + startServer() { + // Data from notebooks/servers endpoint needs some time to update propery. + // To avoid flickering UI, just set a temporary state and display a loading wheel. + // TODO: change this when the notebook service will be updated. + const { successUrl } = this.props; + if (!successUrl) { + this.model.startServer(this.props.projectPath); } else { - return + this.setState({ "starting": true }); + this.model.startServer(this.props.projectPath).then((data) => { + this.props.history.push(successUrl); + }); + } + } + + toggleMergedBranches(event) { + const currentSetting = this.model.get("filters.includeMergedBranches"); + this.model.setMergedBranches(!currentSetting); + this.validateBranch(); + } + + setDisplayedCommits(event) { + this.model.setDisplayedCommits(event.target.value); + this.validateCommit(); + } + + startNotebookPolling() { + this.model.startNotebookPolling(this.props.projectId, this.props.projectPath); + } + + stopNotebookPolling() { + this.model.stopNotebookPolling(); + } + + mapStateToProps(state, ownProps) { + const augmentedState = { ...state, + data: {...state.data, + branches: ownProps.inherited.branches + } // add "branches" to data + }; + return { + handlers: this.handlers, + store: ownProps.store, // adds store and other props manually added to + ...augmentedState } } + + render() { + const ConnectedStartNotebookServer = connect(this.mapStateToProps.bind(this))(StartNotebookServerPresent); + + return ; + } } class Notebooks extends Component { @@ -223,4 +339,4 @@ class Notebooks extends Component { } -export { LaunchNotebookServer, NotebookServers, Notebooks }; +export { NotebookServers, Notebooks, StartNotebookServer }; diff --git a/src/notebooks/Notebooks.present.js b/src/notebooks/Notebooks.present.js index 2375a10037..1708de194d 100644 --- a/src/notebooks/Notebooks.present.js +++ b/src/notebooks/Notebooks.present.js @@ -20,17 +20,19 @@ import React, { Component } from 'react'; import Media from 'react-media'; import { Link } from 'react-router-dom'; -import { Form, FormGroup, Label, Input, Button, Row, Col, Table} from 'reactstrap'; +import { Form, FormGroup, FormText, Label, Input, Button, Row, Col, Table} from 'reactstrap'; import { UncontrolledButtonDropdown, DropdownToggle, DropdownMenu, DropdownItem} from 'reactstrap'; +import { UncontrolledTooltip, UncontrolledPopover, PopoverHeader, PopoverBody } from 'reactstrap'; +// temporary issue with UncontrolledTooltip --> https://github.com/reactstrap/reactstrap/issues/1255 import FontAwesomeIcon from '@fortawesome/react-fontawesome'; -import { faStopCircle, faExternalLinkAlt, faInfoCircle } from '@fortawesome/fontawesome-free-solid'; +import { faStopCircle, faExternalLinkAlt, faInfoCircle, faSyncAlt, faCogs } from '@fortawesome/fontawesome-free-solid'; -import { SpecialPropVal } from '../model/Model'; -import { Loader, InfoAlert } from '../utils/UIComponents'; +import { SpecialPropVal, StatusHelper } from '../model/Model'; +import { Loader, InfoAlert, ExternalLink } from '../utils/UIComponents'; +import Time from '../utils/Time'; import Sizes from '../utils/Media'; import { cleanAnnotations } from '../api-client/notebook-servers'; - const Columns = { large: { default: ["Project", "Commit", "Action"], @@ -42,148 +44,6 @@ const Columns = { } }; -class LogOutUser extends Component { - - constructor(props) { - super(props); - this.state = { - timer: null, - logedout: false - } - } - - componentWillUnmount() { - this.state.timer.clear() - } - - componentDidMount() { - this.setState({ - timer: setTimeout(() => { - this.setState({ logedout: true }); - this.props.client.doLogout(); - }, 6000) - }); - } - - render() { - return ( - this.state.logedout ? - We logged you out. - : - -

You will be logged out because your JupyterLab token expired. -
Please log in again to continue working with Renku. -

- - - ) - } -} - -class RenderedServerOptions extends Component { - render() { - if (this.props.loader) { - return - } - const renderedServerOptions = Object.keys(this.props.serverOptions).map(key => { - const serverOption = this.props.serverOptions[key]; - const onChange = this.props.changeHandlers[key]; - - switch (serverOption.type) { - case 'enum': - return - - - ; - - case 'int': - return - - - ; - - case 'float': - return - - - ; - - case 'boolean': - return - - - ; - - default: - return null; - } - }); - return
- {renderedServerOptions} - -
- } -} - -class NotebookServerOptions extends Component { - render() { - return ( -
- -

Launch new JupyterLab server

-
-   - - - -
- ); - } -} - -class EnumOption extends Component { - render() { - return ( - - {this.props.options.map((optionName, i) => { - return - })} - - ); - } -} - -class BooleanOption extends Component { - render() { - return ( - - ); - } -} - -class RangeOption extends Component { - render() { - return ( - - ); - } -} - class NotebookServerRowAction extends Component { render() { const {status, name} = this.props; @@ -477,4 +337,375 @@ class Notebooks extends Component { } } -export { NotebookServerOptions, NotebookServers, LogOutUser, Notebooks } +class StartNotebookServer extends Component { + render() { + return ( + + +

Start new Jupyterlab server

+
+ + + + + +
+ ) + } +} + +class StartNotebookBranches extends Component { + render() { + const { branches } = this.props.data; + let content; + if (StatusHelper.isUpdating(branches) || branches.length === 0) { + content = ( + + ) + } + else { + if (branches.length === 1) { + content = ( + + + + + + ) + } + else { + const filter = !this.props.filters.includeMergedBranches; + const filteredBranches = filter ? + branches.filter(branch => !branch.merged ? branch : null ) : + branches; + let branchOptions = filteredBranches.map((branch, index) => { + return + }); + content = ( + + + + + {branchOptions} + + + ) + } + } + return ( + + {content} + + ) + } +} + +class StartNotebookBranchesUpdate extends Component { + render() { + return [ + , + + Refresh branches + + ] + } +} + +class StartNotebookBranchesOptions extends Component { + render() { + return [ + , + + Branch options + , + + Branch options + + + + + + + ] + } +} + +class StartNotebookCommits extends Component { + render() { + const { branch } = this.props.filters; + const { branches, commits } = this.props.data; + if (!branch.name || StatusHelper.isUpdating(branches)) { + return null; + } + let content; + if (StatusHelper.isUpdating(commits)) { + content = ( + + ) + } + else { + const maxCommits = this.props.filters.displayedCommits; + const filteredCommits = maxCommits && maxCommits > 0 ? + commits.slice(0, maxCommits) : + commits; + const commitOptions = filteredCommits.map((commit) => { + return + }); + content = ( + + + + + {commitOptions} + + + ) + } + + return ( + content + ) + } +} + +class StartNotebookCommitsUpdate extends Component { + render() { + return [ + , + + Refresh commits + + ] + } +} + +class StartNotebookCommitsOptions extends Component { + render() { + return [ + , + + Commit options + , + + Commit options + + + + + 1-100, 0 for unlimited + + + + ] + } +} + +class StartNotebookOptions extends Component { + render() { + const { commit } = this.props.filters; + const { branches, commits } = this.props.data; + if (!commit.id || StatusHelper.isUpdating(branches) || StatusHelper.isUpdating(commits)) { + return null; + } + const { justStarted } = this.props; + if (justStarted) { + return + } + const { status, url } = this.props.notebooks; + let content; + if (status == null) { + content = ( + + ); + } + else if (status === false) { + const { notebookOptions } = this.props.data; + if (!notebookOptions.commitId || notebookOptions.commitId !== commit.id) { + content = ( + + ); + } + else { + content = [ + , + + ]; + } + } + else { + if (status === "running") { + content = ( + + +
+ +
+ ); + } + else if (status === "spawn") { + content = ( + + + + ); + } + else if (status === "stop") { + content = ( + + + + ); + } + else { + content = ( + + + + ); + } + } + return content; + } +} + +class StartNotebookServerOptions extends Component { + render() { + const { notebookOptions, selectedOptions } = this.props.data; + const renderedServerOptions = Object.keys(notebookOptions) + .filter(key => key !== "commitId") + .map(key => { + const serverOption = { ...notebookOptions[key], selected: selectedOptions[key] }; + const onChange = (event) => { + this.props.handlers.setServerOption(key, event); + }; + + switch (serverOption.type) { + case 'enum': + return + + + ; + + case 'int': + return + + + ; + + case 'float': + return + + + ; + + case 'boolean': + return + + + ; + + default: + return null; + } + }); + return renderedServerOptions.length ? + renderedServerOptions : + ; + } +} + +class ServerOptionEnum extends Component { + render() { + return ( + + {this.props.options.map((optionName, i) => { + return + })} + + ); + } +} + +class ServerOptionBoolean extends Component { + render() { + return ( + + ); + } +} + +class ServerOptionRange extends Component { + render() { + return ( + + ); + } +} + +class ServerOptionLaunch extends Component { + render() { + return ( + + ); + } +} + +export { NotebookServers, Notebooks, StartNotebookServer } diff --git a/src/notebooks/Notebooks.state.js b/src/notebooks/Notebooks.state.js index 4959cf1c34..2551737f55 100644 --- a/src/notebooks/Notebooks.state.js +++ b/src/notebooks/Notebooks.state.js @@ -24,12 +24,33 @@ */ import { Schema, StateKind, StateModel } from '../model/Model'; +import { cleanAnnotations } from '../api-client/notebook-servers'; const notebooksSchema = new Schema({ notebooks: { schema: { polling: {initial: null}, - all: {initial: {}} + all: {initial: {}}, + status: {initial: null}, + url: {initial: null} + } + }, + filters: { + schema: { + branch: {initial: {}}, + commit: {initial: {}}, + includeMergedBranches: {initial:false}, + displayedCommits: {initial:10}, + displayCommitAuthor: {initial:true}, + displayCommitTimestamp: {initial:true} + } + }, + data: { + schema: { + commits: {initial:[]}, // is this the proper place? should this logic moved to Project? + error: {initial:null}, + notebookOptions: {initial:{}}, + selectedOptions: {initial:{}} } } }); @@ -40,6 +61,37 @@ class NotebooksModel extends StateModel { this.client = client; } + setMergedBranches(value) { + this.set('filters.includeMergedBranches', value); + } + + setDisplayedCommits(value) { + this.set('filters.displayedCommits', value); + } + + setBranch(branch) { + this.set('filters.branch', branch); + } + + setCommit(commit) { + this.set('filters.commit', commit); + } + + async fetchCommits(projectId, branchName) { + if (branchName == null) { + this.set('data.commits', []); + return []; + } + else { + this.setUpdating({data: {commits: true}}); + return this.client.getCommits(projectId, branchName) + .then(resp => { + this.set('data.commits', resp.data); + return resp.data; + }); + } + } + fetchNotebooks(first) { if (first) { this.setUpdating({notebooks: {all: true}}); @@ -47,20 +99,124 @@ class NotebooksModel extends StateModel { return this.client.getNotebookServers() .then(resp => { this.set('notebooks.all', resp.data); + return resp.data; }); } - startNotebookPolling() { + verifyIfRunning(projectId, projectPath, servers) { + const notebooks = servers ? + servers : + this.get('notebooks.all'); + const branch = this.get('filters.branch'); + const commit = this.get('filters.commit'); + for (let notebookName of Object.keys(notebooks)) { + const notebook = notebooks[notebookName]; + const annotations = cleanAnnotations(notebook["annotations"], "renku.io"); + if (parseInt(annotations.projectId) !== projectId) continue; + if (annotations["branch"] === branch.name && annotations["commit-sha"] === commit.id) { + this.setObject({ notebooks: { + status: notebook.ready ? + "running" : + notebook.pending, + url: notebook.url + }}); + return true; + } + } + this.setObject({notebooks: { + status: false, + url: null + }}); + + // fetch notebook options + this.fetchNotebookOptions(projectPath, commit.id); + return false; + } + + notebookPollingIteration(verifyRunningId, projectPath) { + const fetchPromise = this.fetchNotebooks(); + if (verifyRunningId) { + return fetchPromise.then((servers) => { + return this.verifyIfRunning(verifyRunningId, projectPath, servers); + }); + } + else { + return fetchPromise; + } + } + + startNotebookPolling(verifyRunningId, projectPath) { + if (verifyRunningId) { + this.setObject({notebooks: { + status: false, + url: null + }}); + } const oldPoller = this.get('notebooks.polling'); if (oldPoller == null) { const newPoller = setInterval(() => { - return this.fetchNotebooks(); + this.notebookPollingIteration(verifyRunningId, projectPath); }, 3000); this.set('notebooks.polling', newPoller); // fetch immediatly - return this.fetchNotebooks(true); + this.notebookPollingIteration(verifyRunningId, projectPath); + } + } + + fetchNotebookOptions(projectPath, commitId) { + const oldData = this.get('data.notebookOptions'); + if (oldData.commitId === commitId) + return; + this.set('data.notebookOptions', {}); + return this.client.getNotebookServerOptions(projectPath, commitId).then((resp)=> { + const notebookOptions = { ...resp, commitId }; + let selectedOptions = {}; + Object.keys(resp).forEach(option => { + selectedOptions[option] = resp[option].default; + }) + const options = { + data: { + notebookOptions, + selectedOptions + } + }; + this.setObject(options); + return options; + }); + } + + setNotebookOptions(option, value) { + this.set(`data.selectedOptions.${option}`, value); + } + + startServer(projectPath, branchName, commitId) { + const options = { + serverOptions: this.get('data.selectedOptions') + }; + let branch = branchName; + if (!branchName) { + const selctedBranch = this.get('filters.branch'); + if (selctedBranch.name) { + branch = selctedBranch.name; + } + else { + branch = "master"; + } } + let commit = commitId; + if (!commitId) { + const selctedCommit = this.get('filters.commit'); + if (selctedCommit.id) { + commit = selctedCommit.id; + } + else { + commit = "latest"; + } + } + + this.set('notebooks.status', 'spawn'); + return this.client.startNotebook(projectPath, branch, commit, options); } stopNotebookPolling() { diff --git a/src/notebooks/Notebooks.test.js b/src/notebooks/Notebooks.test.js index c749df7e87..2f631dfd5b 100644 --- a/src/notebooks/Notebooks.test.js +++ b/src/notebooks/Notebooks.test.js @@ -26,7 +26,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { MemoryRouter } from 'react-router-dom'; -import { Notebooks } from './Notebooks.container'; +import { Notebooks, StartNotebookServer } from './Notebooks.container'; import { cleanAnnotations, ExpectedAnnotations } from '../api-client/notebook-servers'; import { testClient as client } from '../api-client' import { generateFakeUser } from '../app-state/UserState.test'; @@ -41,6 +41,17 @@ describe('rendering', () => { , div); }); + + it('renders launch notebook page without crashing', () => { + const div = document.createElement('div'); + const refreshBranches = async function() { + return []; + }; + ReactDOM.render( + + + , div); + }); }); describe('notebook server clean annotation', () => { diff --git a/src/notebooks/index.js b/src/notebooks/index.js index 45cfe2cee2..46ff30d11e 100644 --- a/src/notebooks/index.js +++ b/src/notebooks/index.js @@ -23,7 +23,6 @@ * */ -import { LaunchNotebookServer, NotebookServers } from './Notebooks.container'; -import { Notebooks } from './Notebooks.container'; +import { Notebooks, NotebookServers, StartNotebookServer } from './Notebooks.container'; -export { LaunchNotebookServer, NotebookServers, Notebooks } +export { NotebookServers, Notebooks, StartNotebookServer } diff --git a/src/project/Project.js b/src/project/Project.js index efcd162089..a26b5a386a 100644 --- a/src/project/Project.js +++ b/src/project/Project.js @@ -35,7 +35,6 @@ import { FileLineage } from '../file' import { ACCESS_LEVELS } from '../api-client'; import { alertError } from '../utils/Errors'; import { MergeRequest, MergeRequestList } from '../merge-request'; -import { LaunchNotebookServer } from '../notebooks'; import List from './list'; import New from './new'; @@ -74,7 +73,7 @@ class View extends Component { return this.projectState.startNotebookServersPolling(this.props.client, this.props.id,PollingInterval.START); } async stopNotebookServersPolling() { return this.projectState.stopNotebookServersPolling(); } - async stopNotebookServer(serverName) { return this.projectState.stopNotebookServer(this.props.client, serverName); } + async stopNotebookServer(serverName) { return this.projectState.stopNotebookServer(this.props.client, serverName, this.props.id); } async createGraphWebhook() { return this.projectState.createGraphWebhook(this.props.client, this.props.id); } async stopCheckingWebhook() { this.projectState.stopCheckingWebhook(); } async fetchGraphWebhook() { this.projectState.fetchGraphWebhook(this.props.client, this.props.id, this.props.user); } @@ -224,7 +223,6 @@ class View extends Component { }; const ConnectedMergeRequestList = connect(mapStateToProps)(MergeRequestList); - const ConnectedLaunchNotebookServer = connect(this.projectState.mapStateToProps)(LaunchNotebookServer); return { kuList: , @@ -260,14 +258,7 @@ class View extends Component { mrView: (p) => , - - launchNotebookServer: (p) => this.props.history.push(`/projects/${this.projectState.get('core.id')}/notebookServers`)} - /> + updateProjectState={this.fetchAll.bind(this)}/> } } @@ -341,6 +332,9 @@ class View extends Component { }, fetchNotebookServerUrl: () => { return this.fetchNotebookServerUrl(); + }, + fetchBranches: () => { + return this.fetchBranches(); } }; diff --git a/src/project/Project.present.js b/src/project/Project.present.js index 671e59ad58..4217e49342 100644 --- a/src/project/Project.present.js +++ b/src/project/Project.present.js @@ -47,7 +47,7 @@ import { ExternalLink, Loader, RenkuNavLink, TimeCaption} from '../utils/UICompo import { InfoAlert, SuccessAlert, WarnAlert, ErrorAlert } from '../utils/UIComponents' import { SpecialPropVal } from '../model/Model' import { ProjectTags, ProjectTagList } from './shared' -import { NotebookServers } from '../notebooks' +import { NotebookServers, StartNotebookServer } from '../notebooks' import FilesTreeView from './filestreeview/FilesTreeView'; import { ACCESS_LEVELS } from '../api-client'; @@ -611,6 +611,24 @@ class ProjectNotebookServers extends Component { } } +class ProjectStartNotebookServer extends Component { + render() { + return ( + + + + ) + } +} + class RepositoryUrls extends Component { render() { return [ @@ -769,7 +787,7 @@ class ProjectView extends Component { } /> + render={props => }/> ] diff --git a/src/project/Project.state.js b/src/project/Project.state.js index f56f7f9df5..a680ac672f 100644 --- a/src/project/Project.state.js +++ b/src/project/Project.state.js @@ -274,7 +274,7 @@ class ProjectModel extends StateModel { }); } - stopNotebookServer(client, serverName) { + stopNotebookServer(client, serverName, id) { // manually set the state instead of waiting for the promise to resolve const updatedState = { notebooks: { @@ -286,6 +286,11 @@ class ProjectModel extends StateModel { } } } + + const oldInterval = this.get('notebooks.pollingInterval'); + if (oldInterval !== PollingInterval.START) { + this.changeNotebookServerPollingInterval(client, id, PollingInterval.START); + } this.setObject(updatedState); return client.stopNotebookServer(serverName); } @@ -317,10 +322,11 @@ class ProjectModel extends StateModel { fetchBranches(client, id) { this.setUpdating({system: {branches: true}}); - client.getBranches(id) + return client.getBranches(id) .then(resp => resp.data) .then(d => { this.set('system.branches', d) + return d; }) } diff --git a/src/utils/Time.js b/src/utils/Time.js index 7535f07064..5f7e909f6c 100644 --- a/src/utils/Time.js +++ b/src/utils/Time.js @@ -38,7 +38,7 @@ class Time { if (this.isDate(convertedDate)) { return convertedDate; } - throw("Invalid date"); + throw(new Error("Invalid date")); } static toISOString(inputDate, type="datetime") { @@ -54,7 +54,7 @@ class Time { return readableDate.substring(11); } else { - throw(`Uknown type "${type}"`); + throw(new Error(`Uknown type "${type}"`)); } } }