├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── RELEASE.md ├── jupyterlab_heroku ├── __init__.py ├── _version.py ├── handlers.py └── heroku.py ├── package.json ├── setup.py ├── src ├── client.ts ├── components │ ├── apps.tsx │ └── settings.tsx ├── index.ts ├── model.ts ├── panel.ts └── tokens.ts ├── style ├── icons │ ├── heroku-dark.svg │ └── heroku-light.svg ├── index.css └── variables.css ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | *.bundle.* 2 | *.tsbuildinfo 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | env/ 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # dotenv 86 | .env 87 | 88 | # virtualenv 89 | .venv 90 | venv/ 91 | ENV/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | node_modules/ 107 | 108 | .vscode 109 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2019, Jeremy Tuloup 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jupyterlab-heroku 2 | 3 | [![npm](https://img.shields.io/npm/v/jupyterlab-heroku.svg?style=flat-square)](https://www.npmjs.com/package/jupyterlab-heroku) 4 | [![pypi](https://img.shields.io/pypi/v/jupyterlab-heroku.svg?style=flat-square)](https://pypi.python.org/pypi/jupyterlab-heroku) 5 | 6 | JupyterLab extension to manage and deploy applications to Heroku. 7 | 8 | The extension enables 1-click deployments of [voila](https://github.com/QuantStack/voila) dashboards. 9 | 10 | ![screencast](https://user-images.githubusercontent.com/591645/61288265-0d00d680-a7c7-11e9-84ff-bb3a1ef2bfc5.gif) 11 | 12 | ## Requirements 13 | 14 | ### Create an Heroku account 15 | 16 | _If you already have an Heroku account, you can skip to the next section_. 17 | 18 | You can create a new Heroku account by following these instructions: 19 | https://signup.heroku.com/ 20 | 21 | ### Install the heroku client 22 | 23 | You need the `heroku` client installed on your machine to be able to deploy applications. To set it up: 24 | https://devcenter.heroku.com/articles/getting-started-with-python#set-up 25 | 26 | Once the setup is complete, test the installation with: 27 | 28 | ``` 29 | $ heroku --version 30 | heroku/7.26.2 linux-x64 node-v11.14.0 31 | ``` 32 | 33 | ### Login to Heroku 34 | 35 | There are different ways to login to Heroku: 36 | 37 | 1. `heroku login` will open a new browser tab to log in with the email and password 38 | 2. create a `~/.netrc` file with the api token (see [the documentation](https://devcenter.heroku.com/articles/authentication#usage-examples) for more details) 39 | 3. set the `HEROKU_API_KEY` environment variable 40 | 41 | To test the authentication: `heroku apps` 42 | 43 | ### Other Dependencies 44 | 45 | This extension also requires: 46 | 47 | - JupyterLab 1.0 48 | - `git` 49 | 50 | ## Install 51 | 52 | With `pip`: 53 | 54 | ```bash 55 | pip install jupyterlab-heroku 56 | ``` 57 | 58 | With `conda`: 59 | 60 | ```bash 61 | conda install -c conda-forge jupyterlab_heroku 62 | ``` 63 | 64 | To enable the extensions: 65 | 66 | ```bash 67 | jupyter serverextension enable --sys-prefix --py jupyterlab_heroku 68 | jupyter labextension install jupyterlab-heroku 69 | ``` 70 | 71 | Since Heroku uses `git` to deploy applications, it is also recommended to install the `jupyterlab-git` extension for JupyterLab: 72 | 73 | ```bash 74 | conda install -c conda-forge jupyterlab-git 75 | # with pip: pip install jupyterlab-git 76 | 77 | jupyter serverextension enable --sys-prefix --py jupyterlab_git 78 | jupyter labextension install @jupyterlab/git 79 | ``` 80 | 81 | ## Contributing 82 | 83 | ### Install 84 | 85 | ```bash 86 | # Clone the repo to your local environment 87 | # Move to jupyterlab-heroku directory 88 | 89 | # Create a new conda environment 90 | conda create -n jupyterlab-heroku -c conda-forge jupyterlab nodejs 91 | 92 | # Install the server extension 93 | python -m pip install -e . 94 | jupyter serverextension enable --sys-prefix --py jupyterlab_heroku 95 | 96 | # Install dependencies 97 | jlpm 98 | 99 | # Build Typescript source 100 | jlpm build 101 | 102 | # Link your development version of the extension with JupyterLab 103 | jupyter labextension link . 104 | 105 | # Rebuild Typescript source after making changes 106 | jlpm build 107 | 108 | # Rebuild JupyterLab after making any changes 109 | jupyter lab build 110 | ``` 111 | 112 | You can watch the source directory and run JupyterLab in watch mode to watch for changes in the extension's source and automatically rebuild the extension and application. 113 | 114 | ```bash 115 | # Watch the source directory in another terminal tab 116 | jlpm watch 117 | 118 | # Run jupyterlab in watch mode in one terminal tab 119 | jupyter lab --watch 120 | ``` 121 | 122 | ### Uninstall 123 | 124 | ```bash 125 | jupyter labextension uninstall jupyterlab-heroku 126 | ``` 127 | 128 | ## Alternatives 129 | 130 | To deploy Voila applications to Heroku using the command line: https://github.com/martinRenou/voila_heroku 131 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Making a new release 2 | 3 | ## Getting a clean environment 4 | 5 | Creating a new environment can help avoid pushing local changes and any extra tag. 6 | 7 | ```bash 8 | conda create -n jupyterlab-heroku-release -c conda-forge twine nodejs 9 | conda activate jupyterlab-heroku-release 10 | ``` 11 | 12 | Alternatively, the local repository can be cleaned with: 13 | 14 | ```bash 15 | git clean -fdx 16 | ``` 17 | 18 | ## Releasing on npm 19 | 20 | 1. Update [package.json](./package.json) with the new version. 21 | 2. `npm login` 22 | 3. `npm publish` 23 | 24 | ## Releasing on PyPI 25 | 26 | Make sure the `dist/` folder is empty. 27 | 28 | 1. Update [jupyterlab_heroku/\_version.py](./jupyterlab_heroku/_version.py) with the new version. 29 | 2. `python setup.py sdist bdist_wheel` 30 | 3. `twine upload dist/*` 31 | 32 | ## Releasing on conda-forge 33 | 34 | TODO 35 | 36 | ## Committing and tagging 37 | 38 | Commit the changes, create a new release tag where `x.y.z` denotes the new version: 39 | 40 | ```bash 41 | git checkout master 42 | git add jupyterlab_heroku/_version.py package.json 43 | git commit -m "Release x.y.z" 44 | git tag x.y.z 45 | git push origin master x.y.z 46 | ``` 47 | -------------------------------------------------------------------------------- /jupyterlab_heroku/__init__.py: -------------------------------------------------------------------------------- 1 | from ._version import __version__ 2 | 3 | from jupyterlab_heroku.handlers import setup_handlers 4 | from jupyterlab_heroku.heroku import Heroku 5 | 6 | 7 | def _jupyter_server_extension_paths(): 8 | return [{"module": "jupyterlab_heroku"}] 9 | 10 | 11 | def load_jupyter_server_extension(nb_app): 12 | heroku = Heroku(nb_app.web_app.settings.get("server_root_dir")) 13 | nb_app.web_app.settings["heroku"] = heroku 14 | setup_handlers(nb_app.web_app) 15 | -------------------------------------------------------------------------------- /jupyterlab_heroku/_version.py: -------------------------------------------------------------------------------- 1 | version_info = (0, 2, 1) 2 | __version__ = ".".join(map(str, version_info)) 3 | -------------------------------------------------------------------------------- /jupyterlab_heroku/handlers.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | 4 | from notebook.utils import url_path_join as ujoin 5 | from notebook.base.handlers import APIHandler 6 | 7 | 8 | class HerokuHandler(APIHandler): 9 | """ 10 | Top-level parent class. 11 | """ 12 | 13 | @property 14 | def heroku(self): 15 | return self.settings["heroku"] 16 | 17 | @property 18 | def current_path(self): 19 | return self.get_json_body()["current_path"] 20 | 21 | 22 | class HerokuStatus(HerokuHandler): 23 | async def post(self): 24 | result = await self.heroku.status(self.current_path) 25 | self.finish(json.dumps(result)) 26 | 27 | 28 | class HerokuLogs(HerokuHandler): 29 | async def post(self): 30 | result = await self.heroku.logs(self.current_path) 31 | self.finish(json.dumps(result)) 32 | 33 | 34 | class HerokuCreate(HerokuHandler): 35 | async def post(self): 36 | result = await self.heroku.create(self.current_path) 37 | self.finish(json.dumps(result)) 38 | 39 | 40 | class HerokuDestroy(HerokuHandler): 41 | async def post(self): 42 | body = self.get_json_body() 43 | app = body.get("app") 44 | result = await self.heroku.destroy(self.current_path, app) 45 | self.finish(json.dumps(result)) 46 | 47 | 48 | class HerokuApps(HerokuHandler): 49 | async def post(self): 50 | result = await self.heroku.apps(self.current_path) 51 | self.finish(json.dumps(result)) 52 | 53 | 54 | class HerokuDeploy(HerokuHandler): 55 | async def post(self): 56 | result = await self.heroku.deploy(self.current_path) 57 | self.finish(json.dumps(result)) 58 | 59 | 60 | class HerokuSettings(HerokuHandler): 61 | async def post(self): 62 | result = await self.heroku.settings(self.current_path) 63 | return self.finish(json.dumps(result)) 64 | 65 | 66 | class HerokuSettingsUpdate(HerokuHandler): 67 | async def post(self): 68 | body = self.get_json_body() 69 | runtime = body.get("runtime") 70 | dependencies = body.get("dependencies") 71 | procfile = body.get("procfile") 72 | result = await self.heroku.update_settings(self.current_path, runtime, dependencies, procfile) 73 | self.finish(json.dumps(result)) 74 | 75 | 76 | def setup_handlers(web_app): 77 | """ 78 | Setups the handlers for interacting with the heroku client. 79 | """ 80 | 81 | heroku_handlers = [ 82 | ("/heroku/status", HerokuStatus), 83 | ("/heroku/logs", HerokuLogs), 84 | ("/heroku/create", HerokuCreate), 85 | ("/heroku/destroy", HerokuDestroy), 86 | ("/heroku/apps", HerokuApps), 87 | ("/heroku/deploy", HerokuDeploy), 88 | ("/heroku/settings", HerokuSettings), 89 | ("/heroku/settings/update", HerokuSettingsUpdate), 90 | ] 91 | 92 | base_url = web_app.settings["base_url"] 93 | heroku_handlers = [ 94 | (ujoin(base_url, path), handler) for path, handler in heroku_handlers 95 | ] 96 | web_app.add_handlers(".*", heroku_handlers) 97 | -------------------------------------------------------------------------------- /jupyterlab_heroku/heroku.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import os 4 | import subprocess 5 | from subprocess import PIPE, Popen 6 | 7 | 8 | class Heroku: 9 | def __init__(self, root_dir): 10 | self.root_dir = os.path.realpath(os.path.expanduser(root_dir)) 11 | 12 | def _error(self, code, message): 13 | return {"code": code, "message": message} 14 | 15 | async def _execute_command(self, current_path, cmd): 16 | p = await asyncio.create_subprocess_exec( 17 | *cmd, 18 | stdout=PIPE, 19 | stderr=PIPE, 20 | cwd=os.path.join(self.root_dir, current_path), 21 | ) 22 | out, err = await p.communicate() 23 | code = p.returncode 24 | if code != 0: 25 | return code, err.decode("utf-8") 26 | return code, out.decode("utf-8") 27 | 28 | async def _get_git_root(self, current_path): 29 | cmd = ["git", "rev-parse", "--show-toplevel"] 30 | code, res = await self._execute_command(current_path, cmd) 31 | if code != 0: 32 | return 33 | return res.strip() 34 | 35 | async def _get_remotes(self, current_path): 36 | # TODO: handle multiple remotes / apps 37 | cmd = ["git", "remote", "get-url", "heroku"] 38 | code, res = await self._execute_command(current_path, cmd) 39 | if code != 0: 40 | return [] 41 | return res.splitlines() 42 | 43 | async def status(self, current_path): 44 | cmd = ["git", "status"] 45 | code, res = await self._execute_command(current_path, cmd) 46 | if code != 0: 47 | return self._error(code, res) 48 | 49 | git_status = res.splitlines() 50 | return {"code": code, "status": { "git": git_status }} 51 | 52 | async def logs(self, current_path): 53 | cmd = ["heroku", "logs"] 54 | code, res = await self._execute_command(current_path, cmd) 55 | if code != 0: 56 | return self._error(code, res) 57 | 58 | logs = res.splitlines() 59 | return {"code": code, "logs": logs} 60 | 61 | async def create(self, current_path): 62 | git_root = await self._get_git_root(current_path) 63 | if not git_root: 64 | return self._error(400, "Not in a git repository") 65 | 66 | cmd = ["git", "remote", "remove", "heroku"] 67 | # ignore error if the remote doesn't exist 68 | _ = await self._execute_command(current_path, cmd) 69 | 70 | cmd = ["heroku", "create", "--json", "-r", "heroku"] 71 | code, res = await self._execute_command(current_path, cmd) 72 | if code != 0: 73 | return self._error(code, res) 74 | 75 | app = json.loads(res) 76 | return {"code": code, "app": app} 77 | 78 | async def destroy(self, current_path, app): 79 | cmd = ["heroku", "destroy", "--app", app, "--confirm", app] 80 | code, res = await self._execute_command(current_path, cmd) 81 | if code != 0: 82 | return self._error(code, res) 83 | 84 | cmd = ["git", "remote", "remove", "heroku"] 85 | # ignore error if the remote doesn't exist 86 | _ = await self._execute_command(current_path, cmd) 87 | 88 | return {"code": code} 89 | 90 | async def apps(self, current_path): 91 | all_remotes = await self._get_remotes(current_path) 92 | if not all_remotes: 93 | return {"code": 0, "apps": []} 94 | 95 | cmd = ["heroku", "apps", "--json"] 96 | code, res = await self._execute_command(current_path, cmd) 97 | if code != 0: 98 | return self._error(code, res) 99 | 100 | all_apps = json.loads(res) 101 | remotes = set(all_remotes) 102 | apps = [app for app in all_apps if app["git_url"] in remotes] 103 | return {"code": code, "apps": apps} 104 | 105 | async def deploy(self, current_path): 106 | all_remotes = await self._get_remotes(current_path) 107 | if not all_remotes: 108 | return self._error(500, "No Heroku remote in the current directory") 109 | 110 | cmd = ["git", "push", "heroku", "master"] 111 | code, res = await self._execute_command(current_path, cmd) 112 | if code != 0: 113 | return self._error(code, res) 114 | 115 | return {"code": code} 116 | 117 | async def settings(self, current_path): 118 | all_remotes = await self._get_remotes(current_path) 119 | if not all_remotes: 120 | return self._error(500, "No Heroku remote in the current directory") 121 | 122 | git_root = await self._get_git_root(current_path) 123 | if not git_root: 124 | return self._error(500, "Not in a git repository") 125 | 126 | runtime, dependencies, procfile = "", "", "" 127 | 128 | runtime_file = f"{git_root}/runtime.txt" 129 | if os.path.exists(runtime_file): 130 | with open(runtime_file) as f: 131 | runtime = f.read().strip() 132 | 133 | dependencies_file = f"{git_root}/requirements.txt" 134 | if os.path.exists(dependencies_file): 135 | with open(dependencies_file) as f: 136 | dependencies = f.read().strip() 137 | 138 | procfile_file = f"{git_root}/Procfile" 139 | if os.path.exists(procfile_file): 140 | with open(procfile_file) as f: 141 | procfile = f.read().strip() 142 | 143 | return { 144 | "code": 0, 145 | "settings": {"runtime": runtime, "dependencies": dependencies, "procfile": procfile}, 146 | } 147 | 148 | async def update_settings(self, current_path, runtime=None, dependencies=None, procfile=None): 149 | if not runtime and not dependencies and not procfile: 150 | return self._error(400, "No settings specified") 151 | 152 | all_remotes = await self._get_remotes(current_path) 153 | if not all_remotes: 154 | return self._error(500, "No Heroku remote in the current directory") 155 | 156 | git_root = await self._get_git_root(current_path) 157 | if not git_root: 158 | return self._error(500, "Not in a git repository") 159 | 160 | if runtime is not None: 161 | with open(f"{git_root}/runtime.txt", "w") as f: 162 | f.write(f"{runtime}\n") 163 | 164 | if dependencies is not None: 165 | with open(f"{git_root}/requirements.txt", "w") as f: 166 | f.write(f"{dependencies}\n") 167 | 168 | if procfile is not None: 169 | with open(f"{git_root}/Procfile", "w") as f: 170 | f.write(f"{procfile}\n") 171 | 172 | return {"code": 0} 173 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jupyterlab-heroku", 3 | "version": "0.2.1", 4 | "description": "JupyterLab extension to manage and deploy applications to Heroku", 5 | "keywords": [ 6 | "jupyter", 7 | "jupyterlab", 8 | "jupyterlab-extension" 9 | ], 10 | "homepage": "https://github.com/jtpio/jupyterlab-heroku", 11 | "bugs": { 12 | "url": "https://github.com/jtpio/jupyterlab-heroku/issues" 13 | }, 14 | "license": "BSD-3-Clause", 15 | "author": "Jeremy Tuloup", 16 | "files": [ 17 | "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}", 18 | "style/**/*.{css,eot,gif,html,jpg,json,png,svg,woff2,ttf}" 19 | ], 20 | "main": "lib/index.js", 21 | "types": "lib/index.d.ts", 22 | "style": "style/index.css", 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/jtpio/jupyterlab-heroku.git" 26 | }, 27 | "scripts": { 28 | "build": "tsc", 29 | "prettier": "prettier --write '**/*{.ts,.tsx,.js,.jsx,.css,.json,.md}'", 30 | "clean": "rimraf lib && rimraf tsconfig.tsbuildinfo", 31 | "prepare": "npm run clean && npm run build", 32 | "watch": "tsc -w" 33 | }, 34 | "dependencies": { 35 | "@blueprintjs/core": "^3.15.1", 36 | "@jupyterlab/application": "^1.0.0", 37 | "@jupyterlab/coreutils": "^3.0.0", 38 | "@jupyterlab/filebrowser": "^1.0.0", 39 | "@jupyterlab/ui-components": "^1.0.0", 40 | "@jupyterlab/terminal": "^1.0.0", 41 | "react": "~16.8.4", 42 | "react-dom": "~16.8.4" 43 | }, 44 | "devDependencies": { 45 | "@types/react": "~16.8.13", 46 | "@types/react-dom": "~16.0.5", 47 | "husky": "^3.0.0", 48 | "lint-staged": "^8.1.5", 49 | "prettier": "^1.17.0", 50 | "rimraf": "^2.6.1", 51 | "tslint-config-prettier": "^1.18.0", 52 | "tslint-plugin-prettier": "^2.0.1", 53 | "typescript": "~3.5.2" 54 | }, 55 | "lint-staged": { 56 | "**/*{.ts,.tsx,.css,.json,.md}": [ 57 | "prettier --write", 58 | "git add" 59 | ] 60 | }, 61 | "husky": { 62 | "hooks": { 63 | "pre-commit": "lint-staged" 64 | } 65 | }, 66 | "sideEffects": [ 67 | "style/*.css" 68 | ], 69 | "jupyterlab": { 70 | "extension": true 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import setuptools 3 | 4 | with open("README.md", "r") as fh: 5 | long_description = fh.read() 6 | 7 | here = os.path.dirname(os.path.abspath(__file__)) 8 | version_ns = {} 9 | with open(os.path.join(here, "jupyterlab_heroku", "_version.py")) as f: 10 | exec(f.read(), {}, version_ns) 11 | 12 | setuptools.setup( 13 | name="jupyterlab_heroku", 14 | version=version_ns["__version__"], 15 | author="Jeremy Tuloup", 16 | description="A server extension for the JupyterLab Heroku extension", 17 | long_description=long_description, 18 | long_description_content_type="text/markdown", 19 | packages=setuptools.find_packages(), 20 | install_requires=["jupyterlab"], 21 | package_data={"jupyterlab_heroku": ["*"]}, 22 | license="BSD 3-clause", 23 | classifiers=[ 24 | "License :: OSI Approved :: BSD License", 25 | "Natural Language :: English", 26 | ], 27 | url="https://github.com/jtpio/jupyterlab-heroku", 28 | ) 29 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import { ServerConnection } from "@jupyterlab/services"; 2 | 3 | import { URLExt } from "@jupyterlab/coreutils"; 4 | 5 | import { ReadonlyJSONObject } from "@phosphor/coreutils"; 6 | 7 | import { IHeroku } from "./tokens"; 8 | 9 | const STATUS_ENDPOINT = "/heroku/status"; 10 | const LOGS_ENDPOINT = "/heroku/logs"; 11 | const CREATE_ENDPOINT = "/heroku/create"; 12 | const DESTROY_ENDPOINT = "/heroku/destroy"; 13 | const APPS_ENDPOINT = "/heroku/apps"; 14 | const DEPLOY_ENDPOINT = "/heroku/deploy"; 15 | const GET_SETTINGS_ENDPOINT = "/heroku/settings"; 16 | const POST_SETTINGS_ENDPOINT = "/heroku/settings/update"; 17 | 18 | function httpRequest( 19 | url: string, 20 | method: string, 21 | request?: Object 22 | ): Promise { 23 | let fullRequest: RequestInit = { 24 | method: method, 25 | body: JSON.stringify(request) 26 | }; 27 | 28 | let setting = ServerConnection.makeSettings(); 29 | let fullUrl = URLExt.join(setting.baseUrl, url); 30 | return ServerConnection.makeRequest(fullUrl, fullRequest, setting); 31 | } 32 | 33 | export class Client implements IHeroku.IClient { 34 | constructor(options: Client.IOptions) {} 35 | 36 | private async herokuAction( 37 | endpoint: string, 38 | method: string = "POST", 39 | data: Object = {} 40 | ): Promise { 41 | const path = this._path; 42 | const request = httpRequest(endpoint, method, { 43 | current_path: path, 44 | ...data 45 | }); 46 | const response = await request; 47 | if (!response.ok) { 48 | const data = await response.json(); 49 | throw new ServerConnection.ResponseError(response, data.message); 50 | } 51 | return response.json(); 52 | } 53 | 54 | async logs(): Promise { 55 | return this.herokuAction(LOGS_ENDPOINT); 56 | } 57 | 58 | async status(): Promise { 59 | return this.herokuAction(STATUS_ENDPOINT); 60 | } 61 | 62 | async create(): Promise { 63 | return this.herokuAction(CREATE_ENDPOINT); 64 | } 65 | 66 | async destroy( 67 | request: IHeroku.IDestroyRequest 68 | ): Promise { 69 | const { app } = request; 70 | return this.herokuAction(DESTROY_ENDPOINT, "POST", { app }); 71 | } 72 | 73 | async apps(): Promise { 74 | const response = await this.herokuAction( 75 | APPS_ENDPOINT 76 | ); 77 | return response; 78 | } 79 | 80 | async deploy(): Promise { 81 | const response = await this.herokuAction( 82 | DEPLOY_ENDPOINT 83 | ); 84 | return response; 85 | } 86 | 87 | async updateSettings(request: IHeroku.ISettingsRequest): Promise { 88 | const { settings } = request; 89 | const response = await this.herokuAction( 90 | POST_SETTINGS_ENDPOINT, 91 | "POST", 92 | { ...settings } 93 | ); 94 | if (response.message) { 95 | console.error(response.message); 96 | } 97 | } 98 | 99 | async settings(): Promise { 100 | const response = await this.herokuAction( 101 | GET_SETTINGS_ENDPOINT 102 | ); 103 | return response; 104 | } 105 | 106 | get path() { 107 | return this._path; 108 | } 109 | 110 | set path(path: string) { 111 | this._path = path; 112 | } 113 | 114 | private _path: string | null = null; 115 | } 116 | 117 | export namespace Client { 118 | export interface IOptions {} 119 | } 120 | -------------------------------------------------------------------------------- /src/components/apps.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ToolbarButtonComponent, 3 | ReactWidget, 4 | Dialog, 5 | showDialog 6 | } from "@jupyterlab/apputils"; 7 | 8 | import React, { useState, useEffect } from "react"; 9 | 10 | import { IHeroku } from "../tokens"; 11 | 12 | const HEROKU_HEADER_CLASS = "jp-Heroku-header"; 13 | const HEROKU_APP_LIST_CLASS = "jp-HerokuApp-sectionList"; 14 | const HEROKU_APP_ITEM_CLASS = "jp-HerokuApp-item"; 15 | const HEROKU_APP_ITEM_ICON_CLASS = "jp-HerokuApp-itemIcon"; 16 | const HEROKU_APP_ITEM_SUCCESS_CLASS = "jp-HerokuApp-itemSuccess"; 17 | const HEROKU_APP_ITEM_ERROR_CLASS = "jp-HerokuApp-itemError"; 18 | const HEROKU_APP_ITEM_LABEL_CLASS = "jp-HerokuApp-itemLabel"; 19 | const HEROKU_APP_DEPLOY_ICON_CLASS = "jp-FileUploadIcon"; 20 | const HEROKU_APP_CREATE_ICON_CLASS = "jp-AddIcon"; 21 | const HEROKU_APP_REFRESH_ICON_CLASS = "jp-RefreshIcon"; 22 | 23 | const App = ({ model, app }: { model: IHeroku.IModel; app: IHeroku.IApp }) => { 24 | const [deploying, setDeploying] = useState(false); 25 | const [error, setError] = useState(false); 26 | 27 | const logs = async () => { 28 | await model.logs(app.name); 29 | }; 30 | 31 | const deploy = async () => { 32 | setError(false); 33 | setDeploying(true); 34 | try { 35 | await model.deploy(); 36 | } catch (err) { 37 | setError(true); 38 | } 39 | setDeploying(false); 40 | }; 41 | 42 | const destroy = async () => { 43 | let destroyButton = Dialog.warnButton({ label: "Destroy" }); 44 | const result = await showDialog({ 45 | title: "Destroy App?", 46 | body: `Do you really want to destroy the app ${app.name}? This cannot be undone`, 47 | buttons: [Dialog.cancelButton(), destroyButton] 48 | }); 49 | if (result.button.accept) { 50 | await model.destroy(app.name); 51 | model.refreshApps(); 52 | } 53 | }; 54 | 55 | return ( 56 |
  • 57 | 62 | {deploying ? ( 63 | 67 | ) : ( 68 | [ 69 | error && ( 70 | 75 | ), 76 | !error && ( 77 | 82 | ) 83 | ] 84 | )} 85 | 86 | 87 | {app.name} 88 | 89 | 94 | 99 | { 103 | window.open(app.web_url); 104 | }} 105 | /> 106 | 112 |
  • 113 | ); 114 | }; 115 | 116 | const AppList = ({ 117 | model, 118 | apps 119 | }: { 120 | model: IHeroku.IModel; 121 | apps: IHeroku.IApp[]; 122 | }) => { 123 | return ( 124 |
      125 | {apps.map((props, i) => ( 126 | 127 | ))} 128 |
    129 | ); 130 | }; 131 | 132 | const AppsComponent = ({ model }: { model: IHeroku.IModel }) => { 133 | const [creating, setCreating] = useState(false); 134 | const [apps, setApps] = useState([]); 135 | 136 | const showErrorDialog = () => { 137 | let title = Not a Git Repository; 138 | let body = ( 139 | <> 140 |

    141 | The current folder does not appear to be a Git repository. Heroku uses 142 | Git to manage and create applications. 143 |

    144 |

    145 | From the command line with `git init` or using the Git Extension for 146 | JupyterLab. 147 |

    148 | 149 | ); 150 | return showDialog({ 151 | title, 152 | body, 153 | buttons: [ 154 | Dialog.createButton({ 155 | label: "Dismiss", 156 | className: "jp-About-button jp-mod-reject jp-mod-styled" 157 | }) 158 | ] 159 | }); 160 | }; 161 | 162 | const create = async () => { 163 | setCreating(true); 164 | try { 165 | await model.createApp(); 166 | } catch (err) { 167 | await showErrorDialog(); 168 | } 169 | setCreating(false); 170 | }; 171 | 172 | const refresh = async () => { 173 | setApps([]); 174 | await model.refreshApps(); 175 | setApps(model.apps); 176 | }; 177 | 178 | useEffect(() => { 179 | model.pathChanged.connect(refresh); 180 | model.appsUpdated.connect(refresh); 181 | 182 | return () => { 183 | model.appsUpdated.disconnect(refresh); 184 | model.pathChanged.disconnect(refresh); 185 | }; 186 | }, []); 187 | 188 | return ( 189 | <> 190 |
    191 |

    Heroku apps

    192 | 198 | 203 |
    204 | 205 | 206 | ); 207 | }; 208 | 209 | export class Apps extends ReactWidget { 210 | constructor(options: Apps.IOptions) { 211 | super(); 212 | this._model = options.model; 213 | } 214 | 215 | render() { 216 | return ; 217 | } 218 | 219 | private _model: IHeroku.IModel; 220 | } 221 | 222 | export namespace Apps { 223 | export interface IOptions { 224 | model: IHeroku.IModel; 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/components/settings.tsx: -------------------------------------------------------------------------------- 1 | import { ToolbarButtonComponent, ReactWidget } from "@jupyterlab/apputils"; 2 | 3 | import { Debouncer } from "@jupyterlab/coreutils"; 4 | 5 | import { HTMLSelect, InputGroup } from "@jupyterlab/ui-components"; 6 | 7 | import { TextArea } from "@blueprintjs/core/lib/cjs/components/forms/textArea"; 8 | 9 | import React, { useState, useEffect } from "react"; 10 | 11 | import { IHeroku } from "../tokens"; 12 | 13 | const HEROKU_HEADER_CLASS = "jp-Heroku-header"; 14 | const HEROKU_SETTINGS_REFRESH_ICON_CLASS = "jp-RefreshIcon"; 15 | const HEROKU_SETTINGS_TITLE_CLASS = "jp-HerokuSettings-title"; 16 | const HEROKU_SETTINGS_LIST_CLASS = "jp-HerokuSettings-sectionList"; 17 | const HEROKU_SETTINGS_SELECT_CLASS = "jp-HerokuSettings-dropdown"; 18 | const HEROKU_SETTINGS_INPUT_CLASS = "jp-HerokuSettings-input"; 19 | const HEROKU_SETTINGS_TEXTAREA_CLASS = "jp-HerokuSettings-textarea"; 20 | 21 | const RUNTIMES = ["python-3.7.3", "python-3.6.8"]; 22 | const DEFAULT_PROCFILE = "web: voila --port=$PORT --no-browser"; 23 | 24 | // debounce settings updates 25 | const SETTINGS_UPDATE_LIMIT = 2000; 26 | 27 | const Procfile = (props: { model: IHeroku.IModel; procfile?: string }) => { 28 | const [procfile, setProcfile] = useState(DEFAULT_PROCFILE); 29 | 30 | const { model } = props; 31 | 32 | const debouncer = new Debouncer(() => { 33 | model.procfile = procfile; 34 | }, SETTINGS_UPDATE_LIMIT); 35 | 36 | useEffect(() => { 37 | setProcfile(props.procfile || DEFAULT_PROCFILE); 38 | debouncer.invoke(); 39 | 40 | return () => { 41 | debouncer.dispose(); 42 | }; 43 | }, [props]); 44 | 45 | useEffect(() => { 46 | debouncer.invoke(); 47 | }, [procfile]); 48 | 49 | const handleChange = (event: React.ChangeEvent) => { 50 | const value = event.currentTarget.value; 51 | setProcfile(value); 52 | }; 53 | 54 | return ( 55 | <> 56 |
    57 |

    Procfile

    58 |
    59 | 66 | 67 | ); 68 | }; 69 | 70 | const Runtime = (props: { model: IHeroku.IModel; runtime: string }) => { 71 | const [runtime, setRuntime] = useState(RUNTIMES[0]); 72 | 73 | const { model } = props; 74 | 75 | useEffect(() => { 76 | setRuntime(props.runtime || RUNTIMES[0]); 77 | }, [props]); 78 | 79 | useEffect(() => { 80 | model.runtime = runtime; 81 | }, [runtime]); 82 | 83 | const handleChange = (event: React.ChangeEvent) => { 84 | const runtime = event.currentTarget.value; 85 | setRuntime(runtime); 86 | }; 87 | 88 | return ( 89 | <> 90 |
    91 |

    Runtime

    92 |
    93 | 100 | ) 101 | }} 102 | aria-label="Runtime" 103 | > 104 | {RUNTIMES.map((runtime, i) => ( 105 | 108 | ))} 109 | 110 | 111 | ); 112 | }; 113 | 114 | const Dependencies = (props: { 115 | model: IHeroku.IModel; 116 | dependencies: string; 117 | }) => { 118 | const [dependencies, setDependencies] = useState(""); 119 | 120 | const { model } = props; 121 | 122 | const debouncer = new Debouncer(() => { 123 | model.dependencies = dependencies; 124 | }, SETTINGS_UPDATE_LIMIT); 125 | 126 | useEffect(() => { 127 | setDependencies(props.dependencies || ""); 128 | 129 | return () => { 130 | debouncer.dispose(); 131 | }; 132 | }, [props]); 133 | 134 | useEffect(() => { 135 | debouncer.invoke(); 136 | }, [dependencies]); 137 | 138 | const handleChange = (event: React.ChangeEvent) => { 139 | const dependencies = event.currentTarget.value; 140 | setDependencies(dependencies); 141 | }; 142 | 143 | return ( 144 | <> 145 |
    146 |

    Dependencies

    147 |
    148 |