diff --git a/.eslintrc.js b/.eslintrc.js index 6ebd4e25b..36af8add3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,14 +1,48 @@ module.exports = { env: { browser: true, - jquery: true, - node: true, - es6: true, - "jest/globals": true, - }, - extends: ["eslint:recommended"], - ignorePatterns: ["**/dist"], - parser: "@babel/eslint-parser", - plugins: ["jest"], - rules: {}, + es2021: true, + }, + extends: ["eslint:recommended", "plugin:react/recommended"], + ignorePatterns: ["dist"], + overrides: [ + { + env: { + node: true, + }, + files: [".eslintrc.{js,cjs}"], + parserOptions: { + sourceType: "script", + }, + }, + { + files: ["**/*.test.js", "**/*.test.jsx"], + env: { + jest: true, + node: true, + }, + }, + ], + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + }, + plugins: ["react"], + rules: { + "react/react-in-jsx-scope": "off", + "react/jsx-uses-react": "off", + // Temporarily turn off prop-types + "react/prop-types": "off", + "no-unused-vars": ["error", { args: "after-used" }], + }, + ignorePatterns: [ + "jupyterhub_fancy_profiles/static/*.js", + "webpack.config.js", + "babel.config.js", + ], + settings: { + react: { + version: "detect", + }, + }, }; diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a162ef585..49d1a9241 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -173,6 +173,9 @@ jobs: pip install --no-binary pycurl -r dev-requirements.txt -r helm-chart/images/binderhub/requirements.txt pip install -e . + - name: Install Playwright browser + run: playwright install firefox + - name: Install JupyterHub chart for main tests if: matrix.test == 'main' run: | @@ -434,6 +437,9 @@ jobs: pip install ".[pycurl]" --no-binary pycurl pip install -e ".[pycurl]" --no-binary pycurl + - name: Install Playwright browser + run: playwright install firefox + - name: Setup JupyterHub NPM dependencies run: npm install -g configurable-http-proxy diff --git a/.gitignore b/.gitignore index 19b482212..14863756b 100644 --- a/.gitignore +++ b/.gitignore @@ -81,6 +81,7 @@ coverage.xml *.py,cover .hypothesis/ .pytest_cache/ +coverage # Translations *.mo diff --git a/babel.config.json b/babel.config.json index 1320b9a32..08d007ea3 100644 --- a/babel.config.json +++ b/babel.config.json @@ -1,3 +1,6 @@ { - "presets": ["@babel/preset-env"] + "presets": [ + "@babel/preset-env", + ["@babel/preset-react", { "runtime": "automatic" }] + ] } diff --git a/binderhub/app.py b/binderhub/app.py index dd14e1dd4..560f54506 100644 --- a/binderhub/app.py +++ b/binderhub/app.py @@ -42,15 +42,15 @@ ) from traitlets.config import Application -from .base import AboutHandler, Custom404, VersionHandler +from .base import VersionHandler from .build import BuildExecutor, KubernetesBuildExecutor, KubernetesCleaner from .builder import BuildHandler -from .config import ConfigHandler from .events import EventLog +from .handlers.repoproviders import RepoProvidersHandlers from .health import HealthHandler, KubernetesHealthHandler from .launcher import Launcher from .log import log_request -from .main import LegacyRedirectHandler, MainHandler, ParameterizedMainHandler +from .main import LegacyRedirectHandler, RepoLaunchUIHandler, UIHandler from .metrics import MetricsHandler from .quota import KubernetesLaunchQuota, LaunchQuota from .ratelimit import RateLimiter @@ -107,6 +107,11 @@ def _log_level(self): None, allow_none=True, help=""" + ..removed:: + + No longer supported. If you want to use Google Analytics, use :attr:`extra_footer_scripts` + to load JS from Google Analytics. + The Google Analytics code to use on the main page. Note that we'll respect Do Not Track settings, despite the fact that GA does not. @@ -118,6 +123,11 @@ def _log_level(self): google_analytics_domain = Unicode( "auto", help=""" + ..removed:: + + No longer supported. If you want to use Google Analytics, use :attr:`extra_footer_scripts` + to load JS from Google Analytics. + The Google Analytics domain to use on the main page. By default this is set to 'auto', which sets it up for current domain and all @@ -126,6 +136,13 @@ def _log_level(self): config=True, ) + @observe("google_analytics_domain", "google_analytics_code") + def _google_analytics_deprecation(self, change): + if change.new: + raise ValueError( + f"Setting {change.owner.__class__.__name__}.{change.name} is no longer supported. Use {change.owner.__class__.__name__}.extra_footer_scripts to load Google Analytics JS directly" + ) + about_message = Unicode( "", help=""" @@ -149,6 +166,14 @@ def _log_level(self): config=True, ) + default_opengraph_title = Unicode( + "The Binder Project", + help=""" + The default opengraph title for pages that don't have a generated opengraph title. + """, + config=True, + ) + extra_footer_scripts = Dict( {}, help=""" @@ -785,7 +810,6 @@ def _template_path_default(self): - /versions - /build/([^/]+)/(.+) - /health - - /_config - /* -> shows a 404 page """, config=True, @@ -913,6 +937,7 @@ def initialize(self, *args, **kwargs): "log_function": log_request, "image_prefix": self.image_prefix, "debug": self.debug, + "default_opengraph_title": self.default_opengraph_title, "launcher": self.launcher, "ban_networks": self.ban_networks, "build_pool": self.build_pool, @@ -931,8 +956,6 @@ def initialize(self, *args, **kwargs): "registry": registry, "traitlets_config": self.config, "traitlets_parent": self, - "google_analytics_code": self.google_analytics_code, - "google_analytics_domain": self.google_analytics_domain, "about_message": self.about_message, "banner_message": self.banner_message, "extra_footer_scripts": self.extra_footer_scripts, @@ -961,15 +984,23 @@ def initialize(self, *args, **kwargs): (r"/versions", VersionHandler), (r"/build/([^/]+)/(.+)", BuildHandler), (r"/health", self.health_handler_class, {"hub_url": self.hub_url_local}), - (r"/_config", ConfigHandler), + (r"/api/repoproviders", RepoProvidersHandlers), ] if not self.enable_api_only_mode: # In API only mode the endpoints in the list below - # are unregistered as they don't make sense in a API only scenario + # are not registered since they are primarily about providing UI + + for provider_id in self.repo_providers: + # Register launchable URLs for all our repo providers + # These render social previews, but otherwise redirect to UIHandler + handlers += [ + ( + rf"/v2/({provider_id})/(.+)", + RepoLaunchUIHandler, + {"repo_provider": self.repo_providers[provider_id]}, + ) + ] handlers += [ - (r"/about", AboutHandler), - (r"/v2/([^/]+)/(.+)", ParameterizedMainHandler), - (r"/", MainHandler), (r"/repo/([^/]+)/([^/]+)(/.*)?", LegacyRedirectHandler), # for backward-compatible mybinder.org badge URLs # /assets/images/badge.svg @@ -1036,9 +1067,8 @@ def initialize(self, *args, **kwargs): ) }, ), + (r"/.*", UIHandler), ] - # This needs to be the last handler in the list, because it needs to match "everything else" - handlers.append((r".*", Custom404)) handlers = self.add_url_prefix(self.base_url, handlers) if self.extra_static_path: handlers.insert( diff --git a/binderhub/base.py b/binderhub/base.py index 3695f1cdc..b21bfdb5b 100644 --- a/binderhub/base.py +++ b/binderhub/base.py @@ -2,7 +2,6 @@ import json import urllib.parse -from http.client import responses import jwt from jupyterhub.services.auth import HubOAuth, HubOAuthenticated @@ -204,47 +203,10 @@ def extract_message(self, exc_info): except Exception: return "" - def write_error(self, status_code, **kwargs): - exc_info = kwargs.get("exc_info") - message = "" - status_message = responses.get(status_code, "Unknown HTTP Error") - if exc_info: - message = self.extract_message(exc_info) - - self.render_template( - "error.html", - status_code=status_code, - status_message=status_message, - message=message, - ) - def options(self, *args, **kwargs): pass -class Custom404(BaseHandler): - """Raise a 404 error, rendering the error.html template""" - - def prepare(self): - raise web.HTTPError(404) - - -class AboutHandler(BaseHandler): - """Serve the about page""" - - async def get(self): - self.render_template( - "about.html", - base_url=self.settings["base_url"], - submit=False, - binder_version=binder_version, - message=self.settings["about_message"], - google_analytics_code=self.settings["google_analytics_code"], - google_analytics_domain=self.settings["google_analytics_domain"], - extra_footer_scripts=self.settings["extra_footer_scripts"], - ) - - class VersionHandler(BaseHandler): """Serve information about versions running""" diff --git a/binderhub/handlers/__init__.py b/binderhub/handlers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/binderhub/handlers/repoproviders.py b/binderhub/handlers/repoproviders.py new file mode 100644 index 000000000..7bfb3c94f --- /dev/null +++ b/binderhub/handlers/repoproviders.py @@ -0,0 +1,15 @@ +import json + +from ..base import BaseHandler + + +class RepoProvidersHandlers(BaseHandler): + """Serve config""" + + async def get(self): + config = [ + repo_provider_class.display_config + for repo_provider_class in self.settings["repo_providers"].values() + ] + self.set_header("Content-type", "application/json") + self.write(json.dumps(config)) diff --git a/binderhub/main.py b/binderhub/main.py index f89d23d79..357e2bef4 100644 --- a/binderhub/main.py +++ b/binderhub/main.py @@ -3,129 +3,79 @@ """ import time -import urllib.parse import jwt -from tornado.httpclient import AsyncHTTPClient, HTTPRequest from tornado.httputil import url_concat -from tornado.log import app_log -from tornado.web import HTTPError, authenticated +from tornado.web import authenticated +from . import __version__ as binder_version from .base import BaseHandler -SPEC_NAMES = { - "gh": "GitHub", - "gist": "Gist", - "gl": "GitLab", - "git": "Git repo", - "zenodo": "Zenodo", - "figshare": "Figshare", - "hydroshare": "Hydroshare", - "dataverse": "Dataverse", - "ckan": "CKAN", -} +class UIHandler(BaseHandler): + """ + Responds to most UI Page Requests + """ -class MainHandler(BaseHandler): - """Main handler for requests""" + def initialize(self): + self.opengraph_title = self.settings["default_opengraph_title"] + self.page_config = {} + return super().initialize() @authenticated def get(self): + repoproviders_display_config = [ + repo_provider_class.display_config + for repo_provider_class in self.settings["repo_providers"].values() + ] + self.page_config |= { + "baseUrl": self.settings["base_url"], + "badgeBaseUrl": self.get_badge_base_url(), + "logoUrl": self.static_url("logo.svg"), + "logoWidth": "320px", + "repoProviders": repoproviders_display_config, + "aboutMessage": self.settings["about_message"], + "bannerHtml": self.settings["banner_message"], + "binderVersion": binder_version, + } self.render_template( - "index.html", - badge_base_url=self.get_badge_base_url(), - base_url=self.settings["base_url"], - submit=False, - google_analytics_code=self.settings["google_analytics_code"], - google_analytics_domain=self.settings["google_analytics_domain"], + "page.html", + page_config=self.page_config, extra_footer_scripts=self.settings["extra_footer_scripts"], - repo_providers=self.settings["repo_providers"], + opengraph_title=self.opengraph_title, ) -class ParameterizedMainHandler(BaseHandler): - """Main handler that allows different parameter settings""" +class RepoLaunchUIHandler(UIHandler): + """ + Responds to /v2/ launch URLs only + + Forwards to UIHandler, but puts out an opengraph_title for social previews + """ + + def initialize(self, repo_provider): + self.repo_provider = repo_provider + return super().initialize() @authenticated - async def get(self, provider_prefix, _unescaped_spec): - prefix = "/v2/" + provider_prefix + def get(self, provider_id, _escaped_spec): + prefix = "/v2/" + provider_id spec = self.get_spec_from_request(prefix) - spec = spec.rstrip("/") - try: - self.get_provider(provider_prefix, spec=spec) - except HTTPError: - raise - except Exception as e: - app_log.error( - "Failed to construct provider for %s/%s", - provider_prefix, - spec, - ) - # FIXME: 400 assumes it's the user's fault (?) - # maybe we should catch a special InvalidSpecError here - raise HTTPError(400, str(e)) - - provider_spec = f"{provider_prefix}/{spec}" - social_desc = f"{SPEC_NAMES[provider_prefix]}: {spec}" - nbviewer_url = None - if provider_prefix == "gh": - # We can only produce an nbviewer URL for github right now - nbviewer_url = "https://nbviewer.jupyter.org/github" - org, repo_name, ref = spec.split("/", 2) - # NOTE: tornado unquotes query arguments too -> notebooks%2Findex.ipynb becomes notebooks/index.ipynb - filepath = self.get_argument("labpath", "").lstrip("/") - if not filepath: - filepath = self.get_argument("filepath", "").lstrip("/") - - # Check the urlpath parameter for a file path, if so use it for the filepath - urlpath = self.get_argument("urlpath", "").lstrip("/") - if urlpath and "/tree/" in urlpath: - filepath = urlpath.split("tree/", 1)[-1] - - blob_or_tree = "blob" if filepath else "tree" - nbviewer_url = ( - f"{nbviewer_url}/{org}/{repo_name}/{blob_or_tree}/{ref}/{filepath}" - ) - - # Check if the nbviewer URL is valid and would display something - # useful to the reader, if not we don't show it - client = AsyncHTTPClient() - # quote any unicode characters in the URL - proto, rest = nbviewer_url.split("://") - rest = urllib.parse.quote(rest) - - request = HTTPRequest( - proto + "://" + rest, - method="HEAD", - user_agent="BinderHub", - ) - response = await client.fetch(request, raise_error=False) - if response.code >= 400: - nbviewer_url = None build_token = jwt.encode( { "exp": int(time.time()) + self.settings["build_token_expires_seconds"], - "aud": provider_spec, + "aud": f"{provider_id}/{spec}", "origin": self.token_origin(), }, key=self.settings["build_token_secret"], algorithm="HS256", ) - self.render_template( - "loading.html", - base_url=self.settings["base_url"], - badge_base_url=self.get_badge_base_url(), - build_token=build_token, - provider_spec=provider_spec, - social_desc=social_desc, - nbviewer_url=nbviewer_url, - # urlpath=self.get_argument('urlpath', None), - submit=True, - google_analytics_code=self.settings["google_analytics_code"], - google_analytics_domain=self.settings["google_analytics_domain"], - extra_footer_scripts=self.settings["extra_footer_scripts"], + self.page_config["buildToken"] = build_token + self.opengraph_title = ( + f"{self.repo_provider.display_config['displayName']}: {spec}" ) + return super().get() class LegacyRedirectHandler(BaseHandler): diff --git a/binderhub/repoproviders.py b/binderhub/repoproviders.py index 6e4a0af96..a884f1714 100644 --- a/binderhub/repoproviders.py +++ b/binderhub/repoproviders.py @@ -120,6 +120,8 @@ class RepoProvider(LoggingConfigurable): unresolved_ref = Unicode() + display_config = {} + git_credentials = Unicode( "", help=""" @@ -220,11 +222,15 @@ def is_valid_sha1(sha1): class FakeProvider(RepoProvider): """Fake provider for local testing of the UI""" - labels = { - "text": "Fake Provider", - "tag_text": "Fake Ref", - "ref_prop_disabled": True, - "label_prop_disabled": True, + display_config = { + "displayName": "Fake", + "id": "fake", + "enabled": False, + "spec": {"validateRegex": ".*"}, + "repo": {"label": "Fake Repo", "placeholder": "", "urlEncode": False}, + "ref": { + "enabled": False, + }, } async def get_resolved_ref(self): @@ -251,13 +257,16 @@ class ZenodoProvider(RepoProvider): name = Unicode("Zenodo") - display_name = "Zenodo DOI" - - labels = { - "text": "Zenodo DOI (10.5281/zenodo.3242074)", - "tag_text": "Git ref (branch, tag, or commit)", - "ref_prop_disabled": True, - "label_prop_disabled": True, + display_config = { + "displayName": "Zenodo DOI", + "id": "zenodo", + "spec": {"validateRegex": r"10\.\d+\/(.)+"}, + "repo": { + "label": "Zenodo DOI", + "placeholder": "example: 10.5281/zenodo.3242074", + "urlEncode": False, + }, + "ref": {"enabled": False}, } async def get_resolved_ref(self): @@ -298,17 +307,20 @@ class FigshareProvider(RepoProvider): name = Unicode("Figshare") - display_name = "Figshare DOI" + display_config = { + "displayName": "FigShare DOI", + "id": "figshare", + "spec": {"validateRegex": r"10\.\d+\/(.)+"}, + "repo": { + "label": "FigShare DOI", + "placeholder": "example: 10.6084/m9.figshare.9782777.v1", + "urlEncode": False, + }, + "ref": {"enabled": False}, + } url_regex = re.compile(r"(.*)/articles/([^/]+)/([^/]+)/(\d+)(/)?(\d+)?") - labels = { - "text": "Figshare DOI (10.6084/m9.figshare.9782777.v1)", - "tag_text": "Git ref (branch, tag, or commit)", - "ref_prop_disabled": True, - "label_prop_disabled": True, - } - async def get_resolved_ref(self): client = AsyncHTTPClient() req = HTTPRequest(f"https://doi.org/{self.spec}", user_agent="BinderHub") @@ -349,13 +361,16 @@ def get_build_slug(self): class DataverseProvider(RepoProvider): name = Unicode("Dataverse") - display_name = "Dataverse DOI" - - labels = { - "text": "Dataverse DOI (10.7910/DVN/TJCLKP)", - "tag_text": "Git ref (branch, tag, or commit)", - "ref_prop_disabled": True, - "label_prop_disabled": True, + display_config = { + "displayName": "Dataverse DOI", + "id": "dataverse", + "spec": {"validateRegex": r"10\.\d+\/(.)+"}, + "repo": { + "label": "Dataverse DOI", + "placeholder": "example: 10.7910/DVN/TJCLKP", + "urlEncode": False, + }, + "ref": {"enabled": False}, } async def get_resolved_ref(self): @@ -416,17 +431,20 @@ class HydroshareProvider(RepoProvider): name = Unicode("Hydroshare") - display_name = "Hydroshare resource" + display_config = { + "displayName": "Hydroshare resource", + "id": "hydroshare", + "spec": {"validateRegex": r"[^/]+"}, + "repo": { + "label": "Hydroshare resource id", + "placeholder": "example: 8f7c2f0341ef4180b0dbe97f59130756", + "urlEncode": True, + }, + "ref": {"enabled": False}, + } url_regex = re.compile(r".*([0-9a-f]{32}).*") - labels = { - "text": "Hydroshare resource id or URL", - "tag_text": "Git ref (branch, tag, or commit)", - "ref_prop_disabled": True, - "label_prop_disabled": True, - } - def _parse_resource_id(self, spec): match = self.url_regex.match(spec) if not match: @@ -482,13 +500,16 @@ class CKANProvider(RepoProvider): name = Unicode("CKAN") - display_name = "CKAN dataset" - - labels = { - "text": "CKAN dataset URL (https://demo.ckan.org/dataset/sample-dataset-1)", - "tag_text": "Git ref (branch, tag, or commit)", - "ref_prop_disabled": True, - "label_prop_disabled": True, + display_config = { + "displayName": "CKAN dataset", + "id": "ckan", + "spec": {"validateRegex": r"[^/]+"}, + "repo": { + "label": "CKAN dataset URL", + "placeholder": "https://demo.ckan.org/dataset/sample-dataset-1", + "urlEncode": True, + }, + "ref": {"enabled": False}, } def __init__(self, *args, **kwargs): @@ -579,13 +600,16 @@ class GitRepoProvider(RepoProvider): name = Unicode("Git") - display_name = "Git repository" - - labels = { - "text": "Arbitrary git repository URL (http://git.example.com/repo)", - "tag_text": "Git ref (branch, tag, or commit)", - "ref_prop_disabled": False, - "label_prop_disabled": False, + display_config = { + "displayName": "Git repository", + "id": "git", + "spec": {"validateRegex": r"[^/]+/.+"}, + "repo": { + "label": "Arbitrary git repository URL", + "placeholder": "example: http://git.example.com/repo", + "urlEncode": True, + }, + "ref": {"enabled": True, "default": "HEAD"}, } allowed_protocols = Set( @@ -683,7 +707,18 @@ class GitLabRepoProvider(RepoProvider): name = Unicode("GitLab") - display_name = "GitLab.com" + display_config = { + "displayName": "GitLab", + "id": "gl", + "spec": {"validateRegex": r"[^/]+/.+"}, + "detect": {"regex": "^(https?://gitlab.com/)?(?.*)"}, + "repo": { + "label": "GitLab repository name or URL", + "placeholder": "example: https://gitlab.com/mosaik/examples/mosaik-tutorials-on-binder or mosaik/examples/mosaik-tutorials-on-binder", + "urlEncode": True, + }, + "ref": {"enabled": True, "default": "HEAD"}, + } hostname = Unicode( "gitlab.com", @@ -741,13 +776,6 @@ def _default_git_credentials(self): return rf"username=binderhub\npassword={self.private_token}" return "" - labels = { - "text": "GitLab.com repository or URL", - "tag_text": "Git ref (branch, tag, or commit)", - "ref_prop_disabled": False, - "label_prop_disabled": False, - } - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.quoted_namespace, unresolved_ref = self.spec.split("/", 1) @@ -808,7 +836,18 @@ class GitHubRepoProvider(RepoProvider): name = Unicode("GitHub") - display_name = "GitHub" + display_config = { + "displayName": "GitHub", + "id": "gh", + "spec": {"validateRegex": r".+/.+/.+"}, + "detect": {"regex": "^(https?://github.com/)?(?.*)"}, + "repo": { + "label": "GitHub repository name or URL", + "placeholder": "example: yuvipanda/requirements or https://github.com/yuvipanda/requirements", + "urlEncode": False, + }, + "ref": {"enabled": True, "default": "HEAD"}, + } # shared cache for resolved refs cache = Cache(1024) @@ -894,13 +933,6 @@ def _default_git_credentials(self): return rf"username={self.access_token}\npassword=x-oauth-basic" return "" - labels = { - "text": "GitHub repository name or URL", - "tag_text": "Git ref (branch, tag, or commit)", - "ref_prop_disabled": False, - "label_prop_disabled": False, - } - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.user, self.repo, self.unresolved_ref = tokenize_spec(self.spec) @@ -1077,7 +1109,18 @@ class GistRepoProvider(GitHubRepoProvider): name = Unicode("Gist") - display_name = "Gist" + display_config = { + "displayName": "GitHub Gist", + "id": "gist", + "spec": {"validateRegex": r".+/.+(/.+)"}, + "detect": {"regex": "^(https?://gist.github.com/)?(?.*)"}, + "repo": { + "label": "Gist ID (username/gistId) or URL", + "placeholder": "", + "urlEncode": False, + }, + "ref": {"enabled": True, "default": "HEAD"}, + } hostname = Unicode("gist.github.com") @@ -1087,13 +1130,6 @@ class GistRepoProvider(GitHubRepoProvider): help="Flag for allowing usages of secret Gists. The default behavior is to disallow secret gists.", ) - labels = { - "text": "Gist ID (username/gistId) or URL", - "tag_text": "Git commit SHA", - "ref_prop_disabled": False, - "label_prop_disabled": False, - } - def __init__(self, *args, **kwargs): # We dont need to initialize entirely the same as github super(RepoProvider, self).__init__(*args, **kwargs) diff --git a/binderhub/static/fonts/clearsans/LICENSE-2.0.txt b/binderhub/static/fonts/clearsans/LICENSE-2.0.txt deleted file mode 100644 index d64569567..000000000 --- a/binderhub/static/fonts/clearsans/LICENSE-2.0.txt +++ /dev/null @@ -1,202 +0,0 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/binderhub/static/fonts/clearsans/WOFF/ClearSans-Bold.woff b/binderhub/static/fonts/clearsans/WOFF/ClearSans-Bold.woff deleted file mode 100644 index bda6eb27a..000000000 Binary files a/binderhub/static/fonts/clearsans/WOFF/ClearSans-Bold.woff and /dev/null differ diff --git a/binderhub/static/fonts/clearsans/WOFF/ClearSans-BoldItalic.woff b/binderhub/static/fonts/clearsans/WOFF/ClearSans-BoldItalic.woff deleted file mode 100644 index 4dee91c29..000000000 Binary files a/binderhub/static/fonts/clearsans/WOFF/ClearSans-BoldItalic.woff and /dev/null differ diff --git a/binderhub/static/fonts/clearsans/WOFF/ClearSans-Italic.woff b/binderhub/static/fonts/clearsans/WOFF/ClearSans-Italic.woff deleted file mode 100644 index 56573d21d..000000000 Binary files a/binderhub/static/fonts/clearsans/WOFF/ClearSans-Italic.woff and /dev/null differ diff --git a/binderhub/static/fonts/clearsans/WOFF/ClearSans-Light.woff b/binderhub/static/fonts/clearsans/WOFF/ClearSans-Light.woff deleted file mode 100644 index bae448bfa..000000000 Binary files a/binderhub/static/fonts/clearsans/WOFF/ClearSans-Light.woff and /dev/null differ diff --git a/binderhub/static/fonts/clearsans/WOFF/ClearSans-Medium.woff b/binderhub/static/fonts/clearsans/WOFF/ClearSans-Medium.woff deleted file mode 100644 index 702fb8aed..000000000 Binary files a/binderhub/static/fonts/clearsans/WOFF/ClearSans-Medium.woff and /dev/null differ diff --git a/binderhub/static/fonts/clearsans/WOFF/ClearSans-MediumItalic.woff b/binderhub/static/fonts/clearsans/WOFF/ClearSans-MediumItalic.woff deleted file mode 100644 index aecf7dfbc..000000000 Binary files a/binderhub/static/fonts/clearsans/WOFF/ClearSans-MediumItalic.woff and /dev/null differ diff --git a/binderhub/static/fonts/clearsans/WOFF/ClearSans-Regular.woff b/binderhub/static/fonts/clearsans/WOFF/ClearSans-Regular.woff deleted file mode 100644 index f4aacf79d..000000000 Binary files a/binderhub/static/fonts/clearsans/WOFF/ClearSans-Regular.woff and /dev/null differ diff --git a/binderhub/static/fonts/clearsans/WOFF/ClearSans-Thin.woff b/binderhub/static/fonts/clearsans/WOFF/ClearSans-Thin.woff deleted file mode 100644 index aa501c6bc..000000000 Binary files a/binderhub/static/fonts/clearsans/WOFF/ClearSans-Thin.woff and /dev/null differ diff --git a/binderhub/static/images/caretdown-white.svg b/binderhub/static/images/caretdown-white.svg deleted file mode 100644 index 731fc2f3e..000000000 --- a/binderhub/static/images/caretdown-white.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/binderhub/static/images/clipboard.svg b/binderhub/static/images/clipboard.svg deleted file mode 100644 index 80c2cb91a..000000000 --- a/binderhub/static/images/clipboard.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/binderhub/static/images/copy-icon-black.svg b/binderhub/static/images/copy-icon-black.svg deleted file mode 100644 index a15cf49c6..000000000 --- a/binderhub/static/images/copy-icon-black.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/binderhub/static/images/copy-icon-white.svg b/binderhub/static/images/copy-icon-white.svg deleted file mode 100644 index db97266b1..000000000 --- a/binderhub/static/images/copy-icon-white.svg +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/binderhub/static/images/favicon_fail.ico b/binderhub/static/images/favicon/fail.ico similarity index 100% rename from binderhub/static/images/favicon_fail.ico rename to binderhub/static/images/favicon/fail.ico diff --git a/binderhub/static/images/favicon_building.ico b/binderhub/static/images/favicon/progress.ico similarity index 100% rename from binderhub/static/images/favicon_building.ico rename to binderhub/static/images/favicon/progress.ico diff --git a/binderhub/static/images/favicon_success.ico b/binderhub/static/images/favicon/success.ico similarity index 100% rename from binderhub/static/images/favicon_success.ico rename to binderhub/static/images/favicon/success.ico diff --git a/binderhub/static/index.css b/binderhub/static/index.css deleted file mode 100644 index 31021457f..000000000 --- a/binderhub/static/index.css +++ /dev/null @@ -1,314 +0,0 @@ -/* custom fonts we are going to be using. */ - -@font-face { - font-family: ClearSans-Thin; - src: url("../static/fonts/clearsans/WOFF/ClearSans-Thin.woff"); -} - -@font-face { - font-family: ClearSans-Light; - src: url("../static/fonts/clearsans/WOFF/ClearSans-Light.woff"); -} - -@font-face { - font-family: ClearSans-Bold; - src: url("../static/fonts/clearsans/WOFF/ClearSans-Bold.woff"); -} - -.hidden { - display: none; -} - -body { - font-family: "ClearSans-Thin", sans-serif; -} - -form { - font-family: "ClearSans-Light", sans-serif; -} - -p > a { - cursor: pointer; - color: rgb(120, 120, 120); - border-bottom: dotted 2px rgb(120, 120, 120); - transition: all 0.1s; - text-decoration: non; -} - -p > a:hover { - color: rgb(30, 30, 30); - border-bottom: dotted 2px rgb(30, 30, 30); - text-decoration: none; -} - -#build-form { - color: rgb(50, 50, 50); - background: rgb(235, 236, 237); - padding: 55px; - padding-top: 25px; - padding-bottom: 20px; -} - -#banner-container { - text-align: left; - color: black; - padding: 16px; - width: 100%; - background-color: rgb(235, 236, 237); - position: relative; -} - -#logo-container { - text-align: center; - color: black; - padding: 16px; -} - -#logo { - padding: 8px; - padding-bottom: 22px; - padding-top: 10px; -} - -#header { - margin-left: 5%; - width: 90%; - padding-bottom: 24px; -} - -.btn-submit { - background-color: rgb(223, 132, 41); - box-shadow: 1px 1px rgba(0, 0, 0, 0.075); - color: white; - border: none; - height: 35px; - width: 100%; - border-radius: 4px; -} - -.jumbotron { - background: rgb(235, 236, 237); -} - -h3 { - color: rgb(70, 70, 70); - font-size: 42px; - line-height: 1.3; -} - -h4 { - font-size: 20px; - color: rgb(70, 70, 70); -} - -#form-header { - padding-bottom: 5px; -} - -#build-progress { - font-family: ClearSans-Bold, sans-serif; - font-size: 16px; - height: 28px; - text-shadow: black 1px 1px 1px; -} - -#build-progress .progress-bar { - padding-top: 4px; -} - -#explanation { - color: rgb(40, 40, 40); - font-size: 20px; - line-height: 1.5; - font-weight: bold; -} - -#log-container .panel-body { - /* match color of terminal! */ - background-color: black; -} - -#launch-buttons { - margin-top: 24px; - width: 100%; -} - -#log { - height: 400px; -} - -#log .terminal { - font-family: "Roboto Mono", monospace; -} - -.url, -.badges { - background-color: #ffffff; - -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); - border: 1px solid #ccc; - box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); - border-radius: 4px; - display: flex; - flex-direction: column; - align-items: flex-end; -} - -.url { - margin-bottom: 20px; -} - -.badges { - margin-bottom: 40px; -} - -.dropdownmenu { - background-color: #dddddd; - border-radius: 3px 3px 0px 0px; - height: 35px; - width: 100%; -} - -.dropdownmenu label { - color: black; - padding: 6px 12px; - margin: 0px; - width: 95%; -} - -.badge-snippet-row, -.url-row { - width: 100%; - display: flex; - flex-direction: reverse; - border-bottom: 1px #ccc solid; - padding: 10px; -} - -.badge-snippet-row .icon, -.url-row .icon { - order: 0; - max-width: 30px; - max-height: 40px; - padding: 3px; - /* margin-top: 13px; */ - /*margin-left: 4px; */ -} - -.input-group-btn .btn { - border: solid #ccc 1px; -} - -.badge-snippet-row pre, -.url-row pre { - order: 1; - margin: 0; - flex-grow: 1; - border-top-right-radius: 0; - border-bottom-right-radius: 0; -} - -#how-it-works { - line-height: 1.5; - font-weight: bold; - font-size: 18px; -} - -#how-it-works div.row { - margin: 32px; - display: flex; - flex-direction: row; - align-items: baseline; -} - -.point { - border-radius: 50%; - border-width: 5px; - border-style: solid; - width: 40px; - height: 40px; - padding: 2px 9px; - font-weight: 800; - font-family: "ClearSans-Bold"; -} - -.point-container { - padding-top: 4px; -} - -.point-orange { - border-color: rgb(247, 144, 42); - color: rgb(247, 144, 42); -} - -.point-red { - border-color: rgb(204, 67, 101); - color: rgb(204, 67, 101); -} - -.point-blue { - border-color: rgb(41, 124, 184); - color: rgb(41, 124, 184); -} - -/*reduce font size of h1 and h2 so the initial design when h3 and h4 tags were used respectively is retained*/ -h1 { - font-size: 1.25em; -} - -h2 { - font-size: 1.125em; -} - -span.front-em { - font-size: 1.5em; -} - -div.front { - font-size: 0.9em; -} - -h4.logo-subtext { - margin-top: -60px; -} - -.form-row .form-group:first-child { - padding-left: 0; -} - -.form-row .form-group:last-child { - padding-right: 0; -} - -#badge-snippets { - width: 100%; -} - -/**/ - -@media (max-width: 991px) { - .form-row .form-group { - padding: 0; - } - - #launch-buttons { - margin-top: inherit; - } -} - -/*Clipboard styling*/ - -img.icon.clipboard { - order: 1; - border: thin solid silver; - border-radius: 5px; - border-bottom-left-radius: 0; - border-top-left-radius: 0; - border-left: none; -} -img.icon.clipboard:hover { - background: #f5f5f5; -} - -img.icon.clipboard:active { - background: #ddd; -} diff --git a/binderhub/static/js/App.jsx b/binderhub/static/js/App.jsx new file mode 100644 index 000000000..2b4b6d6c7 --- /dev/null +++ b/binderhub/static/js/App.jsx @@ -0,0 +1,111 @@ +import { LoadingPage } from "./pages/LoadingPage.jsx"; +import { Route, Router, Switch } from "wouter"; +import "bootstrap/js/dist/dropdown.js"; + +import "./index.scss"; +import "@fontsource/clear-sans/100.css"; +import "@fontsource/clear-sans/300.css"; +import "@fontsource/clear-sans/400.css"; +import { HomePage } from "./pages/HomePage.jsx"; +import { AboutPage } from "./pages/AboutPage.jsx"; +import { NotFoundPage } from "./pages/NotFoundPage.jsx"; + +export const PAGE_CONFIG = window.pageConfig; + +/** + * @typedef {object} RepoConfig + * @prop {string} label + * @prop {string} placeholder + * @prop {boolean} urlEncode + * + * @typedef {object} DetectConfig + * @prop {string} regex + * + * @typedef {object} RefConfig + * @prop {boolean} enabled + * @prop {string} [default] + * + * @typedef {object} SpecConfig + * @prop {string} validateRegex + * + * @typedef {object} Provider + * @prop {string} displayName + * @prop {string} id + * @prop {DetectConfig} [detect] + * @prop {RepoConfig} repo + * @prop {RefConfig} ref + * @prop {SpecConfig} spec + * + */ +/** + * @type {Array} + */ +export const PROVIDERS = PAGE_CONFIG.repoProviders; + +export const BASE_URL = new URL(PAGE_CONFIG.baseUrl, window.location.href); + +export const PUBLIC_BASE_URL = PAGE_CONFIG.publicBaseUrl + ? new URL(BASE_URL) + : new URL(PAGE_CONFIG.baseUrl, window.location.href); + +const BUILD_TOKEN = PAGE_CONFIG.buildToken; + +export function App({ routerHook }) { + // Wouter's component requires *not* having trailing slash to function + // the way we want + const baseRouteUrl = + BASE_URL.pathname.slice(-1) == "/" + ? BASE_URL.pathname.slice(0, -1) + : BASE_URL.pathname; + return ( + <> + {PAGE_CONFIG.bannerHtml && ( +
+ )} +
+
+
+ +
+ + + + + + + {PROVIDERS.map((p) => ( + ${p.spec.validateRegex})`} + > + + + ))} + + + + + + + + + +
+
+ + ); +} diff --git a/binderhub/static/js/App.test.jsx b/binderhub/static/js/App.test.jsx new file mode 100644 index 000000000..35ae6de0a --- /dev/null +++ b/binderhub/static/js/App.test.jsx @@ -0,0 +1,52 @@ +import { render, screen } from "@testing-library/react"; + +import { App } from "./App"; +import { memoryLocation } from "wouter/memory-location"; + +test("render Homepage", () => { + render(); + expect( + screen.queryByText( + /Turn a Git repo into a collection of interactive notebooks/, + ), + ).toBeInTheDocument(); +}); + +test("render About page", () => { + const { hook } = memoryLocation({ path: "/about" }); + render(); + expect(screen.queryByText(/This is the about message/)).toBeInTheDocument(); + expect(screen.queryByText(/v123.456/)).toBeInTheDocument(); +}); + +test("render Not Found page", () => { + const { hook } = memoryLocation({ path: "/not-found" }); + render(); + expect(screen.queryByText(/Not Found/)).toBeInTheDocument(); +}); + +test("renders loading page", () => { + const { hook } = memoryLocation({ path: "/v2/gh/user/repo/main" }); + render(); + expect(screen.queryByText(/Launching your Binder/)).toBeInTheDocument(); +}); + +test("renders loading page with trailing slash", () => { + const { hook } = memoryLocation({ path: "/v2/gh/user/repo/main/" }); + render(); + expect(screen.queryByText(/Launching your Binder/)).toBeInTheDocument(); +}); + +test("renders error for misconfigured repo", () => { + const { hook } = memoryLocation({ path: "/v2/gh/userrep/main/" }); + render(); + expect(screen.queryByText(/Not Found/)).toBeInTheDocument(); +}); + +test("renders loading page with trailing slash", () => { + const { hook } = memoryLocation({ + path: "/v2/zenodo/10.5281/zenodo.3242074/", + }); + render(); + expect(screen.queryByText(/Launching your Binder/)).toBeInTheDocument(); +}); diff --git a/binderhub/static/js/components/BuilderLauncher.jsx b/binderhub/static/js/components/BuilderLauncher.jsx new file mode 100644 index 000000000..39e34b587 --- /dev/null +++ b/binderhub/static/js/components/BuilderLauncher.jsx @@ -0,0 +1,237 @@ +import { BinderRepository } from "@jupyterhub/binderhub-client"; +import { useEffect, useRef, useState } from "react"; +import { Terminal } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import "@xterm/xterm/css/xterm.css"; +import { Progress, PROGRESS_STATES } from "./Progress.jsx"; + +/** + * + * @param {URL} baseUrl + * @param {string?} buildToken + * @param {Spec} spec + * @param {Terminal} term + * @param {Array} logBuffer + * @param {FitAddon} fitAddon + * @param {(l: boolean) => void} setIsLaunching + * @param {(p: PROGRESS_STATES) => void} setProgressState + * @param {(e: boolean) => void} setEnsureLogsVisible + */ +async function buildImage( + baseUrl, + buildToken, + spec, + term, + logBuffer, + fitAddon, + setIsLaunching, + setProgressState, + setEnsureLogsVisible, +) { + const buildEndPointURL = new URL("build/", baseUrl); + let options = {}; + if (buildToken) { + options.buildToken = buildToken; + } + const image = new BinderRepository(spec.buildSpec, buildEndPointURL, options); + // Clear the last line written, so we start from scratch + term.write("\x1b[2K\r"); + logBuffer.length = 0; + fitAddon.fit(); + for await (const data of image.fetch()) { + // Write message to the log terminal if there is a message + if (data.message !== undefined) { + // Write out all messages to the terminal! + term.write(data.message); + // Keep a copy of the message in the logBuffer + logBuffer.push(data.message); + // Resize our terminal to make sure it fits messages appropriately + fitAddon.fit(); + } else { + console.log(data); + } + + switch (data.phase) { + case "failed": { + image.close(); + setIsLaunching(false); + setProgressState(PROGRESS_STATES.FAILED); + setEnsureLogsVisible(true); + break; + } + case "ready": { + setProgressState(PROGRESS_STATES.SUCCESS); + image.close(); + const serverUrl = new URL(data.url); + window.location.href = spec.launchSpec.getJupyterServerRedirectUrl( + serverUrl, + data.token, + ); + console.log(data); + break; + } + case "building": { + setProgressState(PROGRESS_STATES.BUILDING); + break; + } + case "waiting": { + setProgressState(PROGRESS_STATES.WAITING); + break; + } + case "pushing": { + setProgressState(PROGRESS_STATES.PUSHING); + break; + } + case "built": { + setProgressState(PROGRESS_STATES.PUSHING); + break; + } + case "launching": { + setProgressState(PROGRESS_STATES.LAUNCHING); + break; + } + default: { + console.log("Unknown phase in response from server"); + console.log(data); + break; + } + } + } +} + +/** + * @typedef {object} ImageLogsProps + * @prop {(t: Terminal) => void} setTerm + * @prop {(f: FitAddon) => void} setFitAddon + * @prop {boolean} logsVisible + * @prop {Ref>} logBufferRef + * @prop {(l: boolean) => void} setLogsVisible + * + * @param {ImageLogsProps} props + * @returns + */ +function ImageLogs({ + setTerm, + setFitAddon, + logsVisible, + setLogsVisible, + logBufferRef, +}) { + const toggleLogsButton = useRef(); + useEffect(() => { + async function setup() { + const term = new Terminal({ + convertEol: true, + disableStdin: true, + }); + const fitAddon = new FitAddon(); + term.loadAddon(fitAddon); + term.open(document.getElementById("terminal")); + fitAddon.fit(); + setTerm(term); + setFitAddon(fitAddon); + term.write("Logs will appear here when image is being built"); + } + setup(); + }, []); + + return ( +
+
+ Build Logs + + +
+
+
+
+
+ ); +} + +/** + * @typedef {object} BuildLauncherProps + * @prop {URL} baseUrl + * @prop {string?} buildToken + * @prop {Spec} spec + * @prop {boolean} isLaunching + * @prop {(l: boolean) => void} setIsLaunching + * @prop {PROGRESS_STATES} progressState + * @prop {(p: PROGRESS_STATES) => void} setProgressState + * @prop {string?} className + * + * @param {BuildLauncherProps} props + * @returns + */ +export function BuilderLauncher({ + baseUrl, + buildToken, + spec, + isLaunching, + setIsLaunching, + progressState, + setProgressState, + className, +}) { + const [term, setTerm] = useState(null); + const [fitAddon, setFitAddon] = useState(null); + const [logsVisible, setLogsVisible] = useState(false); + const logBufferRef = useRef(new Array()); + useEffect(() => { + async function setup() { + if (isLaunching) { + await buildImage( + baseUrl, + buildToken, + spec, + term, + logBufferRef.current, + fitAddon, + setIsLaunching, + setProgressState, + setLogsVisible, + ); + } + } + setup(); + }, [isLaunching]); + return ( +
+ + +
+ ); +} diff --git a/binderhub/static/js/components/ErrorPage.jsx b/binderhub/static/js/components/ErrorPage.jsx new file mode 100644 index 000000000..cfe01455d --- /dev/null +++ b/binderhub/static/js/components/ErrorPage.jsx @@ -0,0 +1,24 @@ +export function ErrorPage({ title, errorMessage }) { + return ( + <> +
+

{title}

+ +

{errorMessage}

+
+
+
+

+ questions? +
+ join the{" "} + discussion, + read the{" "} + docs, see + the code +

+
+
+ + ); +} diff --git a/binderhub/static/js/components/FaviconUpdater.jsx b/binderhub/static/js/components/FaviconUpdater.jsx new file mode 100644 index 000000000..5ad040a83 --- /dev/null +++ b/binderhub/static/js/components/FaviconUpdater.jsx @@ -0,0 +1,32 @@ +import ProgressIcon from "../../images/favicon/progress.ico"; +import FailIcon from "../../images/favicon/fail.ico"; +import SuccessIcon from "../../images/favicon/success.ico"; + +import { PROGRESS_STATES } from "./Progress.jsx"; + +/** + * @typedef {object} FaviconUpdaterProps + * @prop {PROGRESS_STATES} progressState + * @param {FaviconUpdaterProps} props + */ +export function FaviconUpdater({ progressState }) { + let icon; + switch (progressState) { + case PROGRESS_STATES.FAILED: { + icon = FailIcon; + break; + } + case PROGRESS_STATES.SUCCESS: { + icon = SuccessIcon; + break; + } + case PROGRESS_STATES.BUILDING: + case PROGRESS_STATES.PUSHING: + case PROGRESS_STATES.LAUNCHING: { + icon = ProgressIcon; + break; + } + } + + return ; +} diff --git a/binderhub/static/js/components/HowItWorks.jsx b/binderhub/static/js/components/HowItWorks.jsx new file mode 100644 index 000000000..a01c6ae89 --- /dev/null +++ b/binderhub/static/js/components/HowItWorks.jsx @@ -0,0 +1,75 @@ +export function HowItWorks() { + return ( +
+

How it works

+ +
+
+ + 1 + +
+
+

Enter your repository information

+ Provide in the above form a URL or a GitHub repository that contains + Jupyter notebooks, as well as a branch, tag, or commit hash. Launch + will build your Binder repository. If you specify a path to a notebook + file, the notebook will be opened in your browser after building. +
+
+ +
+
+ + 2 + +
+
+

We build a Docker image of your repository

+ Binder will search for a dependency file, such as requirements.txt or + environment.yml, in the repository's root directory ( + + more details on more complex dependencies in documentation + + ). The dependency files will be used to build a Docker image. If an + image has already been built for the given repository, it will not be + rebuilt. If a new commit has been made, the image will automatically + be rebuilt. +
+
+ +
+
+ + 3 + +
+
+

Interact with your notebooks in a live environment!

A{" "} + JupyterHub{" "} + server will host your repository's contents. We offer you a reusable + link and badge to your live repository that you can easily share with + others. +
+
+
+ ); +} diff --git a/binderhub/static/js/components/LinkGenerator.jsx b/binderhub/static/js/components/LinkGenerator.jsx new file mode 100644 index 000000000..6a6314b8e --- /dev/null +++ b/binderhub/static/js/components/LinkGenerator.jsx @@ -0,0 +1,347 @@ +import { useEffect, useState } from "react"; +import copy from "copy-to-clipboard"; + +/** + * @typedef {object} ProviderSelectorProps + * @prop {import("../App").Provider[]} providers + * @prop {import("../App").Provider} selectedProvider + * @prop {(p: import("../App").Provider) => void} setSelectedProvider + * + * @param {ProviderSelectorProps} props + * @returns + */ +function ProviderSelector({ + providers, + selectedProvider, + setSelectedProvider, +}) { + return ( + <> + +
    + {providers.map((p) => ( +
  • + +
  • + ))} +
+ + ); +} + +function UrlSelector({ setUrlPath }) { + const KINDS = [ + { + id: "file", + displayName: "File", + placeholder: "eg. index.ipynb", + label: "File to open (in JupyterLab)", + // Using /doc/tree as that opens documents *and* notebook files + getUrlPath: (input) => `/doc/tree/${input}`, + }, + { + id: "url", + displayName: "URL", + placeholder: "eg. /rstudio", + label: "URL to open", + getUrlPath: (input) => input, + }, + ]; + + const [kind, setKind] = useState(KINDS[0]); + const [path, setPath] = useState(""); + + useEffect(() => { + if (path) { + setUrlPath(kind.getUrlPath(path)); + } else { + setUrlPath(""); + } + }, [kind, path]); + + return ( + <> + +
+ setPath(e.target.value)} + /> + +
    + {KINDS.map((k) => ( +
  • + +
  • + ))} +
+
+ + ); +} + +/** + * + * @param {URL} publicBaseUrl + * @param {import("../App").Provider} provider + * @param {string} repo + * @param {string} ref + * @param {string} urlPath + * @returns + */ +function makeShareableUrl(publicBaseUrl, provider, repo, ref, urlPath) { + const encodedRepo = provider.repo.urlEncode ? encodeURIComponent(repo) : repo; + const url = new URL(`v2/${provider.id}/${encodedRepo}/${ref}`, publicBaseUrl); + if (urlPath) { + url.searchParams.set("urlpath", urlPath); + } + return url; +} + +export function LinkGenerator({ + providers, + publicBaseUrl, + selectedProvider, + setSelectedProvider, + repo, + setRepo, + reference, + setReference, + urlPath, + setUrlPath, + isLaunching, + setIsLaunching, + className, +}) { + const [badgeType, setBadgeType] = useState("md"); // Options are md and rst + const [badgeVisible, setBadgeVisible] = useState(false); + + let launchUrl = ""; + let badgeMarkup = ""; + + const ref = + reference || + (selectedProvider.ref.enabled ? selectedProvider.ref.default : ""); + if (repo !== "" && (!selectedProvider.ref.enabled || ref !== "")) { + launchUrl = makeShareableUrl( + publicBaseUrl, + selectedProvider, + repo, + ref, + urlPath, + ).toString(); + const badgeLogoUrl = new URL("badge_logo.svg", publicBaseUrl); + if (badgeType === "md") { + badgeMarkup = `[![Binder](${badgeLogoUrl})](${launchUrl})`; + } else { + badgeMarkup = `.. image:: ${badgeLogoUrl}\n :target: ${launchUrl}`; + } + } + + return ( +
+

Build and launch a repository

+
+ {selectedProvider.repo.label} +
+ + { + let repo = e.target.value; + if (selectedProvider.detect && selectedProvider.detect.regex) { + // repo value *must* be detected by this regex, or it is not valid yet + const re = new RegExp(selectedProvider.detect.regex); + const results = re.exec(repo); + if (results !== null && results.groups && results.groups.repo) { + setRepo(results.groups.repo); + } + } else { + setRepo(e.target.value); + } + }} + /> +
+
+ +
+
+ +
+ { + setReference(e.target.value); + }} + /> +
+
+
+ +
+
+ +
+
+ +
+
+
+ {launchUrl || + "Fill in the fields to see a URL for sharing your Binder."} +
+ +
+
+ +
+
+ Badges for your README + +
+
+
+
+ setBadgeType("md")} + > + + + setBadgeType("rst")} + > + +
+
+              {badgeMarkup ||
+                "Fill in the fields to see a badge markup for your README."}
+            
+ +
+
+
+
+ ); +} diff --git a/binderhub/static/loading.css b/binderhub/static/js/components/LoadingIndicator.css similarity index 64% rename from binderhub/static/loading.css rename to binderhub/static/js/components/LoadingIndicator.css index d267b113a..ea8a29120 100644 --- a/binderhub/static/loading.css +++ b/binderhub/static/js/components/LoadingIndicator.css @@ -51,21 +51,21 @@ https://ihatetomatoes.net for initial code templates.*/ } } -.error, -.error:after, -.error:before { +#loader.error, +#loader.error:after, +#loader.error:before { border-top-color: red !important; } -.error { +#loader.error { animation: spin 30s linear infinite !important; } -.error:after { +#loader.error:after { animation: spin 10s linear infinite !important; } -.error:before { +#loader.error:before { animation: spin 20s linear infinite !important; } @@ -74,53 +74,3 @@ https://ihatetomatoes.net for initial code templates.*/ .paused:before { animation-play-state: paused !important; } - -#demo-content { - padding-top: 100px; -} - -div#loader-text { - min-height: 3em; -} - -#loader-text p { - z-index: 1002; - max-width: 750px; - text-align: center; - margin: 0px auto 10px auto; -} - -#loader-text p.launching { - font-size: 2em; -} - -div#loader-links { - min-height: 6em; -} - -#loader-links p { - font-size: 1.5em; - text-align: center; - max-width: 700px; - margin: 0px auto 10px auto; -} - -div#log-container { - width: 80%; - margin: 0% 10%; -} - -.hidden { - display: none; -} - -.preview { - margin-top: 40px; - width: 70%; -} - -#nbviewer-preview > iframe { - width: 100%; - height: 80vh; - border: 1px solid #aaa; -} diff --git a/binderhub/static/js/src/loading.js b/binderhub/static/js/components/LoadingIndicator.jsx similarity index 63% rename from binderhub/static/js/src/loading.js rename to binderhub/static/js/components/LoadingIndicator.jsx index 3f0d3beaf..bb1d0a136 100644 --- a/binderhub/static/js/src/loading.js +++ b/binderhub/static/js/components/LoadingIndicator.jsx @@ -1,7 +1,10 @@ +import { useEffect, useState } from "react"; +import "./LoadingIndicator.css"; +import { PROGRESS_STATES } from "./Progress.jsx"; /** * List of help messages we will cycle through randomly in the loading page */ -const helpMessages = [ +const HELP_MESSAGES = [ 'New to Binder? Check out the Binder Documentation for more information.', 'You can learn more about building your own Binder repositories in the Binder community documentation.', 'We use the repo2docker tool to automatically build the environment in which to run your code.', @@ -18,19 +21,41 @@ const helpMessages = [ ]; /** - * Display a randomly picked help message in the loading page + * @typedef {object} LoadingIndicatorProps + * @prop {PROGRESS_STATES} progressState + * @param {LoadingIndicatorProps} props */ -export function nextHelpText() { - const text = $("div#loader-links p.text-center"); - let msg; - if (text !== null) { - if (!text.hasClass("longLaunch")) { - // Pick a random help message and update - msg = helpMessages[Math.floor(Math.random() * helpMessages.length)]; - } else { - msg = - "Your session is taking longer than usual to start!
Check the log messages below to see what is happening."; - } - text.html(msg); - } +export function LoadingIndicator({ progressState }) { + const [currentMessage, setCurrentMessage] = useState(HELP_MESSAGES[0]); + + useEffect(() => { + const intervalId = setInterval(() => { + const newMessage = + HELP_MESSAGES[Math.floor(Math.random() * HELP_MESSAGES.length)]; + setCurrentMessage(newMessage); + }, 6 * 1000); + + return () => clearInterval(intervalId); + }, []); + + return ( +
+
+ {progressState === PROGRESS_STATES.FAILED ? ( +

+ Launching your Binder failed! See the logs below for more information. +

+ ) : ( + <> +

Launching your Binder...

+
+

+
+ + )} +
+ ); } diff --git a/binderhub/static/js/components/NBViewerIFrame.jsx b/binderhub/static/js/components/NBViewerIFrame.jsx new file mode 100644 index 000000000..cf0c190ad --- /dev/null +++ b/binderhub/static/js/components/NBViewerIFrame.jsx @@ -0,0 +1,52 @@ +import { Spec } from "../spec"; + +/** + * @typedef {object} NBViewerIFrameProps + * @prop {Spec} spec + * @param {NBViewerIFrameProps} props + * @returns + */ +export function NBViewerIFrame({ spec }) { + // We only support GitHub links as preview right now + if (!spec.buildSpec.startsWith("gh/")) { + return; + } + + const [_, org, repo, ref] = spec.buildSpec.split("/"); + + let urlPath = decodeURI(spec.urlPath); + // Handle cases where urlPath starts with a `/` + urlPath = urlPath.replace(/^\//, ""); + let filePath = ""; + if (urlPath.startsWith("doc/tree/")) { + filePath = urlPath.replace(/^doc\/tree\//, ""); + } else if (urlPath.startsWith("tree/")) { + filePath = urlPath.replace(/^tree\//, ""); + } + + let url; + // TODO: The nbviewer url should be configurable + if (filePath) { + url = `https://nbviewer.jupyter.org/github/${org}/${repo}/blob/${ref}/${filePath}`; + } else { + url = `https://nbviewer.jupyter.org/github/${org}/${repo}/tree/${ref}`; + } + + return ( +
+

+ Here is a non-interactive preview on{" "} + + nbviewer + {" "} + while we start a server for you.
+ Your binder will open automatically when it is ready. +

+ +
+ ); +} diff --git a/binderhub/static/js/components/Progress.jsx b/binderhub/static/js/components/Progress.jsx new file mode 100644 index 000000000..14a36a75c --- /dev/null +++ b/binderhub/static/js/components/Progress.jsx @@ -0,0 +1,82 @@ +/** + * @enum {string} + */ +export const PROGRESS_STATES = { + WAITING: "Waiting", + BUILDING: "Building", + PUSHING: "Pushing", + LAUNCHING: "Launching", + SUCCESS: "Success", + FAILED: "Failed", +}; + +const progressDisplay = {}; +(progressDisplay[PROGRESS_STATES.WAITING] = { + precursors: [], + widthPercent: "10", + label: "Waiting", + className: "text-bg-danger", +}), + (progressDisplay[PROGRESS_STATES.BUILDING] = { + precursors: [PROGRESS_STATES.WAITING], + widthPercent: "50", + label: "Building", + className: "text-bg-warning", + }); + +progressDisplay[PROGRESS_STATES.PUSHING] = { + precursors: [PROGRESS_STATES.WAITING, PROGRESS_STATES.BUILDING], + widthPercent: "30", + label: "Pushing", + className: "text-bg-info", +}; + +progressDisplay[PROGRESS_STATES.LAUNCHING] = { + precursors: [ + PROGRESS_STATES.WAITING, + PROGRESS_STATES.BUILDING, + PROGRESS_STATES.PUSHING, + ], + widthPercent: "10", + label: "Launching", + className: "text-bg-success", +}; + +progressDisplay[PROGRESS_STATES.SUCCESS] = + progressDisplay[PROGRESS_STATES.LAUNCHING]; + +progressDisplay[PROGRESS_STATES.FAILED] = { + precursors: [], + widthPercent: "100", + label: "Failed", + className: "text-bg-danger", +}; + +/** + * @typedef {object} ProgressProps + * @prop {PROGRESS_STATES} progressState + * @param {ProgressProps} props + */ +export function Progress({ progressState }) { + return ( +
+ {progressState === null + ? "" + : progressDisplay[progressState].precursors + .concat([progressState]) + .map((s) => ( +
+ {progressDisplay[s].label} +
+ ))} +
+ ); +} diff --git a/binderhub/static/js/index.d.ts b/binderhub/static/js/index.d.ts new file mode 100644 index 000000000..427887634 --- /dev/null +++ b/binderhub/static/js/index.d.ts @@ -0,0 +1,2 @@ +// Tell typescript to be quiet about .ico files we use for favicons +declare module "*.ico"; diff --git a/binderhub/static/js/index.js b/binderhub/static/js/index.js deleted file mode 100644 index dbd9639ab..000000000 --- a/binderhub/static/js/index.js +++ /dev/null @@ -1,253 +0,0 @@ -/* If this file gets over 200 lines of code long (not counting docs / comments), start using a framework - */ -import ClipboardJS from "clipboard"; - -import { BinderRepository } from "@jupyterhub/binderhub-client"; -import { updatePathText } from "./src/path"; -import { nextHelpText } from "./src/loading"; -import { updateFavicon } from "./src/favicon"; - -import "xterm/css/xterm.css"; - -// Include just the bootstrap components we use -import "bootstrap/js/dropdown"; -import "bootstrap/dist/css/bootstrap.min.css"; -import "bootstrap/dist/css/bootstrap-theme.min.css"; - -import "../index.css"; -import { setUpLog } from "./src/log"; -import { updateUrls } from "./src/urls"; -import { getBuildFormValues } from "./src/form"; -import { updateRepoText } from "./src/repo"; - -/** - * @type {URL} - * Base URL of this binderhub installation. - * - * Guaranteed to have a leading & trailing slash by the binderhub python configuration. - */ -const BASE_URL = new URL( - document.getElementById("base-url").dataset.url, - document.location.origin, -); - -const badge_base_url = document.getElementById("badge-base-url").dataset.url; -/** - * @type {URL} - * Base URL to use for both badge images as well as launch links. - * - * If not explicitly set, will default to BASE_URL. Primarily set up different than BASE_URL - * when used as part of a federation - */ -const BADGE_BASE_URL = badge_base_url - ? new URL(badge_base_url, document.location.origin) - : BASE_URL; - -async function build(providerSpec, log, fitAddon, path, pathType) { - updateFavicon(new URL("favicon_building.ico", BASE_URL)); - // split provider prefix off of providerSpec - const spec = providerSpec.slice(providerSpec.indexOf("/") + 1); - // Update the text of the loading page if it exists - if ($("div#loader-text").length > 0) { - $("div#loader-text p.launching").text( - "Starting repository: " + decodeURIComponent(spec), - ); - } - - $("#build-progress .progress-bar").addClass("hidden"); - log.clear(); - - $(".on-build").removeClass("hidden"); - - const buildToken = $("#build-token").data("token"); - const apiToken = $("#api-token").data("token"); - const buildEndpointUrl = new URL("build", BASE_URL); - const image = new BinderRepository(providerSpec, buildEndpointUrl, { - apiToken, - buildToken, - }); - - for await (const data of image.fetch()) { - // Write message to the log terminal if there is a message - if (data.message !== undefined) { - log.writeAndStore(data.message); - fitAddon.fit(); - } else { - console.log(data); - } - - switch (data.phase) { - case "waiting": { - $("#phase-waiting").removeClass("hidden"); - break; - } - case "building": { - $("#phase-building").removeClass("hidden"); - log.show(); - break; - } - case "pushing": { - $("#phase-pushing").removeClass("hidden"); - break; - } - case "failed": { - $("#build-progress .progress-bar").addClass("hidden"); - $("#phase-failed").removeClass("hidden"); - - $("#loader").addClass("paused"); - - // If we fail for any reason, show an error message and logs - updateFavicon(new URL("favicon_fail.ico", BASE_URL)); - log.show(); - if ($("div#loader-text").length > 0) { - $("#loader").addClass("error"); - $("div#loader-text p.launching").html( - "Error loading " + spec + "!
See logs below for details.", - ); - } - image.close(); - break; - } - case "built": { - $("#phase-already-built").removeClass("hidden"); - $("#phase-launching").removeClass("hidden"); - updateFavicon(new URL("favicon_success.ico", BASE_URL)); - break; - } - case "ready": { - image.close(); - // If data.url is an absolute URL, it'll be used. Else, it'll be interpreted - // relative to current page's URL. - const serverUrl = new URL(data.url, window.location.href); - // user server is ready, redirect to there - window.location.href = image.getFullRedirectURL( - serverUrl, - data.token, - path, - pathType, - ); - break; - } - default: { - console.log("Unknown phase in response from server"); - console.log(data); - break; - } - } - } - return image; -} - -function indexMain() { - const [log, fitAddon] = setUpLog(); - - // setup badge dropdown and default values. - updateUrls(BADGE_BASE_URL); - - $("#provider_prefix_sel li").click(function (event) { - event.preventDefault(); - - $("#provider_prefix-selected").text($(this).text()); - $("#provider_prefix").val($(this).attr("value")); - updateRepoText(BASE_URL); - updateUrls(BADGE_BASE_URL); - }); - - $("#url-or-file-btn") - .find("a") - .click(function (evt) { - evt.preventDefault(); - - $("#url-or-file-selected").text($(this).text()); - updatePathText(); - updateUrls(BADGE_BASE_URL); - }); - updatePathText(); - updateRepoText(BASE_URL); - - $("#repository").on("keyup paste change", function () { - updateUrls(BADGE_BASE_URL); - }); - - $("#ref").on("keyup paste change", function () { - updateUrls(BADGE_BASE_URL); - }); - - $("#filepath").on("keyup paste change", function () { - updateUrls(BADGE_BASE_URL); - }); - - $("#toggle-badge-snippet").on("click", function () { - const badgeSnippets = $("#badge-snippets"); - if (badgeSnippets.hasClass("hidden")) { - badgeSnippets.removeClass("hidden"); - $("#badge-snippet-caret").removeClass("glyphicon-triangle-right"); - $("#badge-snippet-caret").addClass("glyphicon-triangle-bottom"); - } else { - badgeSnippets.addClass("hidden"); - $("#badge-snippet-caret").removeClass("glyphicon-triangle-bottom"); - $("#badge-snippet-caret").addClass("glyphicon-triangle-right"); - } - - return false; - }); - - $("#build-form").submit(async function (e) { - e.preventDefault(); - const formValues = getBuildFormValues(); - updateUrls(BADGE_BASE_URL, formValues); - await build( - formValues.providerPrefix + "/" + formValues.repo + "/" + formValues.ref, - log, - fitAddon, - formValues.path, - formValues.pathType, - ); - }); -} - -async function loadingMain(providerSpec) { - const [log, fitAddon] = setUpLog(); - // retrieve (encoded) filepath/urlpath from URL - // URLSearchParams.get returns the decoded value, - // that is good because it is the real value and '/'s will be trimmed in `launch` - const params = new URL(location.href).searchParams; - let pathType, path; - path = params.get("urlpath"); - if (path) { - pathType = "url"; - } else { - path = params.get("labpath"); - if (path) { - pathType = "lab"; - } else { - path = params.get("filepath"); - if (path) { - pathType = "file"; - } - } - } - await build(providerSpec, log, fitAddon, path, pathType); - - // Looping through help text every few seconds - const launchMessageInterval = 6 * 1000; - setInterval(nextHelpText, launchMessageInterval); - - // If we have a long launch, add a class so we display a long launch msg - const launchTimeout = 120 * 1000; - setTimeout(() => { - $("div#loader-links p.text-center").addClass("longLaunch"); - nextHelpText(); - }, launchTimeout); - - return false; -} - -// export entrypoints -window.loadingMain = loadingMain; -window.indexMain = indexMain; - -// Load the clipboard after the page loads so it can find the buttons it needs -window.onload = function () { - new ClipboardJS(".clipboard"); -}; diff --git a/binderhub/static/js/index.jsx b/binderhub/static/js/index.jsx new file mode 100644 index 000000000..aa962b9c2 --- /dev/null +++ b/binderhub/static/js/index.jsx @@ -0,0 +1,5 @@ +import { createRoot } from "react-dom/client"; +import { App } from "./App"; + +const root = createRoot(document.getElementById("root")); +root.render(); diff --git a/binderhub/static/js/index.scss b/binderhub/static/js/index.scss new file mode 100644 index 000000000..2149d5147 --- /dev/null +++ b/binderhub/static/js/index.scss @@ -0,0 +1,63 @@ +@import "bootstrap/scss/functions"; + +// Theming overrides +$primary: rgb(223, 132, 41); +$custom-colors: ( + "primary": $primary, +); + +// Import these after theming overrides so they pick up these variables +@import "bootstrap/scss/variables"; +@import "bootstrap/scss/variables-dark"; +@import "bootstrap/scss/maps"; +@import "bootstrap/scss/mixins"; +@import "bootstrap/scss/utilities"; +@import "bootstrap/scss/root"; +@import "bootstrap/scss/reboot"; + +// Merge the maps +$theme-colors: map-merge($theme-colors, $custom-colors); + +@import "bootstrap/scss/bootstrap"; + +// Font choices + +body { + font-family: "Clear Sans"; + font-weight: 300; +} + +form { + font-weight: 400; +} + +.btn-primary, +.btn-primary:hover { + color: $white; +} + +a { + text-decoration: none; +} + +// Could not replicate this style with just utility classes unfortunately +.circle-point { + border: 5px solid; + padding: 2px 9px; + border-radius: 50%; + font-weight: bold; +} + +.form-label { + font-size: 1rem; +} + +.jumbotron { + margin-bottom: 100px; +} + +.bg-custom-dark { + background-color: rgb(235, 236, 237); +} + +@import "bootstrap-icons/font/bootstrap-icons.css"; diff --git a/binderhub/static/js/pages/AboutPage.jsx b/binderhub/static/js/pages/AboutPage.jsx new file mode 100644 index 000000000..9f1f37a65 --- /dev/null +++ b/binderhub/static/js/pages/AboutPage.jsx @@ -0,0 +1,17 @@ +export function AboutPage({ aboutMessage, binderVersion }) { + return ( +
+

