From 7c6101a05827ed9855eeaf9ad81cf5c8e64e944a Mon Sep 17 00:00:00 2001 From: Igor Babuschkin Date: Tue, 18 Aug 2015 00:53:46 +0200 Subject: [PATCH] Add initial code --- bower.json | 11 ++ everware/__init__.py | 5 + everware/authenticator.py | 299 ++++++++++++++++++++++++++++++++++ everware/homehandler.py | 37 +++++ everware/spawner.py | 110 +++++++++++++ package.json | 16 ++ setup.py | 217 ++++++++++++++++++++++++ share/static/html/error.html | 23 +++ share/static/html/home.html | 48 ++++++ share/static/html/login.html | 96 +++++++++++ share/static/html/page.html | 120 ++++++++++++++ share/static/html/spawn_pending.html | 28 ++++ share/static/images/jupyter.png | Bin 0 -> 4473 bytes share/static/images/jupyterhub-80.png | Bin 0 -> 16966 bytes share/static/js/admin.js | 197 ++++++++++++++++++++++ share/static/js/home.js | 19 +++ share/static/js/jhapi.js | 133 +++++++++++++++ share/static/js/utils.js | 137 ++++++++++++++++ share/static/less/admin.less | 3 + share/static/less/error.less | 21 +++ share/static/less/login.less | 77 +++++++++ share/static/less/page.less | 26 +++ share/static/less/style.less | 27 +++ share/static/less/variables.less | 11 ++ 24 files changed, 1661 insertions(+) create mode 100644 bower.json create mode 100644 everware/__init__.py create mode 100644 everware/authenticator.py create mode 100644 everware/homehandler.py create mode 100644 everware/spawner.py create mode 100644 package.json create mode 100644 setup.py create mode 100644 share/static/html/error.html create mode 100644 share/static/html/home.html create mode 100644 share/static/html/login.html create mode 100644 share/static/html/page.html create mode 100644 share/static/html/spawn_pending.html create mode 100644 share/static/images/jupyter.png create mode 100644 share/static/images/jupyterhub-80.png create mode 100644 share/static/js/admin.js create mode 100644 share/static/js/home.js create mode 100644 share/static/js/jhapi.js create mode 100644 share/static/js/utils.js create mode 100644 share/static/less/admin.less create mode 100644 share/static/less/error.less create mode 100644 share/static/less/login.less create mode 100644 share/static/less/page.less create mode 100644 share/static/less/style.less create mode 100644 share/static/less/variables.less diff --git a/bower.json b/bower.json new file mode 100644 index 0000000..ce78039 --- /dev/null +++ b/bower.json @@ -0,0 +1,11 @@ +{ + "name": "jupyterhub-deps", + "version": "0.0.0", + "dependencies": { + "bootstrap": "components/bootstrap#~3.1", + "font-awesome": "components/font-awesome#~4.1", + "jquery": "components/jquery#~2.0", + "moment": "~2.7", + "requirejs": "~2.1" + } +} diff --git a/everware/__init__.py b/everware/__init__.py new file mode 100644 index 0000000..49f7ba1 --- /dev/null +++ b/everware/__init__.py @@ -0,0 +1,5 @@ + +from .spawner import * +from .authenticator import * +from .homehandler import * + diff --git a/everware/authenticator.py b/everware/authenticator.py new file mode 100644 index 0000000..72e505f --- /dev/null +++ b/everware/authenticator.py @@ -0,0 +1,299 @@ +""" +Custom Authenticator to use GitHub OAuth with JupyterHub + +Most of the code c/o Kyle Kelley (@rgbkrk) +""" + + +import json +import os +import urllib + +from tornado.auth import OAuth2Mixin +from tornado.escape import url_escape +from tornado import gen, web + +from tornado.httputil import url_concat +from tornado.httpclient import HTTPRequest, AsyncHTTPClient + +from jupyterhub.handlers import BaseHandler +from jupyterhub.auth import Authenticator, LocalAuthenticator +from jupyterhub.utils import url_path_join + +from traitlets import Unicode, Set + + +class GitHubMixin(OAuth2Mixin): + _OAUTH_AUTHORIZE_URL = "https://github.com/login/oauth/authorize" + _OAUTH_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token" + + +class BitbucketMixin(OAuth2Mixin): + _OAUTH_AUTHORIZE_URL = "https://bitbucket.org/site/oauth2/authorize" + _OAUTH_ACCESS_TOKEN_URL = "https://bitbucket.org/site/oauth2/access_token" + + +class WelcomeHandler(BaseHandler): + """Render the login page.""" + + def _render(self, login_error=None, username=None): + return self.render_template('login.html', + next=url_escape(self.get_argument('next', default='')), + username=username, + login_error=login_error, + ) + + def get(self): + next_url = self.get_argument('next', '') + if not next_url.startswith('/'): + # disallow non-absolute next URLs (e.g. full URLs) + next_url = '' + user = self.get_current_user() + if user: + if not next_url: + if user.running: + next_url = user.server.base_url + else: + next_url = self.hub.server.base_url + # set new login cookie + # because single-user cookie may have been cleared or incorrect + #self.set_login_cookie(self.get_current_user()) + self.redirect('/oauth_login', permanent=False) + else: + self.finish(self._render()) + +class OAuthLoginHandler(BaseHandler): + + def get(self): + guess_uri = '{proto}://{host}{path}'.format( + proto=self.request.protocol, + host=self.request.host, + path=url_path_join( + self.hub.server.base_url, + 'oauth_callback' + ) + ) + + redirect_uri = self.authenticator.oauth_callback_url or guess_uri + self.log.info('oauth redirect: %r', redirect_uri) + + self.authorize_redirect( + redirect_uri=redirect_uri, + client_id=self.authenticator.client_id, + scope=[], + response_type='code') + + +class GitHubLoginHandler(OAuthLoginHandler, GitHubMixin): + pass + + +class BitbucketLoginHandler(OAuthLoginHandler, BitbucketMixin): + pass + + +class GitHubOAuthHandler(BaseHandler): + @gen.coroutine + def get(self): + # TODO: Check if state argument needs to be checked + username = yield self.authenticator.authenticate(self) + if username: + user = self.user_from_username(username) + self.set_login_cookie(user) + self.redirect(self.hub.server.base_url) + else: + # todo: custom error page? + raise web.HTTPError(403) + + +class BitbucketOAuthHandler(GitHubOAuthHandler): + pass + + +class GitHubOAuthenticator(Authenticator): + + login_service = "GitHub" + oauth_callback_url = Unicode('', config=True) + client_id = Unicode(os.environ.get('GITHUB_CLIENT_ID', ''), + config=True) + client_secret = Unicode(os.environ.get('GITHUB_CLIENT_SECRET', ''), + config=True) + + def login_url(self, base_url): + return url_path_join(base_url, 'login') + + def get_handlers(self, app): + return [ + (r'/login', WelcomeHandler), + (r'/oauth_login', GitHubLoginHandler), + (r'/oauth_callback', GitHubOAuthHandler), + ] + + @gen.coroutine + def authenticate(self, handler): + code = handler.get_argument("code", False) + if not code: + raise web.HTTPError(400, "oauth callback made without a token") + # TODO: Configure the curl_httpclient for tornado + http_client = AsyncHTTPClient() + + # Exchange the OAuth code for a GitHub Access Token + # + # See: https://developer.github.com/v3/oauth/ + + # GitHub specifies a POST request yet requires URL parameters + params = dict( + client_id=self.client_id, + client_secret=self.client_secret, + code=code + ) + + url = url_concat("https://github.com/login/oauth/access_token", + params) + + req = HTTPRequest(url, + method="POST", + headers={"Accept": "application/json"}, + body='' # Body is required for a POST... + ) + + resp = yield http_client.fetch(req) + resp_json = json.loads(resp.body.decode('utf8', 'replace')) + + access_token = resp_json['access_token'] + + # Determine who the logged in user is + headers={"Accept": "application/json", + "User-Agent": "JupyterHub", + "Authorization": "token {}".format(access_token) + } + req = HTTPRequest("https://api.github.com/user", + method="GET", + headers=headers + ) + resp = yield http_client.fetch(req) + resp_json = json.loads(resp.body.decode('utf8', 'replace')) + + username = resp_json["login"] + if self.whitelist and username not in self.whitelist: + username = None + raise gen.Return(username) + + +class BitbucketOAuthenticator(Authenticator): + + login_service = "Bitbucket" + oauth_callback_url = Unicode(os.environ.get('OAUTH_CALLBACK_URL', ''), + config=True) + client_id = Unicode(os.environ.get('BITBUCKET_CLIENT_ID', ''), + config=True) + client_secret = Unicode(os.environ.get('BITBUCKET_CLIENT_SECRET', ''), + config=True) + team_whitelist = Set( + config=True, + help="Automatically whitelist members of selected teams", + ) + + def login_url(self, base_url): + return url_path_join(base_url, 'oauth_login') + + def get_handlers(self, app): + return [ + (r'/oauth_login', BitbucketLoginHandler), + (r'/oauth_callback', BitbucketOAuthHandler), + ] + + @gen.coroutine + def authenticate(self, handler): + code = handler.get_argument("code", False) + if not code: + raise web.HTTPError(400, "oauth callback made without a token") + # TODO: Configure the curl_httpclient for tornado + http_client = AsyncHTTPClient() + + params = dict( + client_id=self.client_id, + client_secret=self.client_secret, + grant_type="authorization_code", + code=code, + redirect_uri=self.oauth_callback_url + ) + + url = url_concat( + "https://bitbucket.org/site/oauth2/access_token", params) + self.log.info(url) + + bb_header = {"Content-Type": + "application/x-www-form-urlencoded;charset=utf-8"} + req = HTTPRequest(url, + method="POST", + auth_username=self.client_id, + auth_password=self.client_secret, + body=urllib.parse.urlencode(params).encode('utf-8'), + headers=bb_header + ) + + resp = yield http_client.fetch(req) + resp_json = json.loads(resp.body.decode('utf8', 'replace')) + + access_token = resp_json['access_token'] + + # Determine who the logged in user is + headers = {"Accept": "application/json", + "User-Agent": "JupyterHub", + "Authorization": "Bearer {}".format(access_token) + } + req = HTTPRequest("https://api.bitbucket.org/2.0/user", + method="GET", + headers=headers + ) + resp = yield http_client.fetch(req) + resp_json = json.loads(resp.body.decode('utf8', 'replace')) + + username = resp_json["username"] + whitelisted = yield self.check_whitelist(username, headers) + if not whitelisted: + username = None + return username + + def check_whitelist(self, username, headers): + if self.team_whitelist: + return self._check_group_whitelist(username, headers) + else: + return self._check_user_whitelist(username) + + @gen.coroutine + def _check_user_whitelist(self, user): + return (not self.whitelist) or (user in self.whitelist) + + @gen.coroutine + def _check_group_whitelist(self, username, headers): + http_client = AsyncHTTPClient() + + # We verify the team membership by calling teams endpoint. + # Re-use the headers, change the request. + next_page = url_concat("https://api.bitbucket.org/2.0/teams", + {'role': 'member'}) + user_teams = set() + while next_page: + req = HTTPRequest(next_page, method="GET", headers=headers) + resp = yield http_client.fetch(req) + resp_json = json.loads(resp.body.decode('utf8', 'replace')) + next_page = resp_json.get('next', None) + + user_teams |= \ + set([entry["username"] for entry in resp_json["values"]]) + return len(self.team_whitelist & user_teams) > 0 + + +class LocalGitHubOAuthenticator(LocalAuthenticator, GitHubOAuthenticator): + + """A version that mixes in local system user creation""" + pass + + +class LocalBitbucketOAuthenticator(LocalAuthenticator, + BitbucketOAuthenticator): + + """A version that mixes in local system user creation""" + pass diff --git a/everware/homehandler.py b/everware/homehandler.py new file mode 100644 index 0000000..c0f9672 --- /dev/null +++ b/everware/homehandler.py @@ -0,0 +1,37 @@ + +from tornado import gen +from jupyterhub.handlers.pages import web, BaseHandler + +class HomeHandler(BaseHandler): + """Render the user's home page.""" + + @web.authenticated + def get(self): + html = self.render_template('home.html', + user=self.get_current_user(), + ) + self.finish(html) + + @gen.coroutine + def post(self): + data = {} + for arg in self.request.arguments: + data[arg] = self.get_argument(arg) + + repo_url = data['repourl'] + user = self.get_current_user() + user.last_repo_url = repo_url + + already_running = False + if user.spawner: + status = yield user.spawner.poll() + already_running = (status == None) + if not already_running: + yield self.spawn_single_user(user) + + user.last_repo_url = repo_url + self.db.commit() + + self.redirect(self.hub.server.base_url) + + diff --git a/everware/spawner.py b/everware/spawner.py new file mode 100644 index 0000000..c9a48b1 --- /dev/null +++ b/everware/spawner.py @@ -0,0 +1,110 @@ +import pwd +from tempfile import mkdtemp +from datetime import timedelta + +from dockerspawner import DockerSpawner +from textwrap import dedent +from traitlets import ( + Integer, + Unicode, +) +from tornado import gen + +from escapism import escape + +import git + + +class CustomDockerSpawner(DockerSpawner): + def __init__(self, **kwargs): + self.user = kwargs['user'] + self.repo_url = self.user.last_repo_url + self.repo_sha = kwargs.get('last_commit', '') + super(CustomDockerSpawner, self).__init__(**kwargs) + + _git_executor = None + @property + def git_executor(self): + """single global git executor""" + cls = self.__class__ + if cls._git_executor is None: + cls._git_executor = ThreadPoolExecutor(1) + return cls._git_executor + + _git_client = None + @property + def git_client(self): + """single global git client instance""" + cls = self.__class__ + if cls._git_client is None: + cls._git_client = git.Git() + return cls._git_client + + def _git(self, method, *args, **kwargs): + """wrapper for calling git methods + + to be passed to ThreadPoolExecutor + """ + m = getattr(self.git_client, method) + return m(*args, **kwargs) + + def git(self, method, *args, **kwargs): + """Call a git method in a background thread + + returns a Future + """ + return self.executor.submit(self._git, method, *args, **kwargs) + + _escaped_repo_url = None + + @property + def escaped_repo_url(self): + if self._escaped_repo_url is None: + trans = str.maketrans(':/-.', "____") + self._escaped_repo_url = self.repo_url.translate(trans) + return self._escaped_repo_url + + @property + def container_name(self): + return "{}-{}-{}-{}".format(self.container_prefix, + self.escaped_name, + self.escaped_repo_url, + self.repo_sha) + + @gen.coroutine + def start(self, image=None): + """start the single-user server in a docker container""" + tmp_dir = mkdtemp(suffix='-everware') + yield self.git('clone', self.repo_url, tmp_dir) + # is this blocking? + # use the username, git repo URL and HEAD commit sha to derive + # the image name + repo = git.Repo(tmp_dir) + self.repo_sha = repo.rev_parse("HEAD") + + image_name = "everware/{}-{}-{}".format(self.user.name, + self.escaped_repo_url, + self.repo_sha) + + self.log.debug("Building image {}".format(image_name)) + build_log = yield gen.with_timeout(timedelta(30), + self.docker('build', + path=tmp_dir, + tag='bla', #image_name, + rm=True)) + self.log.debug("".join(str(line) for line in build_log)) + self.log.info("Built docker image {}".format(image_name)) + + images = yield self.docker('images', image_name) + self.log.debug(images) + + yield super(CustomDockerSpawner, self).start( + image='bla' #image_name + ) + + def _env_default(self): + env = super(CustomDockerSpawner, self)._env_default() + + env.update({'JPY_GITHUBURL': self.repo_url}) + + return env diff --git a/package.json b/package.json new file mode 100644 index 0000000..a6d7702 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "jupyterhub-deps", + "version": "0.0.0", + "description": "JupyterHub nodejs dependencies", + "author": "Jupyter Developers", + "license": "BSD", + "repository": { + "type": "git", + "url": "https://github.com/jupyter/jupyterhub.git" + }, + "devDependencies": { + "bower": "*", + "less": "~2", + "less-plugin-clean-css": "*" + } +} diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..4064dfe --- /dev/null +++ b/setup.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python +# coding: utf-8 + +from __future__ import print_function + +import os +import sys +import shutil +from subprocess import check_call + +v = sys.version_info +if v[:2] < (3,3): + error = "ERROR: Jupyter Hub requires Python version 3.3 or above." + print(error, file=sys.stderr) + sys.exit(1) + +def mtime(path): + """shorthand for mtime""" + return os.stat(path).st_mtime + + +if os.name in ('nt', 'dos'): + error = "ERROR: Windows is not supported" + print(error, file=sys.stderr) + +# At least we're on the python version we need, move on. + +from distutils.core import setup + +pjoin = os.path.join +here = os.path.abspath(os.path.dirname(__file__)) + +from distutils.cmd import Command +from distutils.command.build_py import build_py +from distutils.command.sdist import sdist + +npm_path = ':'.join([ + pjoin(here, 'node_modules', '.bin'), + os.environ.get("PATH", os.defpath), +]) + +here = os.path.abspath(os.path.dirname(__file__)) +share = pjoin(here, 'share') +static = pjoin(share, 'static') + +class BaseCommand(Command): + """Dumb empty command because Command needs subclasses to override too much""" + user_options = [] + + def initialize_options(self): + pass + + def finalize_options(self): + pass + + def get_inputs(self): + return [] + + def get_outputs(self): + return [] + +class Bower(BaseCommand): + description = "fetch static client-side components with bower" + + user_options = [] + bower_dir = pjoin(static, 'components') + node_modules = pjoin(here, 'node_modules') + + def should_run(self): + if not os.path.exists(self.bower_dir): + return True + return mtime(self.bower_dir) < mtime(pjoin(here, 'bower.json')) + + def should_run_npm(self): + if not shutil.which('npm'): + print("npm unavailable", file=sys.stderr) + return False + if not os.path.exists(self.node_modules): + return True + return mtime(self.node_modules) < mtime(pjoin(here, 'package.json')) + + def run(self): + if not self.should_run(): + print("bower dependencies up to date") + return + + if self.should_run_npm(): + print("installing build dependencies with npm") + check_call(['npm', 'install'], cwd=here) + os.utime(self.node_modules) + + env = os.environ.copy() + env['PATH'] = npm_path + + try: + check_call( + ['bower', 'install', '--allow-root', '--config.interactive=false'], + cwd=here, + env=env, + ) + except OSError as e: + print("Failed to run bower: %s" % e, file=sys.stderr) + print("You can install js dependencies with `npm install`", file=sys.stderr) + raise + os.utime(self.bower_dir) + # update data-files in case this created new files + self.distribution.data_files = get_data_files() + + +class CSS(BaseCommand): + description = "compile CSS from LESS" + + def should_run(self): + """Does less need to run?""" + # from IPython.html.tasks.py + + css_targets = [pjoin(static, 'css', 'style.min.css')] + css_maps = [t + '.map' for t in css_targets] + targets = css_targets + css_maps + if not all(os.path.exists(t) for t in targets): + # some generated files don't exist + return True + earliest_target = sorted(mtime(t) for t in targets)[0] + + # check if any .less files are newer than the generated targets + for (dirpath, dirnames, filenames) in os.walk(static): + for f in filenames: + if f.endswith('.less'): + path = pjoin(static, dirpath, f) + timestamp = mtime(path) + if timestamp > earliest_target: + return True + + return False + + def run(self): + if not self.should_run(): + print("CSS up-to-date") + return + + self.run_command('js') + + style_less = pjoin(static, 'less', 'style.less') + style_css = pjoin(static, 'css', 'style.min.css') + sourcemap = style_css + '.map' + + env = os.environ.copy() + env['PATH'] = npm_path + try: + check_call([ + 'lessc', '--clean-css', + '--source-map-basepath={}'.format(static), + '--source-map={}'.format(sourcemap), + '--source-map-rootpath=../', + style_less, style_css, + ], cwd=here, env=env) + except OSError as e: + print("Failed to run lessc: %s" % e, file=sys.stderr) + print("You can install js dependencies with `npm install`", file=sys.stderr) + raise + # update data-files in case this created new files + self.distribution.data_files = get_data_files() + +def get_data_files(): + """Get data files in share/jupyter""" + + data_files = [] + ntrim = len(here) + 1 + + for (d, dirs, filenames) in os.walk(static): + data_files.append(( + d[ntrim:], + [ pjoin(d, f) for f in filenames ] + )) + return data_files + + +setup_args = dict( + name = 'everware', + packages = ['everware'], + version = '0.0.0', + description = """Everware""", + long_description = "", + author = "", + author_email = "", + url = "", + license = "BSD", + platforms = "Linux, Mac OS X", + keywords = ['Interactive', 'Interpreter', 'Shell', 'Web'], + classifiers = [ + 'Intended Audience :: Developers', + 'Intended Audience :: System Administrators', + 'Intended Audience :: Science/Research', + 'License :: OSI Approved :: BSD License', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + ], +) + +setup_args['cmdclass'] = {'js': Bower, 'css': CSS} + +# setuptools requirements +if 'setuptools' in sys.modules: + setup_args['install_requires'] = install_requires = [] + with open('requirements.txt') as f: + for line in f.readlines(): + req = line.strip() + if not req or req.startswith(('-e', '#')): + continue + install_requires.append(req) + + +def main(): + setup(**setup_args) + +if __name__ == '__main__': + main() diff --git a/share/static/html/error.html b/share/static/html/error.html new file mode 100644 index 0000000..c8fef45 --- /dev/null +++ b/share/static/html/error.html @@ -0,0 +1,23 @@ +{% extends "page.html" %} + +{% block login_widget %} +{% endblock %} + +{% block main %} + +
+ {% block h1_error %} +