BinderHub

+
+

+ This website is powered by{" "} + BinderHub v + {binderVersion} +

+ {aboutMessage && ( +

+ )} +
+
+ ); +} diff --git a/binderhub/static/js/pages/HomePage.jsx b/binderhub/static/js/pages/HomePage.jsx new file mode 100644 index 000000000..b5601fee5 --- /dev/null +++ b/binderhub/static/js/pages/HomePage.jsx @@ -0,0 +1,90 @@ +import { LinkGenerator } from "../components/LinkGenerator.jsx"; +import { BuilderLauncher } from "../components/BuilderLauncher.jsx"; +import { HowItWorks } from "../components/HowItWorks.jsx"; +import { useEffect, useState } from "react"; +import { FaviconUpdater } from "../components/FaviconUpdater.jsx"; +import { Spec, LaunchSpec } from "../spec.js"; + +/** + * @typedef {object} HomePageProps + * @prop {import("../App.jsx").Provider[]} providers + * @prop {URL} publicBaseUrl + * @prop {URL} baseUrl + * @param {HomePageProps} props + */ +export function HomePage({ providers, publicBaseUrl, baseUrl }) { + const defaultProvider = providers[0]; + const [selectedProvider, setSelectedProvider] = useState(defaultProvider); + const [repo, setRepo] = useState(""); + const [ref, setRef] = useState(""); + const [urlPath, setUrlPath] = useState(""); + const [isLaunching, setIsLaunching] = useState(false); + const [spec, setSpec] = useState(""); + const [progressState, setProgressState] = useState(null); + + useEffect(() => { + const encodedRepo = selectedProvider.repo.urlEncode + ? encodeURIComponent(repo) + : repo; + let actualRef = ""; + if (selectedProvider.ref.enabled) { + actualRef = ref !== "" ? ref : selectedProvider.ref.default; + } + setSpec( + new Spec( + `${selectedProvider.id}/${encodedRepo}/${actualRef}`, + new LaunchSpec(urlPath), + ), + ); + }, [selectedProvider, repo, ref, urlPath]); + + return ( + <> +
+
Turn a Git repo into a collection of interactive notebooks
+

+ Have a repository full of Jupyter notebooks? With Binder, open those + notebooks in an executable environment, making your code immediately + reproducible by anyone, anywhere. +

+

+ New to Binder? Get started with a{" "} + + Zero-to-Binder tutorial + {" "} + in Julia, Python, or R. +

+
+ + + + + + ); +} diff --git a/binderhub/static/js/pages/HomePage.test.jsx b/binderhub/static/js/pages/HomePage.test.jsx new file mode 100644 index 000000000..add1b8505 --- /dev/null +++ b/binderhub/static/js/pages/HomePage.test.jsx @@ -0,0 +1,171 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { HomePage } from "./HomePage"; + +test("updates launch URL with git repo", async () => { + const user = userEvent.setup(); + + render( + , + ); + + expect( + screen.getByText( + /Fill in the fields to see a URL for sharing your Binder./, + ), + ).toBeInTheDocument(); + const repositoryField = screen.getByRole("textbox", { + name: "Enter repository URL", + }); + await user.type(repositoryField, "org/repo"); + expect( + screen.getByText("http://local.com/v2/gh/org/repo/HEAD"), + ).toBeInTheDocument(); +}); + +test("updates launch URL with git ref", async () => { + const user = userEvent.setup(); + + render( + , + ); + + expect( + screen.getByText( + /Fill in the fields to see a URL for sharing your Binder./, + ), + ).toBeInTheDocument(); + const repositoryField = screen.getByRole("textbox", { + name: "Enter repository URL", + }); + await user.type(repositoryField, "org/repo"); + expect( + screen.getByText("http://local.com/v2/gh/org/repo/HEAD"), + ).toBeInTheDocument(); + + const refField = screen.getByRole("textbox", { + name: "Git ref (branch, tag, or commit)", + }); + await user.type(refField, "main"); + expect( + screen.getByText("http://local.com/v2/gh/org/repo/main"), + ).toBeInTheDocument(); +}); + +test("updates launch URL with file", async () => { + const user = userEvent.setup(); + + render( + , + ); + + expect( + screen.getByText( + /Fill in the fields to see a URL for sharing your Binder./, + ), + ).toBeInTheDocument(); + const repositoryField = screen.getByRole("textbox", { + name: "Enter repository URL", + }); + await user.type(repositoryField, "org/repo"); + expect( + screen.getByText("http://local.com/v2/gh/org/repo/HEAD"), + ).toBeInTheDocument(); + + const fileField = screen.getByRole("textbox", { + name: "File to open (in JupyterLab)", + }); + await user.type(fileField, "test.py"); + expect( + screen.getByText( + "http://local.com/v2/gh/org/repo/HEAD?urlpath=%2Fdoc%2Ftree%2Ftest.py", + ), + ).toBeInTheDocument(); +}); + +test("updates launch URL with URL", async () => { + const user = userEvent.setup(); + + render( + , + ); + + expect( + screen.getByText( + /Fill in the fields to see a URL for sharing your Binder./, + ), + ).toBeInTheDocument(); + const repositoryField = screen.getByRole("textbox", { + name: "Enter repository URL", + }); + await user.type(repositoryField, "org/repo"); + expect( + screen.getByText("http://local.com/v2/gh/org/repo/HEAD"), + ).toBeInTheDocument(); + + // TODO: There are two buttons name "File" in the DOM, so we need queryAllByRole here. + // Ideally, these buttons have distinct labels + await user.click(screen.queryAllByRole("button", { name: "File" })[0]); + await user.click(screen.getByText("URL")); + + const fileField = screen.getByRole("textbox", { name: "URL to open" }); + await user.type(fileField, "http://example.com"); + expect( + screen.getByText( + "http://local.com/v2/gh/org/repo/HEAD?urlpath=http%3A%2F%2Fexample.com", + ), + ).toBeInTheDocument(); +}); + +test("change source type", async () => { + const user = userEvent.setup(); + + render( + , + ); + + expect( + screen.getByText( + /Fill in the fields to see a URL for sharing your Binder./, + ), + ).toBeInTheDocument(); + user.click(screen.getByRole("button", { name: "GitHub" })); + + const zenodoButton = screen.getByRole("button", { name: "Zenodo DOI" }); + await expect(zenodoButton).toBeVisible(); + + await user.click(zenodoButton); + const refField = screen.getByRole("textbox", { + name: "Git ref (branch, tag, or commit)", + }); + expect(refField).toBeDisabled(); + + const repositoryField = screen.getByRole("textbox", { + name: "Enter repository URL", + }); + await user.type(repositoryField, "10.5282/zenodo.3242075"); + expect( + screen.getByText("http://local.com/v2/zenodo/10.5282/zenodo.3242075/"), + ).toBeInTheDocument(); +}); diff --git a/binderhub/static/js/pages/LoadingPage.jsx b/binderhub/static/js/pages/LoadingPage.jsx new file mode 100644 index 000000000..fb454e3b3 --- /dev/null +++ b/binderhub/static/js/pages/LoadingPage.jsx @@ -0,0 +1,68 @@ +import { useEffect, useState } from "react"; +import { BuilderLauncher } from "../components/BuilderLauncher.jsx"; +import { useParams, useSearch } from "wouter"; +import { NBViewerIFrame } from "../components/NBViewerIFrame.jsx"; +import { LoadingIndicator } from "../components/LoadingIndicator.jsx"; +import { FaviconUpdater } from "../components/FaviconUpdater.jsx"; +import { LaunchSpec, Spec } from "../spec.js"; +import { ErrorPage } from "../components/ErrorPage.jsx"; + +/** + * @typedef {object} LoadingPageProps + * @prop {URL} baseUrl + * @prop {string?} buildToken + * @prop {import("../App.jsx").Provider} provider + * @param {LoadingPageProps} props + * @returns + */ +export function LoadingPage({ baseUrl, buildToken, provider }) { + const [progressState, setProgressState] = useState(null); + + const params = useParams(); + const partialSpec = params["0"]; + const buildSpec = `${provider.id}/${partialSpec}`; + + const searchParams = new URLSearchParams(useSearch()); + + const [isLaunching, setIsLaunching] = useState(false); + + const spec = new Spec(buildSpec, LaunchSpec.fromSearchParams(searchParams)); + const formatError = partialSpec.match(provider.spec.validateRegex) === null; + + useEffect(() => { + if (!formatError) { + // Start launching after the DOM has fully loaded + setTimeout(() => setIsLaunching(true), 1); + } + }, []); + + if (formatError) { + return ( + + ); + } + + return ( + <> + + + + + + + ); +} diff --git a/binderhub/static/js/pages/NotFoundPage.jsx b/binderhub/static/js/pages/NotFoundPage.jsx new file mode 100644 index 000000000..9628c41f8 --- /dev/null +++ b/binderhub/static/js/pages/NotFoundPage.jsx @@ -0,0 +1,22 @@ +export function NotFoundPage() { + return ( + <> +
+

404: Not Found

+
+
+
+

+ questions? +
+ join the{" "} + discussion, + read the{" "} + docs, see + the code +

+
+
+ + ); +} diff --git a/binderhub/static/js/spec.js b/binderhub/static/js/spec.js new file mode 100644 index 000000000..28520838e --- /dev/null +++ b/binderhub/static/js/spec.js @@ -0,0 +1,72 @@ +export class LaunchSpec { + /** + * + * @param {string} urlPath Path inside the Jupyter server to redirect the user to after launching + */ + constructor(urlPath) { + this.urlPath = urlPath; + // Ensure no leading / here + this.urlPath = this.urlPath.replace(/^\/*/, ""); + } + + /** + * Return a URL to redirect user to for use with this launch specification + * + * @param {URL} serverUrl Fully qualified URL to a running Jupyter Server + * @param {string} token Authentication token to pass to the Jupyter Server + * + * @returns {URL} + */ + getJupyterServerRedirectUrl(serverUrl, token) { + const redirectUrl = new URL(this.urlPath, serverUrl); + redirectUrl.searchParams.append("token", token); + return redirectUrl; + } + + /** + * Create a LaunchSpec from given query parameters in the URL + * + * Handles backwards compatible parameters as needed. + * + * @param {URLSearchParams} searchParams + * + * @returns {LaunchSpec} + */ + static fromSearchParams(searchParams) { + let urlPath = searchParams.get("urlpath"); + if (urlPath === null) { + urlPath = ""; + } + + // Handle legacy parameters for opening URLs after launching + // labpath and filepath + if (searchParams.has("labpath")) { + // Trim trailing / on file paths + const filePath = searchParams.get("labpath").replace(/(\/$)/g, ""); + urlPath = `doc/tree/${encodeURI(filePath)}`; + } else if (searchParams.has("filepath")) { + // Trim trailing / on file paths + const filePath = searchParams.get("filepath").replace(/(\/$)/g, ""); + urlPath = `tree/${encodeURI(filePath)}`; + } + + return new LaunchSpec(urlPath); + } +} + +/** + * A full binder specification + * + * Includes a *build* specification (determining what is built), and a + * *launch* specification (determining what is launched). + */ +export class Spec { + /** + * @param {string} buildSpec Build specification, passed directly to binderhub API + * @param {LaunchSpec} launchSpec Launch specification, determining what is launched + */ + constructor(buildSpec, launchSpec) { + this.buildSpec = buildSpec; + this.launchSpec = launchSpec; + } +} diff --git a/binderhub/static/js/src/favicon.js b/binderhub/static/js/src/favicon.js deleted file mode 100644 index ae6592cfa..000000000 --- a/binderhub/static/js/src/favicon.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Dynamically set current page's favicon. - * - * @param {URL} href Path to Favicon to use - */ -function updateFavicon(href) { - let link = document.querySelector("link[rel*='icon']"); - if (!link) { - link = document.createElement("link"); - document.getElementsByTagName("head")[0].appendChild(link); - } - link.type = "image/x-icon"; - link.rel = "shortcut icon"; - link.href = href; -} - -export { updateFavicon }; diff --git a/binderhub/static/js/src/favicon.test.js b/binderhub/static/js/src/favicon.test.js deleted file mode 100644 index c73cab495..000000000 --- a/binderhub/static/js/src/favicon.test.js +++ /dev/null @@ -1,35 +0,0 @@ -import { updateFavicon } from "./favicon"; - -afterEach(() => { - // Clear out HEAD after each test run, so our DOM is clean. - // Jest does *not* clear out the DOM between test runs on the same file! - document.querySelector("head").innerHTML = ""; -}); - -test("Setting favicon when there is none works", () => { - expect(document.querySelector("link[rel*='icon']")).toBeNull(); - - updateFavicon("https://example.com/somefile.png"); - - expect(document.querySelector("link[rel*='icon']").href).toBe( - "https://example.com/somefile.png", - ); -}); - -test("Setting favicon multiple times works without leaking link tags", () => { - expect(document.querySelector("link[rel*='icon']")).toBeNull(); - - updateFavicon("https://example.com/somefile.png"); - - expect(document.querySelector("link[rel*='icon']").href).toBe( - "https://example.com/somefile.png", - ); - expect(document.querySelectorAll("link[rel*='icon']").length).toBe(1); - - updateFavicon("https://example.com/some-other-file.png"); - - expect(document.querySelector("link[rel*='icon']").href).toBe( - "https://example.com/some-other-file.png", - ); - expect(document.querySelectorAll("link[rel*='icon']").length).toBe(1); -}); diff --git a/binderhub/static/js/src/form.js b/binderhub/static/js/src/form.js deleted file mode 100644 index 1bf70e6f1..000000000 --- a/binderhub/static/js/src/form.js +++ /dev/null @@ -1,47 +0,0 @@ -import { getPathType } from "./path"; - -/** - * Parse current values in form and return them with appropriate URL encoding - * @typedef FormValues - * @prop {string} providerPrefix prefix denoting what provider was selected - * @prop {string} repo repo to build - * @prop {[string]} ref optional ref in this repo to build - * @prop {string} path Path to launch after this repo has been built - * @prop {string} pathType Type of thing to open path with (raw url, notebook file, lab, etc) - * @returns {} - */ -export function getBuildFormValues() { - const providerPrefix = $("#provider_prefix").val().trim(); - let repo = $("#repository").val().trim(); - if (providerPrefix !== "git") { - repo = repo.replace(/^(https?:\/\/)?gist.github.com\//, ""); - repo = repo.replace(/^(https?:\/\/)?github.com\//, ""); - repo = repo.replace(/^(https?:\/\/)?gitlab.com\//, ""); - } - // trim trailing or leading '/' on repo - repo = repo.replace(/(^\/)|(\/?$)/g, ""); - // git providers encode the URL of the git repository as the repo - // argument. - if (repo.includes("://") || providerPrefix === "gl") { - repo = encodeURIComponent(repo); - } - - let ref = $("#ref").val().trim() || $("#ref").attr("placeholder"); - if ( - providerPrefix === "zenodo" || - providerPrefix === "figshare" || - providerPrefix === "dataverse" || - providerPrefix === "hydroshare" || - providerPrefix === "ckan" - ) { - ref = ""; - } - const path = $("#filepath").val().trim(); - return { - providerPrefix: providerPrefix, - repo: repo, - ref: ref, - path: path, - pathType: getPathType(), - }; -} diff --git a/binderhub/static/js/src/log.js b/binderhub/static/js/src/log.js deleted file mode 100644 index f37ed5078..000000000 --- a/binderhub/static/js/src/log.js +++ /dev/null @@ -1,75 +0,0 @@ -import { Terminal } from "xterm"; -import { FitAddon } from "xterm-addon-fit"; - -/** - * Set up a read only xterm.js based terminal, augmented with some additional methods, to display log lines - * - * @returns Array of the xterm.js instance to write to, and a FitAddon instance to use for resizing the xterm appropriately - */ -export function setUpLog() { - const log = new Terminal({ - convertEol: true, - disableStdin: true, - }); - - const fitAddon = new FitAddon(); - log.loadAddon(fitAddon); - const logMessages = []; - - log.open(document.getElementById("log"), false); - fitAddon.fit(); - - $(window).resize(function () { - fitAddon.fit(); - }); - - const $panelBody = $("div.panel-body"); - - /** - * Show the log terminal - */ - log.show = function () { - $("#toggle-logs button.toggle").text("hide"); - $panelBody.removeClass("hidden"); - }; - - /** - * Hide the log terminal - */ - log.hide = function () { - $("#toggle-logs button.toggle").text("show"); - $panelBody.addClass("hidden"); - }; - - /** - * Toggle visibility of the log terminal - */ - log.toggle = function () { - if ($panelBody.hasClass("hidden")) { - log.show(); - } else { - log.hide(); - } - }; - - $("#view-raw-logs").on("click", function (ev) { - const blob = new Blob([logMessages.join("")], { type: "text/plain" }); - this.href = window.URL.createObjectURL(blob); - // Prevent the toggle action from firing - ev.stopPropagation(); - }); - - $("#toggle-logs").click(log.toggle); - - /** - * Write message to xterm and store it in the download buffer - * - * @param {string} msg Message to write to the terminal & add to message buffer - */ - log.writeAndStore = function (msg) { - logMessages.push(msg); - log.write(msg); - }; - - return [log, fitAddon]; -} diff --git a/binderhub/static/js/src/path.js b/binderhub/static/js/src/path.js deleted file mode 100644 index 618ed9561..000000000 --- a/binderhub/static/js/src/path.js +++ /dev/null @@ -1,27 +0,0 @@ -export function getPathType() { - // return path type. 'file' or 'url' - const element = document.getElementById("url-or-file-selected"); - let pathType = element.innerText.trim().toLowerCase(); - if (pathType === "file") { - // selecting a 'file' in the form opens with jupyterlab - // avoids backward-incompatibility with old `filepath` urls, - // which still open old UI - pathType = "lab"; - } - return pathType; -} - -export function updatePathText() { - const pathType = getPathType(); - let text; - if (pathType === "file" || pathType === "lab") { - text = "Path to a notebook file (optional)"; - } else { - text = "URL to open (optional)"; - } - const filePathElement = document.getElementById("filepath"); - filePathElement.setAttribute("placeholder", text); - - const filePathElementLabel = document.querySelector("label[for=filepath]"); - filePathElementLabel.innerText = text; -} diff --git a/binderhub/static/js/src/repo.js b/binderhub/static/js/src/repo.js deleted file mode 100644 index 17a3524b2..000000000 --- a/binderhub/static/js/src/repo.js +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Dict holding cached values of API request to _config endpoint - */ -let configDict = {}; - -function setLabels() { - const provider = $("#provider_prefix").val(); - const text = configDict[provider]["text"]; - const tagText = configDict[provider]["tag_text"]; - const refPropDisabled = configDict[provider]["ref_prop_disabled"]; - const labelPropDisabled = configDict[provider]["label_prop_disabled"]; - const placeholder = "HEAD"; - - $("#ref").attr("placeholder", placeholder).prop("disabled", refPropDisabled); - $("label[for=ref]").text(tagText).prop("disabled", labelPropDisabled); - $("#repository").attr("placeholder", text); - $("label[for=repository]").text(text); -} - -/** - * Update labels for various inputboxes based on user selection of repo provider - * - * @param {URL} baseUrl Base URL to use for constructing path to _config endpoint - */ -export function updateRepoText(baseUrl) { - if (Object.keys(configDict).length === 0) { - const xsrf = $("#xsrf-token").data("token"); - const apiToken = $("#api-token").data("token"); - const configUrl = new URL("_config", baseUrl); - const headers = {}; - if (apiToken && apiToken.length > 0) { - headers["Authorization"] = `Bearer ${apiToken}`; - } else if (xsrf && xsrf.length > 0) { - headers["X-Xsrftoken"] = xsrf; - } - fetch(configUrl, { headers }).then((resp) => { - resp.json().then((data) => { - configDict = data; - setLabels(); - }); - }); - } else { - setLabels(); - } -} diff --git a/binderhub/static/js/src/urls.js b/binderhub/static/js/src/urls.js deleted file mode 100644 index 698ccfdb6..000000000 --- a/binderhub/static/js/src/urls.js +++ /dev/null @@ -1,39 +0,0 @@ -import { getBuildFormValues } from "./form"; -import { - makeShareableBinderURL, - makeBadgeMarkup, -} from "@jupyterhub/binderhub-client"; - -/** - * Update the shareable URL and badge snippets in the UI based on values user has entered in the form - */ -export function updateUrls(publicBaseUrl, formValues) { - if (typeof formValues === "undefined") { - formValues = getBuildFormValues(); - } - if (formValues.repo) { - const url = makeShareableBinderURL( - publicBaseUrl, - formValues.providerPrefix, - formValues.repo, - formValues.ref, - formValues.path, - formValues.pathType, - ); - - // update URLs and links (badges, etc.) - $("#badge-link").attr("href", url); - $("#basic-url-snippet").text(url); - $("#markdown-badge-snippet").text( - makeBadgeMarkup(publicBaseUrl, url, "markdown"), - ); - $("#rst-badge-snippet").text(makeBadgeMarkup(publicBaseUrl, url, "rst")); - } else { - ["#basic-url-snippet", "#markdown-badge-snippet", "#rst-badge-snippet"].map( - function (item) { - const el = $(item); - el.text(el.attr("data-default")); - }, - ); - } -} diff --git a/binderhub/templates/about.html b/binderhub/templates/about.html deleted file mode 100644 index b18f14b70..000000000 --- a/binderhub/templates/about.html +++ /dev/null @@ -1,27 +0,0 @@ -{% extends "page.html" %} - -{% block main %} -
-
-
- {% block header %} - - {% endblock header %} -
-
-
-{% endblock main %} - -{% block footer %} -{% endblock footer %} diff --git a/binderhub/templates/error.html b/binderhub/templates/error.html deleted file mode 100644 index 093d4e3e6..000000000 --- a/binderhub/templates/error.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends "page.html" %} - -{% block meta_social %} -{% endblock meta_social %} - -{% block main %} -
-

- {{status_code}}: {{status_message}} -

- -

- {{message}}. Note: Some errors disappear by refreshing the page. -

-
-{% endblock main %} diff --git a/binderhub/templates/index.html b/binderhub/templates/index.html deleted file mode 100644 index cc55b89a7..000000000 --- a/binderhub/templates/index.html +++ /dev/null @@ -1,206 +0,0 @@ -{% extends "page.html" %} - -{% block head %} - - - - - -{{ super() }} -{% endblock head %} - -{% block main %} -
-
-
- {% block header %} - -
-

New to Binder? Get started with a Zero-to-Binder tutorial in Julia, Python, or R.

-
- {% endblock header %} - - {% block form %} -
-

Build and launch a repository

- -
- -
-
- - -
- -
-
-
-
- - -
-
- -
- -
- - -
-
-
- -
-
- -
-
-
- - -
- -
-
Fill in the fields to see a URL for sharing your Binder.
- Copy to clipboard -
-
- -
- - -
- - - - -
- {% endblock form %} -
-
- {% block how_it_works %} -
-

How it works

- -
-
- 1 -
-
- Enter your repository information
Provide in the above form a URL or a GitHub repository that contains Jupyter notebooks, as well as a branch, tag, or commit hash. Launch will build your Binder repository. If you specify a path to a notebook file, the notebook will be opened in your browser after building. -
-
- -
-
- 2 -
-
- We build a Docker image of your repository
Binder will search for a dependency file, such as requirements.txt or environment.yml, in the repository's root directory (more details on more complex dependencies in documentation). The dependency files will be used to build a Docker image. If an image has already been built for the given repository, it will not be rebuilt. If a new commit has been made, the image will automatically be rebuilt. -
-
- -
-
- 3 -
-
- Interact with your notebooks in a live environment!
A JupyterHub server will host your repository's contents. We offer you a reusable link and badge to your live repository that you can easily share with others. -
-
-
- {% endblock how_it_works %} -
-{% endblock main %} - -{% block footer %} -{{ super () }} - -{% endblock footer %} diff --git a/binderhub/templates/loading.html b/binderhub/templates/loading.html deleted file mode 100644 index 4fe8af1b0..000000000 --- a/binderhub/templates/loading.html +++ /dev/null @@ -1,68 +0,0 @@ -{% extends "page.html" %} - -{% block meta_social %} - - - - - - - -{% endblock meta_social %} - -{% block head %} - - - - - -{{ super() }} - - -{% endblock head %} - -{% block main %} -
-
-

Launching your Binder...

-
- - - - -{% block preview %} -{% if nbviewer_url %} -
-

-Here's a non-interactive preview on -nbviewer -while we start a server for you. -Your binder will open automatically when it is ready. -

-
- -
-
-{% endif %} -{% endblock preview %} - -{% endblock main %} - -{% block footer %} - -{% endblock footer %} diff --git a/binderhub/templates/page.html b/binderhub/templates/page.html index 5b62e83f9..6b6780d27 100644 --- a/binderhub/templates/page.html +++ b/binderhub/templates/page.html @@ -5,76 +5,33 @@ {% block title %}Binder{% endblock %} {% block meta_social %} {# Social media previews #} - - + + {% endblock meta_social %} - {% block head %} - {% endblock head %} + + - {% block body %} - - {% if banner %} - - {% endif %} - - {% block logo %} -
-
-
- -
-
-
- {% endblock logo %} - - {% block main %} - {% endblock main %} +
+ - {% block footer %} -
-
-

questions?
join the discussion, read the docs, see the code

-
-
- {% endblock footer %} + - {% if google_analytics_code %} - +{% endfor %} +{% endif %} - ga('create', '{{ google_analytics_code }}', '{{ google_analytics_domain }}', - {'storage': 'none'}); - ga('set', 'anonymizeIp', true); - ga('send', 'pageview'); - } - - {% endif %} - {% if extra_footer_scripts %} - {% for script in extra_footer_scripts|dictsort %} - - {% endfor %} - {% endif %} - {% endblock body %} - diff --git a/binderhub/tests/test_main.py b/binderhub/tests/test_main.py index 1201e3f41..5eb18800c 100644 --- a/binderhub/tests/test_main.py +++ b/binderhub/tests/test_main.py @@ -1,5 +1,6 @@ """Test main handlers""" +import json import time from urllib.parse import quote @@ -10,15 +11,6 @@ from .utils import async_requests -@pytest.mark.remote -@pytest.mark.helm -async def test_custom_template(app): - """Check that our custom template config is applied via the helm chart""" - r = await async_requests.get(app.url) - assert r.status_code == 200 - assert "test-template" in r.text - - @pytest.mark.parametrize( "origin,host,expected_origin", [ @@ -44,11 +36,17 @@ async def test_build_token_origin(app, origin, host, expected_origin): r = await async_requests.get(app.url + uri, headers=headers) soup = BeautifulSoup(r.text, "html5lib") - assert soup.find(id="build-token") - token_element = soup.find(id="build-token") - assert token_element - assert "data-token" in token_element.attrs - build_token = token_element["data-token"] + script_tag = soup.select_one("head > script") + page_config_str = ( + script_tag.string.strip().removeprefix("window.pageConfig = ").removesuffix(";") + ) + print(page_config_str) + page_config = json.loads(page_config_str) + print(page_config) + + assert "buildToken" in page_config + + build_token = page_config["buildToken"] payload = jwt.decode( build_token, audience=provider_spec, diff --git a/conftest.py b/conftest.py index 92718736b..78d7788fb 100644 --- a/conftest.py +++ b/conftest.py @@ -4,6 +4,8 @@ not in binderhub/tests/conftest.py """ +import nest_asyncio + def pytest_addoption(parser): parser.addoption( @@ -12,3 +14,7 @@ def pytest_addoption(parser): default=False, help="Run tests marked with pytest.mark.helm", ) + + +def pytest_configure(): + nest_asyncio.apply() diff --git a/docs/source/customizing.rst b/docs/source/customizing.rst index c660ede33..f96dc62ec 100644 --- a/docs/source/customizing.rst +++ b/docs/source/customizing.rst @@ -51,100 +51,6 @@ HTML to the page by setting the ``c.BinderHub.about_message`` configuration option to the raw HTML you would like to add. You can use this to display contact information or other details about your deployment. -Template customization ----------------------- - -BinderHub uses `Jinja `_ template engine and -it is possible to customize templates in a BinderHub deployment. -Here it is explained by a minimal example which shows how to use a custom logo. - -Before configuring BinderHub to use custom templates and static files, -you have to provide these files to the binder pod where the application runs. -One way to do this using `Init Containers -`_ and a Git repo. - -Firstly assume that you have a Git repo ``binderhub_custom_files`` which holds your custom files:: - - binderhub_custom_files/ - ├── static - │   └── custom_logo.svg - └── templates - └── page.html - -where ``page.html`` extends the `base page.html -`_ and -updates only the source url of the logo in order to use your custom logo:: - - {% extends "templates/page.html" %} - - {% block logo_image %}"{{ EXTRA_STATIC_URL_PREFIX }}custom_logo.svg"{% endblock logo_image %} - -.. note:: - - If you want to extend `any other base template - `_, - you have to include ``{% extends "templates/.html" %}`` - in the beginning of your custom template. - It is also possible to have completely new template instead of extending the base one. - Then BinderHub will ignore the base one. - -Now you can use ``Init Containers`` to clone that Git repo into a volume (``custom-templates``) -which is mounted to both init container and binder container. -To do that add the following into your ``config.yaml``:: - - initContainers: - - name: git-clone-templates - image: alpine/git - args: - - clone - - --single-branch - - --branch=main - - --depth=1 - - -- - - - - /etc/binderhub/custom - securityContext: - runAsUser: 0 - volumeMounts: - - name: custom-templates - mountPath: /etc/binderhub/custom - extraVolumes: - - name: custom-templates - emptyDir: {} - extraVolumeMounts: - - name: custom-templates - mountPath: /etc/binderhub/custom - -.. note:: - - You have to replace ```` with the url of the public repo (``binderhub_custom_files``) - where you have your templates and static files. - -The final thing you have to do is to configure BinderHub, -so it knows where to look for custom templates and static files (where the volume is mounted). -To do that update your ``config.yaml`` by the following:: - - config: - BinderHub: - template_path: /etc/binderhub/custom/templates - extra_static_path: /etc/binderhub/custom/static - extra_static_url_prefix: /extra_static/ - template_variables: - EXTRA_STATIC_URL_PREFIX: "/extra_static/" - -.. warning:: - - You have to set the ``extra_static_url_prefix`` different than ``/static/`` - which is the default static url prefix of BinderHub. - Otherwise default one overrides it and BinderHub only uses default static files. - -.. note:: - - In this example a custom template variable (``EXTRA_STATIC_URL_PREFIX``) - to hold the value of ``extra_static_url_prefix`` is also defined, - which was used in custom ``page.html``. - This is good to do specially if you have many custom templates and static files. - .. _repo-specific-config: Custom configuration for specific repositories diff --git a/integration-tests/test_ui.py b/integration-tests/test_ui.py index 2e4e4f87e..4b0215098 100644 --- a/integration-tests/test_ui.py +++ b/integration-tests/test_ui.py @@ -53,7 +53,6 @@ async def local_hub_local_binder(request): ("provider_prefix", "repo", "ref", "path", "path_type", "status_code"), [ ("gh", "binderhub-ci-repos/requirements", "master", "", "", 200), - ("gh", "binderhub-ci-repos%2Frequirements", "master", "", "", 400), ("gh", "binderhub-ci-repos/requirements", "master/", "", "", 200), ( "gh", @@ -103,18 +102,15 @@ async def test_loading_page( spec = f"{repo}/{ref}" provider_spec = f"{provider_prefix}/{spec}" query = f"{path_type}path={path}" if path else "" - uri = f"/v2/{provider_spec}?{query}" - r = page.goto(local_hub_local_binder + uri) - - assert r.status == status_code - + uri = f"{local_hub_local_binder}v2/{provider_spec}?{query}" + r = page.goto(uri) + assert r.status == status_code, f"{r.status} {uri}" if status_code == 200: - assert page.query_selector("#log-container") - iframe = page.query_selector("#nbviewer-preview iframe") - assert iframe is not None - nbviewer_url = iframe.get_attribute("src") - r = await async_requests.get(nbviewer_url) - assert r.status_code == 200, f"{r.status_code} {nbviewer_url}" + nbviewer_url = page.get_by_test_id("nbviewer-iframe").get_attribute("src") + expected_url = ( + f"https://nbviewer.jupyter.org/github/{repo}/tree/{ref.replace("/", "")}" + ) + assert nbviewer_url == expected_url @pytest.mark.parametrize( @@ -139,7 +135,7 @@ async def test_loading_page( "master", "some file with spaces.ipynb", "file", - "v2/gh/binder-examples/requirements/master?labpath=some+file+with+spaces.ipynb", + "v2/gh/binder-examples/requirements/master?urlpath=%2Fdoc%2Ftree%2Fsome+file+with+spaces.ipynb", ), ( "binder-examples/requirements", @@ -156,24 +152,24 @@ async def test_main_page( resp = page.goto(local_hub_local_binder) assert resp.status == 200 - page.get_by_placeholder("GitHub repository name or URL").type(repo) + page.locator("[name='repository']").type(repo) if ref: - page.locator("#ref").type(ref) + page.locator("[name='ref']").type(ref) if path_type: - page.query_selector("#url-or-file-btn").click() + page.get_by_role("button", name="File").click() if path_type == "file": - page.locator("a:text-is('File')").click() + pass elif path_type == "url": - page.locator("a:text-is('URL')").click() + page.get_by_role("button", name="URL").click() else: raise ValueError(f"Unknown path_type {path_type}") if path: - page.locator("#filepath").type(path) + page.locator("[name='path']").type(path) assert ( - page.query_selector("#basic-url-snippet").inner_text() + page.get_by_test_id("launch-url").inner_text() == f"{local_hub_local_binder}{shared_url}" ) diff --git a/js/packages/binderhub-client/lib/index.js b/js/packages/binderhub-client/lib/index.js index f33f35600..ee474d895 100644 --- a/js/packages/binderhub-client/lib/index.js +++ b/js/packages/binderhub-client/lib/index.js @@ -208,111 +208,4 @@ export class BinderRepository { this.abortController = null; } } - - /** - * Get URL to redirect user to on a Jupyter Server to display a given path - - * @param {URL} serverUrl URL to the running jupyter server - * @param {string} token Secret token used to authenticate to the jupyter server - * @param {string} [path] The path of the file or url suffix to launch the user into - * @param {string} [pathType] One of "lab", "file" or "url", denoting what kinda path we are launching the user into - * - * @returns {URL} A URL to redirect the user to - */ - getFullRedirectURL(serverUrl, token, path, pathType) { - // Make a copy of the URL so we don't mangle the original - let url = new URL(serverUrl); - if (path) { - // Ensure there is a trailing / in serverUrl - if (!url.pathname.endsWith("/")) { - url.pathname += "/"; - } - // trim leading '/' from path to launch users into - path = path.replace(/(^\/)/g, ""); - - if (pathType === "lab") { - // The path is a specific *file* we should open with JupyterLab - // trim trailing / on file paths - path = path.replace(/(\/$)/g, ""); - - // /doc/tree is safe because it allows redirect to files - url = new URL("doc/tree/" + encodeURI(path), url); - } else if (pathType === "file") { - // The path is a specific file we should open with *classic notebook* - - // trim trailing / on file paths - path = path.replace(/(\/$)/g, ""); - - url = new URL("tree/" + encodeURI(path), url); - } else { - // pathType is 'url' and we should just pass it on - url = new URL(path, url); - } - } - - url.searchParams.append("token", token); - return url; - } -} - -/** - * Generate a shareable binder URL for given repository - * - * @param {URL} publicBaseUrl Base URL to use for making public URLs. Must end with a trailing slash. - * @param {string} providerPrefix prefix denoting what provider was selected - * @param {string} repository repo to build - * @param {string} ref optional ref in this repo to build - * @param {string} [path] Path to launch after this repo has been built - * @param {string} [pathType] Type of thing to open path with (raw url, notebook file, lab, etc) - * - * @returns {URL} A URL that can be shared with others, and clicking which will launch the repo - */ -export function makeShareableBinderURL( - publicBaseUrl, - providerPrefix, - repository, - ref, - path, - pathType, -) { - if (!publicBaseUrl.pathname.endsWith("/")) { - throw new Error( - `publicBaseUrl must end with a trailing slash, got ${publicBaseUrl}`, - ); - } - const url = new URL( - `v2/${providerPrefix}/${repository}/${ref}`, - publicBaseUrl, - ); - if (path && path.length > 0) { - url.searchParams.append(`${pathType}path`, path); - } - return url; -} - -/** - * Generate markup that people can put on their README or documentation to link to a specific binder - * - * @param {URL} publicBaseUrl Base URL to use for making public URLs - * @param {URL} url Link target URL that represents this binder installation - * @param {string} syntax Kind of markup to generate. Supports 'markdown' and 'rst' - * @returns {string} - */ -export function makeBadgeMarkup(publicBaseUrl, url, syntax) { - if (!publicBaseUrl.pathname.endsWith("/")) { - throw new Error( - `publicBaseUrl must end with a trailing slash, got ${publicBaseUrl}`, - ); - } - const badgeImageUrl = new URL("badge_logo.svg", publicBaseUrl); - - if (syntax === "markdown") { - return `[![Binder](${badgeImageUrl})](${url})`; - } else if (syntax === "rst") { - return `.. image:: ${badgeImageUrl}\n :target: ${url}`; - } else { - throw new Error( - `Only markdown or rst badges are supported, got ${syntax} instead`, - ); - } } diff --git a/js/packages/binderhub-client/tests/index.test.js b/js/packages/binderhub-client/tests/index.test.js index 04e35685b..3ca05d358 100644 --- a/js/packages/binderhub-client/tests/index.test.js +++ b/js/packages/binderhub-client/tests/index.test.js @@ -1,16 +1,12 @@ // fetch polyfill (only needed for node tests) import { fetch, TextDecoder } from "@whatwg-node/fetch"; -import { - BinderRepository, - makeShareableBinderURL, - makeBadgeMarkup, -} from "@jupyterhub/binderhub-client"; +import { BinderRepository } from "@jupyterhub/binderhub-client"; import { parseEventSource, simpleEventSourceServer } from "./utils"; import { readFileSync } from "node:fs"; async function wrapFetch(resource, options) { - /* like fetch, but ignore signal input + /* like fetch, but ignore signal input // abort signal shows up as uncaught in tests, despite working fine */ if (options) { @@ -82,155 +78,6 @@ test("Build URL correctly built from Build Endpoint when used with token", () => ); }); -test("Get full redirect URL with correct token but no path", () => { - const br = new BinderRepository( - "gh/test/test", - new URL("https://test-binder.org/build"), - ); - expect( - br - .getFullRedirectURL( - new URL("https://hub.test-binder.org/user/something"), - "token", - ) - .toString(), - ).toBe("https://hub.test-binder.org/user/something?token=token"); -}); - -test("Get full redirect URL with urlpath", () => { - const br = new BinderRepository( - "gh/test/test", - new URL("https://test-binder.org/build"), - ); - expect( - br - .getFullRedirectURL( - new URL("https://hub.test-binder.org/user/something"), - "token", - "rstudio", - "url", - ) - .toString(), - ).toBe("https://hub.test-binder.org/user/something/rstudio?token=token"); -}); - -test("Get full redirect URL when opening a file with jupyterlab", () => { - const br = new BinderRepository( - "gh/test/test", - new URL("https://test-binder.org/build"), - ); - expect( - br - .getFullRedirectURL( - new URL("https://hub.test-binder.org/user/something"), - "token", - "index.ipynb", - "lab", - ) - .toString(), - ).toBe( - "https://hub.test-binder.org/user/something/doc/tree/index.ipynb?token=token", - ); -}); - -test("Get full redirect URL when opening a file with classic notebook (with file= path)", () => { - const br = new BinderRepository( - "gh/test/test", - new URL("https://test-binder.org/build"), - ); - expect( - br - .getFullRedirectURL( - new URL("https://hub.test-binder.org/user/something"), - "token", - "index.ipynb", - "file", - ) - .toString(), - ).toBe( - "https://hub.test-binder.org/user/something/tree/index.ipynb?token=token", - ); -}); - -test("Get full redirect URL and deal with excessive slashes (with pathType=url)", () => { - const br = new BinderRepository( - "gh/test/test", - new URL("https://test-binder.org/build"), - ); - expect( - br - .getFullRedirectURL( - // Trailing slash should not be preserved here - new URL("https://hub.test-binder.org/user/something/"), - "token", - // Trailing slash should be preserved here, but leading slash should not be repeated - "/rstudio/", - "url", - ) - .toString(), - ).toBe("https://hub.test-binder.org/user/something/rstudio/?token=token"); -}); - -test("Get full redirect URL and deal with excessive slashes (with pathType=lab)", () => { - const br = new BinderRepository( - "gh/test/test", - new URL("https://test-binder.org/build"), - ); - expect( - br - .getFullRedirectURL( - new URL("https://hub.test-binder.org/user/something/"), - "token", - // Both leading and trailing slashes should be gone here. - "/directory/index.ipynb/", - "lab", - ) - .toString(), - ).toBe( - "https://hub.test-binder.org/user/something/doc/tree/directory/index.ipynb?token=token", - ); -}); - -test("Get full redirect URL and deal with missing trailing slash", () => { - const br = new BinderRepository( - "gh/test/test", - new URL("https://test-binder.org/build"), - ); - expect( - br - .getFullRedirectURL( - // Missing trailing slash here should not affect target url - new URL("https://hub.test-binder.org/user/something"), - "token", - "/directory/index.ipynb/", - "lab", - ) - .toString(), - ).toBe( - "https://hub.test-binder.org/user/something/doc/tree/directory/index.ipynb?token=token", - ); -}); - -test("Get full redirect URL and deal with excessive slashes (with pathType=file)", () => { - const br = new BinderRepository( - "gh/test/test", - new URL("https://test-binder.org/build"), - ); - expect( - br - .getFullRedirectURL( - new URL("https://hub.test-binder.org/user/something/"), - "token", - // Both leading and trailing slashes should be gone here. - "/directory/index.ipynb/", - "file", - ) - .toString(), - ).toBe( - "https://hub.test-binder.org/user/something/tree/directory/index.ipynb?token=token", - ); -}); - describe("Iterate over full output from calling the binderhub API", () => { let closeServer, serverUrl; @@ -286,136 +133,3 @@ describe("Invalid eventsource response causes failure", () => { ]); }); }); - -test("Get full redirect URL and deal with query and encoded query (with pathType=url)", () => { - const br = new BinderRepository( - "gh/test/test", - new URL("https://test-binder.org/build"), - ); - expect( - br - .getFullRedirectURL( - new URL("https://hub.test-binder.org/user/something/"), - "token", - // url path here is already url encoded - "endpoint?a=1%2F2&b=3%3F%2F", - "url", - ) - .toString(), - ).toBe( - // url path here is exactly as encoded as passed in - not *double* encoded - "https://hub.test-binder.org/user/something/endpoint?a=1%2F2&b=3%3F%2F&token=token", - ); -}); - -test("Get full redirect URL with nbgitpuller URL", () => { - const br = new BinderRepository( - "gh/test/test", - new URL("https://test-binder.org/build"), - ); - expect( - br - .getFullRedirectURL( - new URL("https://hub.test-binder.org/user/something/"), - "token", - // urlpath is not actually url encoded - note that / is / not %2F - "git-pull?repo=https://github.com/alperyilmaz/jupyterlab-python-intro&urlpath=lab/tree/jupyterlab-python-intro/&branch=master", - "url", - ) - .toString(), - ).toBe( - // generated URL path here *is* url encoded - "https://hub.test-binder.org/user/something/git-pull?repo=https%3A%2F%2Fgithub.com%2Falperyilmaz%2Fjupyterlab-python-intro&urlpath=lab%2Ftree%2Fjupyterlab-python-intro%2F&branch=master&token=token", - ); -}); - -test("Make a shareable URL", () => { - const url = makeShareableBinderURL( - new URL("https://test.binder.org"), - "gh", - "yuvipanda", - "requirements", - ); - expect(url.toString()).toBe( - "https://test.binder.org/v2/gh/yuvipanda/requirements", - ); -}); - -test("Make a shareable path with URL", () => { - const url = makeShareableBinderURL( - new URL("https://test.binder.org"), - "gh", - "yuvipanda", - "requirements", - "url", - "git-pull?repo=https://github.com/alperyilmaz/jupyterlab-python-intro&urlpath=lab/tree/jupyterlab-python-intro/&branch=master", - ); - expect(url.toString()).toBe( - "https://test.binder.org/v2/gh/yuvipanda/requirements?git-pull%3Frepo%3Dhttps%3A%2F%2Fgithub.com%2Falperyilmaz%2Fjupyterlab-python-intro%26urlpath%3Dlab%2Ftree%2Fjupyterlab-python-intro%2F%26branch%3Dmasterpath=url", - ); -}); - -test("Making a shareable URL with base URL without trailing / throws error", () => { - expect(() => { - makeShareableBinderURL( - new URL("https://test.binder.org/suffix"), - "gh", - "yuvipanda", - "requirements", - ); - }).toThrow(Error); -}); - -test("Make a markdown badge", () => { - const url = makeShareableBinderURL( - new URL("https://test.binder.org"), - "gh", - "yuvipanda", - "requirements", - ); - const badge = makeBadgeMarkup( - new URL("https://test.binder.org"), - url, - "markdown", - ); - expect(badge).toBe( - "[![Binder](https://test.binder.org/badge_logo.svg)](https://test.binder.org/v2/gh/yuvipanda/requirements)", - ); -}); - -test("Make a rst badge", () => { - const url = makeShareableBinderURL( - new URL("https://test.binder.org"), - "gh", - "yuvipanda", - "requirements", - ); - const badge = makeBadgeMarkup(new URL("https://test.binder.org"), url, "rst"); - expect(badge).toBe( - ".. image:: https://test.binder.org/badge_logo.svg\n :target: https://test.binder.org/v2/gh/yuvipanda/requirements", - ); -}); - -test("Making a badge with an unsupported syntax throws error", () => { - const url = makeShareableBinderURL( - new URL("https://test.binder.org"), - "gh", - "yuvipanda", - "requirements", - ); - expect(() => { - makeBadgeMarkup(new URL("https://test.binder.org"), url, "docx"); - }).toThrow(Error); -}); - -test("Making a badge with base URL without trailing / throws error", () => { - const url = makeShareableBinderURL( - new URL("https://test.binder.org"), - "gh", - "yuvipanda", - "requirements", - ); - expect(() => { - makeBadgeMarkup(new URL("https://test.binder.org/suffix"), url, "markdown"); - }).toThrow(Error); -}); diff --git a/package.json b/package.json index 916d542cf..1ff2e5c7a 100644 --- a/package.json +++ b/package.json @@ -1,28 +1,48 @@ { "name": "binderhub", - "description": "BinderHub's web user interface involves javascript built by this node package.", + "description": "Frontend Interface for BinderHub", + "private": true, "dependencies": { - "bootstrap": "^3.4.1", - "clipboard": "^2.0.11", - "jquery": "^3.6.4", - "xterm": "^5.1.0", - "xterm-addon-fit": "^0.7.0" + "@fontsource/clear-sans": "^5.0.11", + "bootstrap": "^5.3.3", + "bootstrap-icons": "^1.11.3", + "copy-to-clipboard": "^3.3.3", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "wouter": "^3.3.5", + "@xterm/xterm": "^5.5.0", + "@xterm/addon-fit": "^0.9.0" }, "devDependencies": { "@babel/cli": "^7.21.0", "@babel/core": "^7.21.4", "@babel/eslint-parser": "^7.22.15", "@babel/preset-env": "^7.21.4", + "@babel/preset-react": "^7.26.3", + "@types/react": "^19.0.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.5.2", + "configurable-http-proxy": "^4.6.2", "@types/jest": "^29.5.5", "@whatwg-node/fetch": "^0.9.17", + "autoprefixer": "^10.4.19", "babel-jest": "^29.7.0", "babel-loader": "^9.1.2", - "css-loader": "^6.7.3", + "css-loader": "^6.11.0", "eslint": "^8.38.0", "eslint-plugin-jest": "^27.4.2", + "eslint-plugin-react": "^7.37.2", + "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "mini-css-extract-plugin": "^2.7.5", + "postcss-loader": "^8.1.1", + "sass": "^1.77.1", + "sass-loader": "^14.2.1", + "style-loader": "^4.0.0", + "ts-loader": "^9.5.1", + "typescript": "^5.4.5", "webpack": "^5.78.0", "webpack-cli": "^5.0.1" }, @@ -32,7 +52,7 @@ "scripts": { "webpack": "webpack", "webpack:watch": "webpack --watch", - "lint": "eslint .", + "lint": "eslint binderhub/static/js js", "test": "jest" }, "jest": { @@ -41,6 +61,23 @@ "coverageReporters": [ "text", "cobertura" - ] + ], + "testPathIgnorePatterns": [ + "spec.js" + ], + "moduleNameMapper": { + "\\.css$": "identity-obj-proxy", + "\\.scss$": "identity-obj-proxy", + "\\.ico$": "identity-obj-proxy" + }, + "setupFilesAfterEnv": [ + "/setupTests.js" + ], + "transformIgnorePatterns": [ + "/node_modules/(?!wouter)" + ], + "transform": { + "\\.[jt]sx?$": "babel-jest" + } } } diff --git a/setupTests.js b/setupTests.js new file mode 100644 index 000000000..0a4a8dce4 --- /dev/null +++ b/setupTests.js @@ -0,0 +1,54 @@ +import { jest } from "@jest/globals"; +import "@testing-library/jest-dom"; + +HTMLCanvasElement.prototype.getContext = () => {}; +Object.defineProperty(window, "matchMedia", { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // Deprecated + removeListener: jest.fn(), // Deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}); + +window.pageConfig = { + baseUrl: "/", + aboutMessage: "This is the about message", + binderVersion: "v123.456", + repoProviders: [ + { + detect: { + regex: "^(https?://github.com/)?(?.*)", + }, + displayName: "GitHub", + id: "gh", + spec: { validateRegex: ".+\\/.+\\/.+" }, + ref: { + default: "HEAD", + enabled: true, + }, + repo: { + label: "GitHub repository name or URL", + placeholder: + "example: yuvipanda/requirements or https://github.com/yuvipanda/requirements", + }, + }, + { + displayName: "Zenodo DOI", + id: "zenodo", + spec: { validateRegex: "10\\.\\d+\\/(.)+" }, + ref: { + enabled: false, + }, + repo: { + label: "Zenodo DOI", + placeholder: "example: 10.5281/zenodo.3242074", + }, + }, + ], +}; diff --git a/testing/k8s-binder-k8s-hub/binderhub-chart-config.yaml b/testing/k8s-binder-k8s-hub/binderhub-chart-config.yaml index 9b3d470c6..652d13bc3 100644 --- a/testing/k8s-binder-k8s-hub/binderhub-chart-config.yaml +++ b/testing/k8s-binder-k8s-hub/binderhub-chart-config.yaml @@ -18,16 +18,6 @@ config: log_level: 10 cors_allow_origin: "*" -extraFiles: - page.html: - mountPath: /etc/binderhub/templates/page.html - stringData: | - {% extends "templates/page.html" %} - {% block footer %} - {{ super() }} - test-template - {% endblock %} - ingress: # Enabled to test the creation/update of the k8s Ingress resource, but not # used actively in our CI system. diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..4a56b9984 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": false, + "noEmit": false, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "noImplicitAny": false, + "module": "es6", + "target": "es5", + "jsx": "react-jsx", + "moduleResolution": "node", + "sourceMap": true + }, + "include": ["binderhub/static/js/"] +} diff --git a/webpack.config.js b/webpack.config.js index ba73e76a6..f2838ec97 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,36 +1,33 @@ const webpack = require("webpack"); const path = require("path"); +const autoprefixer = require("autoprefixer"); const MiniCssExtractPlugin = require("mini-css-extract-plugin"); module.exports = { - mode: "production", + mode: "development", context: path.resolve(__dirname, "binderhub/static"), - entry: "./js/index.js", + entry: "./js/index.jsx", output: { path: path.resolve(__dirname, "binderhub/static/dist/"), filename: "bundle.js", - publicPath: "/static/dist/", + publicPath: "auto", }, plugins: [ - new webpack.ProvidePlugin({ - $: "jquery", - jQuery: "jquery", - }), new MiniCssExtractPlugin({ filename: "styles.css", }), ], + resolve: { + extensions: [".tsx", ".ts", ".js", ".jsx"], + }, module: { rules: [ { - test: /\.js$/, + test: /\.(t|j)sx?$/, exclude: /(node_modules|bower_components)/, use: { - loader: "babel-loader", - options: { - presets: ["@babel/preset-env"], - }, + loader: "ts-loader", }, }, { @@ -49,7 +46,33 @@ module.exports = { ], }, { - test: /\.(eot|woff|ttf|woff2|svg)$/, + test: /\.(scss)$/, + use: [ + { + // Adds CSS to the DOM by injecting a `