+ {{status_code}} : {{status_message}} +

+ {% endblock h1_error %} + {% block error_detail %} + {% if message %} +

+ {{message}} +

+ {% endif %} + {% endblock error_detail %} +
+ +{% endblock %} diff --git a/share/static/html/home.html b/share/static/html/home.html new file mode 100644 index 0000000..5d8d4f1 --- /dev/null +++ b/share/static/html/home.html @@ -0,0 +1,48 @@ +{% extends "page.html" %} + +{% block main %} + +
+
+

everware

+

Run your notebooks in the cloud

+
+ +
+
+
+ +
+ +
+
+ +
+ +
+ +{% endblock %} + +{% block script %} + +{% endblock %} diff --git a/share/static/html/login.html b/share/static/html/login.html new file mode 100644 index 0000000..162531a --- /dev/null +++ b/share/static/html/login.html @@ -0,0 +1,96 @@ +{% extends "page.html" %} + +{% block login_widget %} +{% endblock %} + +{% block main %} +{% block login %} +
+
+

everware

+

Run your notebooks in the cloud

+
+ +
+{% if custom_html %} +{{ custom_html }} +{% elif login_service %} + +{% else %} +
+
+ {% if login_error %} + + {% endif %} +
+ +
+
+ +
+ +
+
+
+ +
+ +
+
+ +
+
+{% endif %} +
+
+{% endblock login %} + + + +{% endblock %} + +{% block script %} +{{super()}} + +{% endblock %} diff --git a/share/static/html/page.html b/share/static/html/page.html new file mode 100644 index 0000000..8a84c4a --- /dev/null +++ b/share/static/html/page.html @@ -0,0 +1,120 @@ +{% macro modal(title, btn_label=None, btn_class="btn-primary") %} +{% set key = title.replace(' ', '-').lower() %} +{% set btn_label = btn_label or title %} + +{% endmacro %} + + + + + + + + + {% block title %}Jupyter Hub{% endblock %} + + + + {% block stylesheet %} + + {% endblock %} + + + + + + {% block meta %} + {% endblock %} + + + + + + + + + +{% block main %} +{% endblock %} + +{% call modal('Error', btn_label='OK') %} +
+ The error +
+{% endcall %} + +{% block script %} +{% endblock %} + + + + diff --git a/share/static/html/spawn_pending.html b/share/static/html/spawn_pending.html new file mode 100644 index 0000000..a662be5 --- /dev/null +++ b/share/static/html/spawn_pending.html @@ -0,0 +1,28 @@ +{% extends "page.html" %} + +{% block main %} + +
+
+
+

Your server is starting up.

+

You will be redirected automatically when it's ready for you.

+ refresh +
+
+
+ +{% endblock %} + +{% block script %} + +{% endblock %} diff --git a/share/static/images/jupyter.png b/share/static/images/jupyter.png new file mode 100644 index 0000000000000000000000000000000000000000..54cc416db0a30265a51eaf5a0328267a5099b512 GIT binary patch literal 4473 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 literal 0 HcmV?d00001 diff --git a/share/static/js/admin.js b/share/static/js/admin.js new file mode 100644 index 0000000..30b22a3 --- /dev/null +++ b/share/static/js/admin.js @@ -0,0 +1,197 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +require(["jquery", "bootstrap", "moment", "jhapi", "utils"], function ($, bs, moment, JHAPI, utils) { + "use strict"; + + var base_url = window.jhdata.base_url; + var prefix = window.jhdata.prefix; + + var api = new JHAPI(base_url); + + function get_row (element) { + while (!element.hasClass("user-row")) { + element = element.parent(); + } + return element; + } + + function resort (col, order) { + var query = window.location.search.slice(1).split('&'); + // if col already present in args, remove it + var i = 0; + while (i < query.length) { + if (query[i] === 'sort=' + col) { + query.splice(i,1); + if (query[i] && query[i].substr(0, 6) === 'order=') { + query.splice(i,1); + } + } else { + i += 1; + } + } + // add new order to the front + if (order) { + query.unshift('order=' + order); + } + query.unshift('sort=' + col); + // reload page with new order + window.location = window.location.pathname + '?' + query.join('&'); + } + + $("th").map(function (i, th) { + th = $(th); + var col = th.data('sort'); + if (!col || col.length === 0) { + return; + } + var order = th.find('i').hasClass('fa-sort-desc') ? 'asc':'desc'; + th.find('a').click( + function () { + resort(col, order); + } + ); + }); + + $(".time-col").map(function (i, el) { + // convert ISO datestamps to nice momentjs ones + el = $(el); + el.text(moment(new Date(el.text())).fromNow()); + }); + + $(".stop-server").click(function () { + var el = $(this); + var row = get_row(el); + var user = row.data('user'); + el.text("stopping..."); + api.stop_server(user, { + success: function () { + el.text('stop server').addClass('hidden'); + row.find('.access-server').addClass('hidden'); + row.find('.start-server').removeClass('hidden'); + } + }); + }); + + $(".access-server").click(function () { + var el = $(this); + var row = get_row(el); + var user = row.data('user'); + var w = window.open(); + api.admin_access(user, { + async: false, + success: function () { + w.location = utils.url_path_join(prefix, 'user', user); + }, + error: function (xhr, err) { + w.close(); + console.error("Failed to gain access to server", err); + } + }); + }); + + $(".start-server").click(function () { + var el = $(this); + var row = get_row(el); + var user = row.data('user'); + el.text("starting..."); + api.start_server(user, { + success: function () { + el.text('start server').addClass('hidden'); + row.find('.stop-server').removeClass('hidden'); + row.find('.access-server').removeClass('hidden'); + } + }); + }); + + $(".edit-user").click(function () { + var el = $(this); + var row = get_row(el); + var user = row.data('user'); + var admin = row.data('admin'); + var dialog = $("#edit-user-dialog"); + dialog.data('user', user); + dialog.find(".username-input").val(user); + dialog.find(".admin-checkbox").attr("checked", admin==='True'); + dialog.modal(); + }); + + $("#edit-user-dialog").find(".save-button").click(function () { + var dialog = $("#edit-user-dialog"); + var user = dialog.data('user'); + var name = dialog.find(".username-input").val(); + var admin = dialog.find(".admin-checkbox").prop("checked"); + api.edit_user(user, { + admin: admin, + name: name + }, { + success: function () { + window.location.reload(); + } + }); + }); + + + $(".delete-user").click(function () { + var el = $(this); + var row = get_row(el); + var user = row.data('user'); + var dialog = $("#delete-user-dialog"); + dialog.find(".delete-username").text(user); + dialog.modal(); + }); + + $("#delete-user-dialog").find(".delete-button").click(function () { + var dialog = $("#delete-user-dialog"); + var username = dialog.find(".delete-username").text(); + console.log("deleting", username); + api.delete_user(username, { + success: function () { + window.location.reload(); + } + }); + }); + + $("#add-user").click(function () { + var dialog = $("#add-user-dialog"); + dialog.find(".username-input").val(''); + dialog.find(".admin-checkbox").prop("checked", false); + dialog.modal(); + }); + + $("#add-user-dialog").find(".save-button").click(function () { + var dialog = $("#add-user-dialog"); + var lines = dialog.find(".username-input").val().split('\n'); + var admin = dialog.find(".admin-checkbox").prop("checked"); + var usernames = []; + lines.map(function (line) { + var username = line.trim(); + if (username.length) { + usernames.push(username); + } + }); + + api.add_users(usernames, {admin: admin}, { + success: function () { + window.location.reload(); + } + }); + }); + + $("#shutdown-hub").click(function () { + var dialog = $("#shutdown-hub-dialog"); + dialog.find("input[type=checkbox]").prop("checked", true); + dialog.modal(); + }); + + $("#shutdown-hub-dialog").find(".shutdown-button").click(function () { + var dialog = $("#shutdown-hub-dialog"); + var servers = dialog.find(".shutdown-servers-checkbox").prop("checked"); + var proxy = dialog.find(".shutdown-proxy-checkbox").prop("checked"); + api.shutdown_hub({ + proxy: proxy, + servers: servers, + }); + }); + +}); diff --git a/share/static/js/home.js b/share/static/js/home.js new file mode 100644 index 0000000..cb9be3e --- /dev/null +++ b/share/static/js/home.js @@ -0,0 +1,19 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +require(["jquery", "jhapi"], function ($, JHAPI) { + "use strict"; + + var base_url = window.jhdata.base_url; + var user = window.jhdata.user; + var api = new JHAPI(base_url); + + $("#stop").click(function () { + api.stop_server(user, { + success: function () { + $("#stop").hide(); + } + }); + }); + +}); diff --git a/share/static/js/jhapi.js b/share/static/js/jhapi.js new file mode 100644 index 0000000..754460c --- /dev/null +++ b/share/static/js/jhapi.js @@ -0,0 +1,133 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +define(['jquery', 'utils'], function ($, utils) { + "use strict"; + + var JHAPI = function (base_url) { + this.base_url = base_url; + }; + + var default_options = { + type: 'GET', + contentType: "application/json", + cache: false, + dataType : "json", + processData: false, + success: null, + error: utils.ajax_error_dialog, + }; + + var update = function (d1, d2) { + $.map(d2, function (i, key) { + d1[key] = d2[key]; + }); + return d1; + }; + + var ajax_defaults = function (options) { + var d = {}; + update(d, default_options); + update(d, options); + return d; + }; + + JHAPI.prototype.api_request = function (path, options) { + options = options || {}; + options = ajax_defaults(options || {}); + var url = utils.url_path_join( + this.base_url, + 'api', + utils.encode_uri_components(path) + ); + $.ajax(url, options); + }; + + JHAPI.prototype.start_server = function (user, options) { + options = options || {}; + options = update(options, {type: 'POST', dataType: null}); + this.api_request( + utils.url_path_join('users', user, 'server'), + options + ); + }; + + JHAPI.prototype.stop_server = function (user, options) { + options = options || {}; + options = update(options, {type: 'DELETE', dataType: null}); + this.api_request( + utils.url_path_join('users', user, 'server'), + options + ); + }; + + JHAPI.prototype.list_users = function (options) { + this.api_request('users', options); + }; + + JHAPI.prototype.get_user = function (user, options) { + this.api_request( + utils.url_path_join('users', user), + options + ); + }; + + JHAPI.prototype.add_users = function (usernames, userinfo, options) { + options = options || {}; + var data = update(userinfo, {usernames: usernames}); + options = update(options, { + type: 'POST', + dataType: null, + data: JSON.stringify(data) + }); + + this.api_request('users', options); + }; + + JHAPI.prototype.edit_user = function (user, userinfo, options) { + options = options || {}; + options = update(options, { + type: 'PATCH', + dataType: null, + data: JSON.stringify(userinfo) + }); + + this.api_request( + utils.url_path_join('users', user), + options + ); + }; + + JHAPI.prototype.admin_access = function (user, options) { + options = options || {}; + options = update(options, { + type: 'POST', + dataType: null, + }); + + this.api_request( + utils.url_path_join('users', user, 'admin-access'), + options + ); + }; + + JHAPI.prototype.delete_user = function (user, options) { + options = options || {}; + options = update(options, {type: 'DELETE', dataType: null}); + this.api_request( + utils.url_path_join('users', user), + options + ); + }; + + JHAPI.prototype.shutdown_hub = function (data, options) { + options = options || {}; + options = update(options, {type: 'POST'}); + if (data) { + options.data = JSON.stringify(data); + } + this.api_request('shutdown', options); + }; + + return JHAPI; +}); \ No newline at end of file diff --git a/share/static/js/utils.js b/share/static/js/utils.js new file mode 100644 index 0000000..b20fb97 --- /dev/null +++ b/share/static/js/utils.js @@ -0,0 +1,137 @@ +// Based on IPython's base.js.utils +// Original Copyright (c) IPython Development Team. +// Distributed under the terms of the Modified BSD License. + +// Modifications Copyright (c) Juptyer Development Team. +// Distributed under the terms of the Modified BSD License. + +define(['jquery'], function($){ + "use strict"; + + var url_path_join = function () { + // join a sequence of url components with '/' + var url = ''; + for (var i = 0; i < arguments.length; i++) { + if (arguments[i] === '') { + continue; + } + if (url.length > 0 && url[url.length-1] != '/') { + url = url + '/' + arguments[i]; + } else { + url = url + arguments[i]; + } + } + url = url.replace(/\/\/+/, '/'); + return url; + }; + + var parse_url = function (url) { + // an `a` element with an href allows attr-access to the parsed segments of a URL + // a = parse_url("http://localhost:8888/path/name#hash") + // a.protocol = "http:" + // a.host = "localhost:8888" + // a.hostname = "localhost" + // a.port = 8888 + // a.pathname = "/path/name" + // a.hash = "#hash" + var a = document.createElement("a"); + a.href = url; + return a; + }; + + var encode_uri_components = function (uri) { + // encode just the components of a multi-segment uri, + // leaving '/' separators + return uri.split('/').map(encodeURIComponent).join('/'); + }; + + var url_join_encode = function () { + // join a sequence of url components with '/', + // encoding each component with encodeURIComponent + return encode_uri_components(url_path_join.apply(null, arguments)); + }; + + + var escape_html = function (text) { + // escape text to HTML + return $("
").text(text).html(); + }; + + var get_body_data = function(key) { + // get a url-encoded item from body.data and decode it + // we should never have any encoded URLs anywhere else in code + // until we are building an actual request + return decodeURIComponent($('body').data(key)); + }; + + + // http://stackoverflow.com/questions/2400935/browser-detection-in-javascript + var browser = (function() { + if (typeof navigator === 'undefined') { + // navigator undefined in node + return 'None'; + } + var N= navigator.appName, ua= navigator.userAgent, tem; + var M= ua.match(/(opera|chrome|safari|firefox|msie)\/?\s*(\.?\d+(\.\d+)*)/i); + if (M && (tem= ua.match(/version\/([\.\d]+)/i)) !== null) M[2]= tem[1]; + M= M? [M[1], M[2]]: [N, navigator.appVersion,'-?']; + return M; + })(); + + // http://stackoverflow.com/questions/11219582/how-to-detect-my-browser-version-and-operating-system-using-javascript + var platform = (function () { + if (typeof navigator === 'undefined') { + // navigator undefined in node + return 'None'; + } + var OSName="None"; + if (navigator.appVersion.indexOf("Win")!=-1) OSName="Windows"; + if (navigator.appVersion.indexOf("Mac")!=-1) OSName="MacOS"; + if (navigator.appVersion.indexOf("X11")!=-1) OSName="UNIX"; + if (navigator.appVersion.indexOf("Linux")!=-1) OSName="Linux"; + return OSName; + })(); + + var ajax_error_msg = function (jqXHR) { + // Return a JSON error message if there is one, + // otherwise the basic HTTP status text. + if (jqXHR.responseJSON && jqXHR.responseJSON.message) { + return jqXHR.responseJSON.message; + } else { + return jqXHR.statusText; + } + }; + + var log_ajax_error = function (jqXHR, status, error) { + // log ajax failures with informative messages + var msg = "API request failed (" + jqXHR.status + "): "; + console.log(jqXHR); + msg += ajax_error_msg(jqXHR); + console.log(msg); + return msg; + }; + + var ajax_error_dialog = function (jqXHR, status, error) { + console.log("ajax dialog", arguments); + var msg = log_ajax_error(jqXHR, status, error); + var dialog = $("#error-dialog"); + dialog.find(".ajax-error").text(msg); + dialog.modal(); + }; + + var utils = { + url_path_join : url_path_join, + url_join_encode : url_join_encode, + encode_uri_components : encode_uri_components, + escape_html : escape_html, + get_body_data : get_body_data, + parse_url : parse_url, + browser : browser, + platform: platform, + ajax_error_msg : ajax_error_msg, + log_ajax_error : log_ajax_error, + ajax_error_dialog : ajax_error_dialog, + }; + + return utils; +}); diff --git a/share/static/less/admin.less b/share/static/less/admin.less new file mode 100644 index 0000000..7283d91 --- /dev/null +++ b/share/static/less/admin.less @@ -0,0 +1,3 @@ +i.sort-icon { + margin-left: 4px; +} \ No newline at end of file diff --git a/share/static/less/error.less b/share/static/less/error.less new file mode 100644 index 0000000..ee9df73 --- /dev/null +++ b/share/static/less/error.less @@ -0,0 +1,21 @@ +div.error { + margin: 2em; + text-align: center; +} + +div.ajax-error { + padding: 1em; + text-align: center; + .alert-danger(); +} + +div.error > h1 { + font-size: 300%; + line-height: normal; +} + +div.error > p { + font-size: 200%; + line-height: normal; +} + diff --git a/share/static/less/login.less b/share/static/less/login.less new file mode 100644 index 0000000..85ae274 --- /dev/null +++ b/share/static/less/login.less @@ -0,0 +1,77 @@ +body { + font-family: "Helvetica Neue",Arial,sans-serif; + font-size: 14px; + color: #333; +} + + +.footer { + position: absolute; + bottom: 0; + width: 100%; + height: 60px; + background-color: #f5f5f5; + color: #777; +} + +.footer-inside { + margin: 20px 20px 20px 0; +} + +.footer .container { + margin: 20px 0; +} + +#login-main { + .service-login { + text-align: center; + display: block; + margin: 40px 0; + } + + + .masthead { + text-align: center; + margin: 0; + border-bottom: 1px solid #e5e5e5; + } + + .masthead h1 { + font-family: inherit; + font-size: 81px; + font-weight: 700; + letter-spacing: -1px; + color: @btn-primary-bg; + } + + .masthead p { + font-family: inherit; + font-size: 33px; + font-weight: 500; + } + + .btn-github { + color: #fff; + background-color: #474949; + boder-color: $474949; + } + + form { + display: block; + width: 350px; + padding: 30px 0; + margin-left: auto; + margin-right: auto; + } + + .input-group, input[type=text], button { + width: 100%; + } + + .login_error { + color: orangered; + font-weight: bold; + text-align: center; + } +} + diff --git a/share/static/less/page.less b/share/static/less/page.less new file mode 100644 index 0000000..e546acc --- /dev/null +++ b/share/static/less/page.less @@ -0,0 +1,26 @@ +.jpy-logo { + height: 28px; + margin-top: 6px; +} + +#header { + border-bottom: 1px solid #e7e7e7; + height: 40px; +} + +.hidden { + display: none; +} + +.dropdown.navbar-btn{ + padding:0 5px 0 0; +} + +#login_widget{ + + & .navbar-btn.btn-sm { + margin-top: 5px; + margin-bottom: 5px; + } + +} diff --git a/share/static/less/style.less b/share/static/less/style.less new file mode 100644 index 0000000..d74d7ef --- /dev/null +++ b/share/static/less/style.less @@ -0,0 +1,27 @@ +/*! +* +* Twitter Bootstrap +* +*/ +@import "../components/bootstrap/less/bootstrap.less"; +@import "../components/bootstrap/less/responsive-utilities.less"; + +/*! +* +* Font Awesome +* +*/ +@import "../components/font-awesome/less/font-awesome.less"; +@fa-font-path: "../components/font-awesome/fonts"; + +/*! +* +* Jupyter +* +*/ + +@import "./variables.less"; +@import "./page.less"; +@import "./admin.less"; +@import "./error.less"; +@import "./login.less"; diff --git a/share/static/less/variables.less b/share/static/less/variables.less new file mode 100644 index 0000000..89b45e5 --- /dev/null +++ b/share/static/less/variables.less @@ -0,0 +1,11 @@ +@border-radius-small: 2px; +@border-radius-base: 2px; +@border-radius-large: 3px; +@navbar-height: 20px; + +@jupyter-orange: #F37524; +@jupyter-red: #E34F21; + +.btn-jupyter { + .button-variant(#fff; @jupyter-orange; @jupyter-red); +}