├── .github ├── dependabot.yml └── workflows │ ├── build-ui.yml │ ├── lint-ui.yml │ ├── lint.yml │ ├── release.yml │ ├── test-integration.yml │ ├── test-k3s-integration.yml │ ├── test-ui.yml │ └── test.yml ├── .gitignore ├── .husky └── pre-push ├── LICENSE ├── README.md ├── demo.gif ├── docker ├── .dockerignore ├── Dockerfile.jhub ├── Dockerfile.jupyterhub ├── README.md ├── docker-compose.yml └── jupyterhub_config.py ├── docs ├── .gitignore ├── README.md ├── babel.config.js ├── docs │ ├── concepts │ │ ├── _category_.json │ │ └── infrastructure-architecture.mdx │ ├── configuration.md │ ├── create-apps │ │ ├── _category_.json │ │ ├── bokeh-app.md │ │ ├── custom-app.md │ │ ├── general-app.md │ │ ├── gradio-app.md │ │ ├── panel-app.md │ │ ├── plotly-dash-app.md │ │ ├── streamlit-app.md │ │ └── voila-app.md │ ├── installation.md │ └── intro.md ├── docusaurus.config.js ├── package-lock.json ├── package.json ├── sidebars.js ├── src │ ├── components │ │ └── HomepageFeatures │ │ │ ├── index.js │ │ │ └── styles.module.css │ ├── css │ │ └── custom.css │ └── pages │ │ ├── index.js │ │ └── index.module.css ├── static │ ├── .nojekyll │ └── img │ │ ├── app_card.png │ │ ├── app_card_context_menu.png │ │ ├── create_app_env_var_1.png │ │ ├── create_app_env_var_2.png │ │ ├── create_app_info_and_configuration.png │ │ ├── create_app_select_server.png │ │ ├── create_app_sharing.png │ │ ├── create_app_thumbnail.png │ │ ├── custom_app_creation.png │ │ ├── favicon.ico │ │ ├── homepage.png │ │ ├── jhub_service_diagram.png │ │ ├── jhub_single_native_proxy.png │ │ ├── jhub_subsystems.png │ │ ├── logo.svg │ │ ├── logos │ │ ├── bokeh.png │ │ ├── custom.png │ │ ├── gradio.png │ │ ├── jupyter.png │ │ ├── panel.png │ │ ├── plotly-dash.png │ │ ├── streamlit.png │ │ └── voila.png │ │ ├── proxy-arg-overrides.png │ │ ├── streamlit_app.png │ │ └── voila_app.png └── yarn.lock ├── jhub_apps ├── __about__.py ├── __init__.py ├── config_utils.py ├── configuration.py ├── examples │ ├── __init__.py │ ├── bokeh_basic.py │ ├── gradio_basic.py │ ├── panel_basic.py │ ├── plotlydash_app.py │ ├── streamlit_app.py │ └── voila_basic.ipynb ├── hub_client │ ├── __init__.py │ ├── hub_client.py │ └── utils.py ├── main.py ├── service │ ├── __init__.py │ ├── app.py │ ├── app_from_git.py │ ├── auth.py │ ├── client.py │ ├── japps_routes.py │ ├── logging_utils.py │ ├── middlewares.py │ ├── models.py │ ├── routes.py │ ├── security.py │ └── utils.py ├── service_utils.py ├── spawner │ ├── __init__.py │ ├── command.py │ ├── env.py │ ├── spawner_creation.py │ ├── types.py │ └── utils.py ├── static │ ├── css │ │ └── index.css │ ├── favicon.ico │ ├── img │ │ ├── Nebari-Logo-Horizontal-Lockup-Black-text.svg │ │ ├── Nebari-Logo-Horizontal-Lockup-White-text.svg │ │ ├── Nebari-logo-square.svg │ │ └── logos │ │ │ ├── bokeh.png │ │ │ ├── custom.png │ │ │ ├── gradio.png │ │ │ ├── jupyter.png │ │ │ ├── panel.png │ │ │ ├── plotly-dash.png │ │ │ ├── streamlit.png │ │ │ ├── voila.png │ │ │ └── vscode.png │ └── js │ │ └── index.js ├── tasks │ └── commands │ │ └── initialize_startup_apps.py ├── templates │ ├── 404.html │ ├── home.html │ ├── japps_custom.html │ ├── japps_page.html │ ├── launcher_base.html │ ├── login.html │ ├── not_running.html │ ├── oauth.html │ ├── page.html │ ├── server_options.html │ └── style.css ├── tests │ ├── __init__.py │ ├── common │ │ ├── __init__.py │ │ ├── api_fixtures.py │ │ └── constants.py │ ├── conftest.py │ ├── tests_e2e │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── test_api.py │ │ ├── test_integration.py │ │ ├── test_startup_apps.py │ │ └── utils.py │ └── tests_unit │ │ ├── __init__.py │ │ ├── test_api.py │ │ ├── test_app_from_git.py │ │ ├── test_command_template.py │ │ ├── test_filter_users__groups_based_on_scopes.py │ │ └── test_hub_client.py ├── themes │ └── __init__.py └── version.py ├── jupyter_config_profile_list ├── jupyterhub_config.py ├── k3s-dev ├── .gitignore ├── .tiltignore ├── Makefile ├── README.md ├── Tiltfile ├── config │ ├── 00-jhub-apps.py │ ├── 01-spawner.py │ └── 02-profiles.py ├── jupyterhub-values.yaml └── k3d-config.yaml ├── package-lock.json ├── pyproject.toml ├── ui ├── .env ├── .prettierignore ├── .prettierrc ├── README.md ├── build-and-copy.sh ├── eslint.config.js ├── index.html ├── package-lock.json ├── package.json ├── public │ ├── env.js │ ├── favicon.ico │ └── img │ │ └── logo.svg ├── src │ ├── App.tsx │ ├── components │ │ ├── app-card │ │ │ ├── app-card.css │ │ │ ├── app-card.test.tsx │ │ │ └── app-card.tsx │ │ ├── app-form │ │ │ ├── app-form.css │ │ │ ├── app-form.test.tsx │ │ │ └── app-form.tsx │ │ ├── app-sharing │ │ │ ├── app-sharing.css │ │ │ ├── app-sharing.test.tsx │ │ │ └── app-sharing.tsx │ │ ├── button-group │ │ │ ├── button-group.css │ │ │ ├── button-group.test.tsx │ │ │ └── button-group.tsx │ │ ├── context-menu │ │ │ ├── context-menu.css │ │ │ ├── context-menu.test.tsx │ │ │ └── context-menu.tsx │ │ ├── custom-label │ │ │ ├── custom-label.test.tsx │ │ │ └── custom-label.tsx │ │ ├── environment-variables │ │ │ ├── environment-variables.test.tsx │ │ │ └── environment-variables.tsx │ │ ├── index.ts │ │ ├── navigation │ │ │ ├── navigation.css │ │ │ ├── navigation.test.tsx │ │ │ └── navigation.tsx │ │ ├── notification-bar │ │ │ ├── notification-bar.css │ │ │ ├── notification-bar.test.tsx │ │ │ └── notification-bar.tsx │ │ ├── status-chip │ │ │ ├── status-chip.css │ │ │ ├── status-chip.test.tsx │ │ │ └── status-chip.tsx │ │ └── thumbnail │ │ │ ├── thumbnail.css │ │ │ ├── thumbnail.test.tsx │ │ │ └── thumbnail.tsx │ ├── data │ │ ├── api.ts │ │ ├── jupyterhub.ts │ │ ├── logos.ts │ │ └── user.ts │ ├── index.css │ ├── main.tsx │ ├── pages │ │ ├── create-app │ │ │ ├── create-app.test.tsx │ │ │ └── create-app.tsx │ │ ├── edit-app │ │ │ ├── edit-app.test.tsx │ │ │ └── edit-app.tsx │ │ ├── home │ │ │ ├── apps-section │ │ │ │ ├── app-filters │ │ │ │ │ ├── app-filters.css │ │ │ │ │ ├── app-filters.test.tsx │ │ │ │ │ └── app-filters.tsx │ │ │ │ ├── app-grid │ │ │ │ │ ├── app-grid.test.tsx │ │ │ │ │ └── app-grid.tsx │ │ │ │ ├── app-table │ │ │ │ │ ├── app-table.css │ │ │ │ │ ├── app-table.test.tsx │ │ │ │ │ └── app-table.tsx │ │ │ │ ├── apps-section.test.tsx │ │ │ │ └── apps-section.tsx │ │ │ ├── home.css │ │ │ ├── home.test.tsx │ │ │ ├── home.tsx │ │ │ └── services-section │ │ │ │ ├── service-grid │ │ │ │ ├── service-grid.test.tsx │ │ │ │ └── service-grid.tsx │ │ │ │ ├── services-section.test.tsx │ │ │ │ └── services-section.tsx │ │ ├── not-running │ │ │ ├── not-running.test.tsx │ │ │ └── not-running.tsx │ │ ├── server-types │ │ │ ├── server-types.css │ │ │ ├── server-types.test.tsx │ │ │ └── server-types.tsx │ │ ├── stop-pending │ │ │ ├── stop-pending.css │ │ │ ├── stop-pending.test.tsx │ │ │ └── stop-pending.tsx │ │ └── success │ │ │ ├── success.test.tsx │ │ │ └── success.tsx │ ├── store.ts │ ├── styles │ │ ├── styled-filter-button.tsx │ │ ├── styled-form-paragraph.tsx │ │ ├── styled-form-section.tsx │ │ └── styled-item.tsx │ ├── theme │ │ ├── colors.tsx │ │ └── theme.tsx │ ├── types.d.ts │ ├── types │ │ ├── api.ts │ │ ├── form.ts │ │ ├── jupyterhub.ts │ │ └── user.ts │ ├── utils │ │ ├── axios.ts │ │ ├── constants.ts │ │ ├── jupyterhub-axios.ts │ │ ├── jupyterhub.test.ts │ │ └── jupyterhub.ts │ └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json ├── vercel.json ├── vite.config.ts └── vitest.setup.ts └── uv.lock /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/ui" 5 | schedule: 6 | interval: "monthly" 7 | day: "monday" 8 | time: "08:00" 9 | timezone: "US/Eastern" 10 | open-pull-requests-limit: 10 11 | labels: 12 | - "dependencies" 13 | groups: 14 | minors: 15 | patterns: 16 | - "*" 17 | update-types: 18 | - "minor" 19 | - "patch" 20 | ignore: 21 | - dependency-name: react 22 | versions: 23 | - ">=18.2.0" 24 | - dependency-name: react-dom 25 | versions: 26 | - ">=18.2.0" 27 | - dependency-name: "@types/react" 28 | versions: 29 | - ">=18.2.0" 30 | - dependency-name: "@types/react-dom" 31 | versions: 32 | - ">=18.2.0" 33 | - dependency-name: "@mui/material" 34 | versions: 35 | - ">=7.0.0" 36 | - dependency-name: "@mui/icons-material" 37 | versions: 38 | - ">=7.0.0" 39 | - dependency-name: "@mui/lab" 40 | - dependency-name: "@rollup/rollup-linux-x64-gnu" 41 | - dependency-name: "@esbuild/linux-x64" 42 | - dependency-name: "vite" 43 | versions: 44 | - ">=7.0.0" 45 | -------------------------------------------------------------------------------- /.github/workflows/build-ui.yml: -------------------------------------------------------------------------------- 1 | name: Build UI 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout current repo head to support automated local PRs 16 | if: github.event.pull_request.head.repo.full_name == github.repository 17 | uses: actions/checkout@v4.1.1 18 | with: 19 | ref: ${{ github.head_ref }} 20 | fetch-depth: 2 21 | 22 | - name: Checkout default if not upstream repo 23 | if: github.event.pull_request.head.repo.full_name != github.repository 24 | uses: actions/checkout@v4.1.1 25 | 26 | - id: check_label 27 | name: Check PL labels 28 | uses: docker://agilepathway/pull-request-label-checker:v1.6.21 29 | with: 30 | one_of: dependencies 31 | repo_token: ${{ secrets.GITHUB_TOKEN }} 32 | allow_failure: true 33 | 34 | - name: Set up Node environment 35 | uses: actions/setup-node@v4.0.2 36 | with: 37 | node-version: 18.x 38 | 39 | - name: Install dependencies 40 | run: npm ci 41 | working-directory: ui 42 | 43 | - name: Build and Copy 44 | run: npm run build 45 | working-directory: ui 46 | 47 | - name: Get Last Commit 48 | id: last_commit 49 | run: | 50 | echo "message=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT 51 | echo "author=$(git log -1 --pretty=\"%an <%ae>\")" >> $GITHUB_OUTPUT 52 | 53 | - name: Commit and push changes 54 | if: steps.check_label.outputs.label_check == 'success' 55 | uses: stefanzweifel/git-auto-commit-action@v5 56 | with: 57 | commit_author: ${{ steps.last_commit.outputs.author }} 58 | commit_message: ${{ steps.last_commit.outputs.message }} 59 | commit_options: "--amend --no-edit" 60 | push_options: "--force" 61 | skip_fetch: true 62 | 63 | - name: Check static files uncommitted 64 | run: | 65 | if [[ $(git status --porcelain) ]]; then 66 | echo "There are uncommitted changes." 67 | git diff 68 | exit 1 69 | else 70 | echo "No uncommitted changes found." 71 | fi 72 | -------------------------------------------------------------------------------- /.github/workflows/lint-ui.yml: -------------------------------------------------------------------------------- 1 | name: Lint UI 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | push: 7 | branches: [main] 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4.1.1 16 | 17 | - name: Set up Node environment 18 | uses: actions/setup-node@v4.0.2 19 | with: 20 | node-version: 18.x 21 | 22 | - name: Install dependencies 23 | run: npm ci 24 | working-directory: ui 25 | 26 | - name: Run Linting 27 | run: npm run lint 28 | working-directory: ui 29 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | push: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | python-version: ["3.9"] 16 | 17 | steps: 18 | # https://github.com/actions/checkout/releases/tag/v5.0.0 19 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 20 | - name: Setup Python 21 | # https://github.com/actions/setup-python/releases/tag/v6.0.0 22 | uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c 23 | with: 24 | python-version: '3.12' 25 | 26 | - name: Install uv 27 | # https://github.com/astral-sh/setup-uv/releases/tag/v6.8.0 28 | uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e 29 | 30 | - name: Install dependencies 31 | run: | 32 | uv sync --extra dev 33 | 34 | - name: Lint with ruff 35 | run: | 36 | uv run ruff check . 37 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: [release, workflow_dispatch] 4 | 5 | env: 6 | HATCH_INDEX_USER: __token__ 7 | HATCH_INDEX_AUTH: ${{ secrets.HATCH_INDEX_AUTH }} 8 | 9 | jobs: 10 | release: 11 | name: Release jhub-apps 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | # https://github.com/actions/checkout/releases/tag/v5.0.0 16 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 17 | 18 | - name: Setup Python 19 | # https://github.com/actions/setup-python/releases/tag/v6.0.0 20 | uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c 21 | with: 22 | python-version: '3.12' 23 | 24 | - name: Install uv 25 | # https://github.com/astral-sh/setup-uv/releases/tag/v6.8.0 26 | uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e 27 | 28 | - name: Install dependencies 29 | run: uv sync 30 | 31 | - name: Hatch Build 32 | run: hatch build 33 | 34 | - name: Hatch Publish 35 | run: hatch publish -n 36 | -------------------------------------------------------------------------------- /.github/workflows/test-ui.yml: -------------------------------------------------------------------------------- 1 | name: Test UI 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | push: 7 | branches: [main] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4.1.1 16 | 17 | - name: Set up Node environment 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: 18.x 21 | 22 | - name: Install dependencies 23 | run: npm ci 24 | working-directory: ui 25 | 26 | - name: Run Vitest Tests 27 | run: npm run test:coverage 28 | working-directory: ui 29 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | push: 7 | branches: [main] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | python-version: 16 | - "3.10" 17 | - "3.11" 18 | - "3.12" 19 | test_type: 20 | - tests_unit 21 | jupyterhub: 22 | - "==4.1.5" 23 | - ">=5.0.0" 24 | steps: 25 | # https://github.com/actions/checkout/releases/tag/v5.0.0 26 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 27 | 28 | - name: Setup Python 29 | # https://github.com/actions/setup-python/releases/tag/v6.0.0 30 | uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c 31 | with: 32 | python-version: ${{ matrix.python-version }} 33 | 34 | - name: Install uv 35 | # https://github.com/astral-sh/setup-uv/releases/tag/v6.8.0 36 | uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e 37 | 38 | - name: Install dependencies 39 | run: | 40 | if [ "${{ matrix.jupyterhub }}" = "==4.1.5" ]; then 41 | uv sync --group test-jupyterhub-4 --extra dev 42 | else 43 | uv sync --group test-jupyterhub-5 --extra dev 44 | fi 45 | 46 | - name: uv pip list 47 | run: uv pip list 48 | 49 | - name: Run Tests 50 | run: | 51 | GROUP_FLAG="" 52 | if [ "${{ matrix.jupyterhub }}" = "==4.1.5" ]; then 53 | GROUP_FLAG="--group test-jupyterhub-4" 54 | else 55 | GROUP_FLAG="--group test-jupyterhub-5" 56 | fi 57 | uv run $GROUP_FLAG --extra dev pytest jhub_apps/tests/${{ matrix.test_type }} -vvv -s --log-cli-level=INFO 58 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | ## Navigate to UI directory 2 | 3 | cd ui 4 | 5 | ## Install Dependencies 6 | 7 | npm install 8 | 9 | ## Run Build 10 | 11 | npm run build 12 | 13 | ## Check for Uncommitted Build Files 14 | 15 | if [ -n "$(git status --porcelain)" ]; then 16 | echo "There are uncommitted changes. Please review the project for any uncommitted files before proceeding." 17 | exit 1 18 | else 19 | echo "No uncommitted changes found." 20 | fi 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2023, Nebari development team 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/demo.gif -------------------------------------------------------------------------------- /docker/.dockerignore: -------------------------------------------------------------------------------- 1 | docker-compose.yaml 2 | -------------------------------------------------------------------------------- /docker/Dockerfile.jhub: -------------------------------------------------------------------------------- 1 | FROM python:3.8 2 | 3 | USER root 4 | RUN apt update && apt install git gcc musl-dev python3-dev -y 5 | 6 | RUN pip3 install \ 7 | 'jupyterhub==4.0.2' \ 8 | 'notebook==7.0.3' 9 | 10 | # create a user, since we don't want to run as root 11 | RUN useradd -m jovyan 12 | ENV HOME=/home/jovyan 13 | WORKDIR $HOME 14 | USER jovyan 15 | 16 | RUN python3 -m pip install --no-cache-dir \ 17 | flask \ 18 | jupyterhub \ 19 | jupyter \ 20 | plotlydash-tornado-cmd \ 21 | bokeh-root-cmd \ 22 | jhsingle-native-proxy \ 23 | pytest \ 24 | black \ 25 | panel \ 26 | bokeh \ 27 | voila \ 28 | dash \ 29 | streamlit \ 30 | traitlets \ 31 | gradio 32 | 33 | RUN python3 -m pip install --no-cache-dir \ 34 | git+https://github.com/nebari-dev/jhub-apps.git@9fd9d10b96f4c43f279b334ec18e6a9cb284326b 35 | CMD ["jupyterhub-singleuser"] 36 | -------------------------------------------------------------------------------- /docker/Dockerfile.jupyterhub: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | ARG JUPYTERHUB_VERSION 4 | FROM jupyterhub/jupyterhub:$JUPYTERHUB_VERSION 5 | 6 | # Install dockerspawner, nativeauthenticator 7 | # hadolint ignore=DL3013 8 | RUN python3 -m pip install --no-cache-dir \ 9 | dockerspawner \ 10 | jupyterhub-nativeauthenticator 11 | 12 | RUN apt update && apt install git gcc musl-dev python3-dev -y 13 | RUN python3 -m pip install --no-cache-dir \ 14 | flask \ 15 | jupyterhub \ 16 | jupyter \ 17 | plotlydash-tornado-cmd \ 18 | bokeh-root-cmd \ 19 | jhsingle-native-proxy \ 20 | pytest \ 21 | black \ 22 | panel \ 23 | bokeh \ 24 | voila \ 25 | dash \ 26 | streamlit \ 27 | traitlets \ 28 | gradio 29 | 30 | RUN python3 -m pip install --no-cache-dir \ 31 | git+https://github.com/nebari-dev/jhub-apps.git@9fd9d10b96f4c43f279b334ec18e6a9cb284326b 32 | 33 | CMD ["jupyterhub", "-f", "/srv/jupyterhub/jupyterhub_config.py"] 34 | -------------------------------------------------------------------------------- /docker/README.md: -------------------------------------------------------------------------------- 1 | # DockerSpawner with JHub Apps 2 | 3 | ## Build JHub Image 4 | 5 | This is will used as image for DockerSpawner 6 | 7 | ```bash 8 | docker build -t jhub -f Dockerfile.jhub . 9 | ``` 10 | 11 | ## Build and Run JupyterHub 12 | 13 | ```bash 14 | docker compose build 15 | docker compose up 16 | ``` 17 | 18 | Go to http://127.0.0.1:8000 19 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | # JupyterHub docker-compose configuration file 5 | version: "3" 6 | 7 | services: 8 | hub: 9 | build: 10 | context: . 11 | dockerfile: Dockerfile.jupyterhub 12 | args: 13 | JUPYTERHUB_VERSION: latest 14 | restart: always 15 | image: jupyterhub 16 | container_name: jupyterhub 17 | networks: 18 | - jupyterhub-network 19 | volumes: 20 | # The JupyterHub configuration file 21 | - "./jupyterhub_config.py:/srv/jupyterhub/jupyterhub_config.py:ro" 22 | # Bind Docker socket on the host so we can connect to the daemon from 23 | # within the container 24 | - "/var/run/docker.sock:/var/run/docker.sock:rw" 25 | # Bind Docker volume on host for JupyterHub database and cookie secrets 26 | - "jupyterhub-data:/data" 27 | ports: 28 | - "8000:8000" 29 | environment: 30 | # This username will be a JupyterHub admin 31 | JUPYTERHUB_ADMIN: admin 32 | # All containers will join this network 33 | DOCKER_NETWORK_NAME: jupyterhub-network 34 | # JupyterHub will spawn this Notebook image for users 35 | DOCKER_NOTEBOOK_IMAGE: jupyter/base-notebook:latest 36 | # Notebook directory inside user image 37 | DOCKER_NOTEBOOK_DIR: /home/jovyan/work 38 | 39 | volumes: 40 | jupyterhub-data: 41 | 42 | networks: 43 | jupyterhub-network: 44 | name: jupyterhub-network 45 | -------------------------------------------------------------------------------- /docker/jupyterhub_config.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | # Configuration file for JupyterHub 5 | import os 6 | 7 | c = get_config() # noqa: F821 8 | 9 | # We rely on environment variables to configure JupyterHub so that we 10 | # avoid having to rebuild the JupyterHub container every time we change a 11 | # configuration parameter. 12 | 13 | # Spawn single-user servers as Docker containers 14 | c.JupyterHub.spawner_class = "dockerspawner.DockerSpawner" 15 | 16 | # Spawn containers from this image 17 | c.DockerSpawner.image = os.environ["DOCKER_NOTEBOOK_IMAGE"] 18 | 19 | # Connect containers to this Docker network 20 | network_name = os.environ["DOCKER_NETWORK_NAME"] 21 | c.DockerSpawner.use_internal_ip = True 22 | c.DockerSpawner.network_name = network_name 23 | 24 | # Explicitly set notebook directory because we'll be mounting a volume to it. 25 | # Most `jupyter/docker-stacks` *-notebook images run the Notebook server as 26 | # user `jovyan`, and set the notebook directory to `/home/jovyan/work`. 27 | # We follow the same convention. 28 | notebook_dir = os.environ.get("DOCKER_NOTEBOOK_DIR", "/home/jovyan/work") 29 | c.DockerSpawner.notebook_dir = notebook_dir 30 | 31 | # Mount the real user's Docker volume on the host to the notebook user's 32 | # notebook directory in the container 33 | c.DockerSpawner.volumes = {"jupyterhub-user-{username}": notebook_dir} 34 | 35 | # Remove containers once they are stopped 36 | c.DockerSpawner.remove = True 37 | 38 | # For debugging arguments passed to spawned containers 39 | c.DockerSpawner.debug = True 40 | 41 | # User containers will access hub by container name on the Docker network 42 | c.JupyterHub.hub_ip = "jupyterhub" 43 | c.JupyterHub.hub_port = 8080 44 | 45 | # Persist hub data on volume mounted inside container 46 | c.JupyterHub.cookie_secret_file = "/data/jupyterhub_cookie_secret" 47 | c.JupyterHub.db_url = "sqlite:////data/jupyterhub.sqlite" 48 | 49 | # Authenticate users with Native Authenticator 50 | c.JupyterHub.authenticator_class = "nativeauthenticator.NativeAuthenticator" 51 | 52 | # Allow anyone to sign-up without approval 53 | c.NativeAuthenticator.open_signup = True 54 | 55 | # Allowed admins 56 | admin = os.environ.get("JUPYTERHUB_ADMIN") 57 | if admin: 58 | c.Authenticator.admin_users = [admin] 59 | 60 | from jhub_apps.configuration import install_jhub_apps # noqa: E402 61 | from dockerspawner import DockerSpawner # noqa: E402 62 | 63 | c.DockerSpawner.image = "jhub:latest" 64 | c.JupyterHub.bind_url = "http://0.0.0.0:8000" 65 | c.DockerSpawner.name_template = "{prefix}-{username}-{servername}" 66 | c = install_jhub_apps(c, spawner_to_subclass=DockerSpawner) 67 | c.JupyterHub.log_level = 10 68 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # JHub Apps Documentation 2 | 3 | The documentation for JHub Apps is built with [Docusaurus](https://docusaurus.io/). 4 | 5 | ## Installation 6 | 7 | Navigate to the `docs` folder of the repository and install the `yarn` dependencies. 8 | 9 | ``` 10 | $ cd docs 11 | $ yarn 12 | ``` 13 | 14 | ## Local development 15 | 16 | ``` 17 | $ yarn start 18 | ``` 19 | 20 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 21 | 22 | ## Build the static page 23 | 24 | ``` 25 | $ yarn build 26 | ``` 27 | 28 | This command generates static content into the `build` directory and can be served using any static contents hosting 29 | service. It most closely mimics the production deployment and has a slightly different process than the live-uploading 30 | "local development" above. 31 | 32 | ## Documentation deployment 33 | 34 | The deployment of the jhub-apps documentation from this repo is managed externally by Vercel. The documentation page 35 | will be automatically redeployed when changes are merged. 36 | 37 | The following commands can be used for manually deploying: 38 | 39 | Using SSH: 40 | 41 | ``` 42 | $ USE_SSH=true yarn deploy 43 | ``` 44 | 45 | Not using SSH: 46 | 47 | ``` 48 | $ GIT_USER= yarn deploy 49 | ``` 50 | 51 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 52 | -------------------------------------------------------------------------------- /docs/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /docs/docs/concepts/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Conceptual Overview", 3 | "position": 4, 4 | "link": { 5 | "type": "generated-index", 6 | "description": "Conceptual Overview" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /docs/docs/create-apps/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Create Apps", 3 | "position": 3, 4 | "link": { 5 | "type": "generated-index", 6 | "description": "Get started with JHub Apps Quickly" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /docs/docs/create-apps/bokeh-app.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 4 3 | --- 4 | 5 | # Bokeh apps 6 | 7 | ## Environment requirements 8 | 9 | Your conda environment (used in JHub Apps Launcher's App creation form) must have the following packages for successful app deployment: 10 | 11 | * `jhsingle-native-proxy` >= 0.8.2 12 | * `bokeh` 13 | * Other libraries used in the app 14 | 15 | :::note 16 | In some cases, you may need `bokeh-root-cmd`, `ipywidgets`, and `ipywidgets-bokeh`. 17 | ::: 18 | 19 | ## Example application 20 | 21 | To deploy the [Bokeh Sliders Example][bokeh-sliders] using JHub Apps, you can use the following code (same as the [Bokeh example][bokeh-sliders]) and environment: 22 | 23 |
24 | Code (Jupyter Notebook) 25 | 26 | ```python title="bokeh-sliders-app.ipynb" 27 | ''' Present an interactive function explorer with slider widgets. 28 | 29 | Scrub the sliders to change the properties of the ``sin`` curve, or 30 | type into the title text box to update the title of the plot. 31 | 32 | Use the ``bokeh serve`` command to run the example by executing: 33 | 34 | bokeh serve sliders.py 35 | 36 | at your command prompt. Then navigate to the URL 37 | 38 | http://localhost:5006/sliders 39 | 40 | in your browser. 41 | 42 | ''' 43 | import numpy as np 44 | 45 | from bokeh.io import curdoc 46 | from bokeh.layouts import column, row 47 | from bokeh.models import ColumnDataSource, Slider, TextInput 48 | from bokeh.plotting import figure 49 | 50 | # Set up data 51 | N = 200 52 | x = np.linspace(0, 4*np.pi, N) 53 | y = np.sin(x) 54 | source = ColumnDataSource(data=dict(x=x, y=y)) 55 | 56 | 57 | # Set up plot 58 | plot = figure(height=400, width=400, title="my sine wave", 59 | tools="crosshair,pan,reset,save,wheel_zoom", 60 | x_range=[0, 4*np.pi], y_range=[-2.5, 2.5]) 61 | 62 | plot.line('x', 'y', source=source, line_width=3, line_alpha=0.6) 63 | 64 | 65 | # Set up widgets 66 | text = TextInput(title="title", value='my sine wave') 67 | offset = Slider(title="offset", value=0.0, start=-5.0, end=5.0, step=0.1) 68 | amplitude = Slider(title="amplitude", value=1.0, start=-5.0, end=5.0, step=0.1) 69 | phase = Slider(title="phase", value=0.0, start=0.0, end=2*np.pi) 70 | freq = Slider(title="frequency", value=1.0, start=0.1, end=5.1, step=0.1) 71 | 72 | 73 | # Set up callbacks 74 | def update_title(attrname, old, new): 75 | plot.title.text = text.value 76 | 77 | text.on_change('value', update_title) 78 | 79 | def update_data(attrname, old, new): 80 | 81 | # Get the current slider values 82 | a = amplitude.value 83 | b = offset.value 84 | w = phase.value 85 | k = freq.value 86 | 87 | # Generate the new curve 88 | x = np.linspace(0, 4*np.pi, N) 89 | y = a*np.sin(k*x + w) + b 90 | 91 | source.data = dict(x=x, y=y) 92 | 93 | for w in [offset, amplitude, phase, freq]: 94 | w.on_change('value', update_data) 95 | 96 | 97 | # Set up layouts and add to document 98 | inputs = column(text, offset, amplitude, phase, freq) 99 | 100 | curdoc().add_root(row(inputs, plot, width=800)) 101 | curdoc().title = "Sliders" 102 | ``` 103 | 104 |
105 | 106 |
107 | Environment specification 108 | 109 | Use the following spec to create a conda environment wherever JHub Apps is deployed. 110 | If using Nebari, use this spec to create an environment with [conda-store][conda-store]. 111 | 112 | 113 | ```yaml 114 | name: bokeh-sliders-app 115 | channels: 116 | - conda-forge 117 | dependencies: 118 | - numpy 119 | - bokeh 120 | - ipykernel 121 | - jhsingle-native-proxy>=0.8.2 122 | ``` 123 | 124 |
125 | 126 | ## Next steps 127 | 128 | :sparkles: [Launch app →](/docs/create-apps/general-app) 129 | 130 | 131 | 132 | [bokeh-sliders]: https://demo.bokeh.org/sliders 133 | [conda-store]: https://conda.store/conda-store-ui/tutorials/create-envs 134 | -------------------------------------------------------------------------------- /docs/docs/create-apps/gradio-app.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | 5 | # Gradio apps 6 | 7 | ## Environment requirements 8 | 9 | Your conda environment (used in JHub Apps Launcher's App creation form) must have the following packages for successful app deployment: 10 | 11 | * `jhsingle-native-proxy` >= 0.8.2 12 | * `gradio` 13 | * Other libraries used in the app 14 | 15 | ## Code requirements 16 | 17 | Write your application code in a Python script, and add the following following additional arguments to your launch function: 18 | 19 | ```python 20 | import argparse 21 | parser = argparse.ArgumentParser(description="Process CLI args for gradio") 22 | parser.add_argument( 23 | "--server-port", type=str, help="server_port for gradio app", default=8500 24 | ) 25 | parser.add_argument("--root-path", type=str, help="root_path for gradio", default=None) 26 | cli_args = parser.parse_args() 27 | 28 | demo.launch(, server_port=int(cli_args.server_port), root_path=cli_args.root_path) 29 | ``` 30 | 31 | ## Example application 32 | 33 | To deploy the [Gradio Quickstart Example][gradio-quickstart] using JHub Apps, you can use the following code and environment: 34 | 35 |
36 | Code (Python file) 37 | 38 | In a Python file, copy the following lines of code. 39 | 40 | ```python title="gradio-hello-app.py" 41 | import gradio as gr 42 | import argparse 43 | 44 | parser = argparse.ArgumentParser() 45 | 46 | parser.add_argument( 47 | "--server-port", type=str, help="server_port for gradio app", default=8500 48 | ) 49 | 50 | parser.add_argument("--root-path", type=str, help="root_path for gradio", default=None) 51 | 52 | cli_args = parser.parse_args() 53 | 54 | def greet(name, intensity): 55 | return "Hello, " + name + "!" * int(intensity) 56 | 57 | demo = gr.Interface( 58 | fn=greet, 59 | inputs=["text", "slider"], 60 | outputs=["text"], 61 | ) 62 | 63 | if __name__ == "__main__": 64 | demo.launch(server_port=int(cli_args.server_port), root_path=cli_args.root_path) 65 | ``` 66 | 67 |
68 | 69 |
70 | Environment specification 71 | 72 | Use the following spec to create a conda environment wherever JHub Apps is deployed. 73 | If using Nebari, use this spec to create an environment with [conda-store][conda-store]. 74 | 75 | ```yaml 76 | name: gradio-hello-app 77 | channels: 78 | - conda-forge 79 | dependencies: 80 | - gradio 81 | - jhsingle-native-proxy >= 0.8.2 82 | - ipykernel 83 | - ipywidgets 84 | - nbconvert 85 | ``` 86 | 87 |
88 | 89 | 90 | ## Next steps 91 | 92 | :sparkles: [Launch app →](/docs/create-apps/general-app) 93 | 94 | 95 | 96 | [gradio-quickstart]: https://www.gradio.app/guides/quickstart#building-your-first-demo 97 | [conda-store]: https://conda.store/conda-store-ui/tutorials/create-envs 98 | -------------------------------------------------------------------------------- /docs/docs/create-apps/plotly-dash-app.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 5 3 | --- 4 | 5 | # Plotly Dash apps 6 | 7 | ## Environment requirements 8 | 9 | Your conda environment (used in JHub Apps Launcher's App creation form) must have the following packages for successful app deployment: 10 | 11 | * `jhsingle-native-proxy` >= 0.8.2 12 | * `dash` 13 | * `plotlydash-tornado-cmd` 14 | * Other libraries used in the app 15 | 16 | ## Next steps 17 | 18 | :sparkles: [Launch app →](/docs/create-apps/general-app) 19 | -------------------------------------------------------------------------------- /docs/docs/create-apps/streamlit-app.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 8 3 | --- 4 | 5 | # Streamlit apps 6 | 7 | ## Environment requirements 8 | 9 | Your conda environment (used in JHub Apps Launcher's App creation form) must have the following packages for successful app deployment: 10 | 11 | * `jhsingle-native-proxy` >= 0.8.2 12 | * `streamlit` 13 | * Other libraries used in the app 14 | 15 | :::note 16 | In some cases, you may need `ipywidgets` 17 | ::: 18 | 19 | ## Example application 20 | 21 | To deploy the [Streamlit App][streamlit-app] using JHub Apps, you can use the following code and environment: 22 | 23 |
24 | Code (Python file) 25 | 26 | In a Python file, copy the following lines of code. 27 | 28 | ```python title="streamlit_app.py" 29 | from collections import namedtuple 30 | import altair as alt 31 | import math 32 | import pandas as pd 33 | import streamlit as st 34 | 35 | """ 36 | # Welcome to Streamlit! 37 | 38 | """ 39 | 40 | total_points = st.slider("Number of points in spiral", 1, 5000, 2000) 41 | num_turns = st.slider("Number of turns in spiral", 1, 100, 9) 42 | 43 | Point = namedtuple("Point", "x y") 44 | data = [] 45 | 46 | points_per_turn = total_points / num_turns 47 | 48 | for curr_point_num in range(total_points): 49 | curr_turn, i = divmod(curr_point_num, points_per_turn) 50 | angle = (curr_turn + 1) * 2 * math.pi * i / points_per_turn 51 | radius = curr_point_num / total_points 52 | x = radius * math.cos(angle) 53 | y = radius * math.sin(angle) 54 | data.append(Point(x, y)) 55 | 56 | st.altair_chart( 57 | alt.Chart(pd.DataFrame(data), height=500, width=500) 58 | .mark_circle(color="#0068c9", opacity=0.5) 59 | .encode(x="x:Q", y="y:Q") 60 | ) 61 | ``` 62 | 63 | You will see an app that displays a spiral of points: 64 | 65 | ![Streamlit interactive plot of a spiral of points with two sliders to adjust the number of points and the number of turns in the spiral](/img/streamlit_app.png) 66 |
67 | 68 |
69 | Environment specification 70 | 71 | Use the following spec to create a Conda environment wherever JHub Apps is deployed. 72 | If using Nebari, use this spec to create an environment with [conda-store][conda-store]. 73 | 74 | ```yaml 75 | channels: 76 | - conda-forge 77 | dependencies: 78 | - altair 79 | - jhsingle-native-proxy>=0.8.2 80 | - pandas 81 | - streamlit 82 | - ipykernel 83 | ``` 84 |
85 | 86 | 87 | ## Next steps 88 | 89 | :sparkles: [Launch app →](/docs/create-apps/general-app) 90 | 91 | 92 | 93 | [streamlit-app]: https://github.com/streamlit/streamlit-example/blob/8bd2197e4ba68dd68127a264dc6708f0a96f23c8/streamlit_app.py 94 | [conda-store]: https://conda.store/conda-store-ui/tutorials/create-envs 95 | -------------------------------------------------------------------------------- /docs/docs/create-apps/voila-app.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 7 3 | --- 4 | 5 | # Voila apps 6 | 7 | ## Environment requirements 8 | 9 | Your conda environment (used in JHub Apps Launcher's App creation form) must have the following packages for successful app deployment: 10 | 11 | * `jhsingle-native-proxy` >= 0.8.2 12 | * `voila` >= 0.5.6 13 | * Other libraries used in the app 14 | 15 | :::note 16 | In some cases, you may need `ipywidgets` 17 | ::: 18 | 19 | ## Example application 20 | 21 | To deploy the [Voila Basic Example][voila-basic-example] using JHub Apps, you can use the following code and environment: 22 | 23 |
24 | Code (Jupyter Notebook) 25 | 26 | In a Jupyter Notebook, copy the following lines of code into a cell. 27 | 28 | ```python title="voila-basic-slider.ipynb" 29 | import ipywidgets as widgets 30 | 31 | slider = widgets.FloatSlider(description='x') 32 | text = widgets.FloatText(disabled=True, description='x^2') 33 | 34 | def compute(*ignore): 35 | text.value = str(slider.value ** 2) 36 | 37 | slider.observe(compute, 'value') 38 | 39 | slider.value = 4 40 | 41 | widgets.VBox([slider, text]) 42 | ``` 43 | 44 | You will see a basic slider app as shown in this screenshot: 45 | 46 | ![Simple interactive Voilà app displaying a slider for variable x and its squared value](/img/voila_app.png) 47 |
48 | 49 |
50 | Environment specification 51 | 52 | Use the following spec to create a Conda environment wherever JHub Apps is deployed. 53 | If using Nebari, use this spec to create an environment with [conda-store][conda-store]. 54 | 55 | ```yaml 56 | channels: 57 | - conda-forge 58 | dependencies: 59 | - ipywidgets 60 | - jhsingle-native-proxy>=0.8.2 61 | - pandas 62 | - python 63 | - pip 64 | - pip: 65 | - voila==0.5.6 66 | - ipykernel 67 | ``` 68 | :::note 69 | When voila (>=0.5.6) is available on Conda Forge, it can be moved outside of the pip section of the dependencies 70 | ::: 71 | 72 |
73 | 74 | 75 | ## Next steps 76 | 77 | :sparkles: [Launch app →](/docs/create-apps/general-app) 78 | 79 | 80 | 81 | [voila-basic-example]: https://github.com/voila-dashboards/voila/blob/7596c4f930caf4fc2d89ba63b1096046adf9fe0e/notebooks/basics.ipynb 82 | [conda-store]: https://conda.store/conda-store-ui/tutorials/create-envs 83 | -------------------------------------------------------------------------------- /docs/docs/installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | import Tabs from '@theme/Tabs'; 6 | import TabItem from '@theme/TabItem'; 7 | 8 | # Install and setup 9 | 10 | JHub Apps can be integrated with most JupyterHub (> 4) installations, but we officially support JupyterHub(s) with following Spawners: 11 | 12 | - `KubeSpawner` 13 | - `SimpleLocalProcessSpawner` 14 | - `DockerSpawner` 15 | 16 | ## Installation 17 | 18 | Pre-requisites: Python >= 3.8 19 | 20 | 21 | 22 | 23 | ```bash 24 | pip install jhub-apps 25 | ``` 26 | 27 | 28 | 29 | 30 | 31 | ```bash 32 | conda install -c conda-forge jhub-apps 33 | ``` 34 | 35 | 36 | 37 | 38 | ## Usage 39 | 40 | To integrate `jhub-apps` into your JupyterHub installation, add the following to your 41 | `jupyterhub_config.py`: 42 | 43 | ```python 44 | c.JAppsConfig.jupyterhub_config_path = "jupyterhub_config.py" 45 | c = install_jhub_apps(c, ) 46 | ``` 47 | -------------------------------------------------------------------------------- /docs/docs/intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | # Introduction 6 | 7 | JHub Apps (JupyterHub Apps) Launcher is a generalized server launcher. 8 | The goal of this project is to support launching anything like a Flask Server, FastAPI server, or a Panel Dashboard through a user-supplied command. 9 | 10 | ![JHub Apps Homepage](/img/homepage.png) 11 | 12 | Currently, JHub Apps supports the following frameworks: Panel, Bokeh, Streamlit, Plotly Dash, Voila, Gradio, JupyterLab, and any Generic Python Command. 13 | 14 | **Get started:** 15 | 16 | * [Learn to install JHub Apps →][install] 17 | * [Create and share your first app →][create-app] 18 | 19 | 20 | 21 | [install]: /docs/installation 22 | [create-app]: /docs/category/create-apps 23 | -------------------------------------------------------------------------------- /docs/docusaurus.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // `@type` JSDoc annotations allow editor autocompletion and type checking 3 | // (when paired with `@ts-check`). 4 | // There are various equivalent ways to declare your Docusaurus config. 5 | // See: https://docusaurus.io/docs/api/docusaurus-config 6 | 7 | import {themes as prismThemes} from 'prism-react-renderer'; 8 | 9 | /** @type {import('@docusaurus/types').Config} */ 10 | const config = { 11 | title: 'JHub Apps', 12 | tagline: 'JupyterHub Apps Launcher, a generalized server launcher.', 13 | favicon: 'img/favicon.ico', 14 | 15 | // Set the production url of your site here 16 | url: 'https://jhub-apps.nebari.dev/', 17 | // Set the // pathname under which your site is served 18 | // For GitHub pages deployment, it is often '//' 19 | baseUrl: '/', 20 | 21 | // GitHub pages deployment config. 22 | // If you aren't using GitHub pages, you don't need these. 23 | organizationName: 'nebari-dev', // Usually your GitHub org/user name. 24 | projectName: 'jhub-apps', // Usually your repo name. 25 | 26 | onBrokenLinks: 'throw', 27 | onBrokenMarkdownLinks: 'warn', 28 | 29 | // Even if you don't use internationalization, you can use this field to set 30 | // useful metadata like html lang. For example, if your site is Chinese, you 31 | // may want to replace "en" with "zh-Hans". 32 | i18n: { 33 | defaultLocale: 'en', 34 | locales: ['en'], 35 | }, 36 | 37 | presets: [ 38 | [ 39 | 'classic', 40 | /** @type {import('@docusaurus/preset-classic').Options} */ 41 | ({ 42 | docs: { 43 | sidebarPath: './sidebars.js', 44 | // Please change this to your repo. 45 | // Remove this to remove the "edit this page" links. 46 | editUrl: 47 | 'https://github.com/nebari-dev/jhub-apps/tree/main/docs', 48 | }, 49 | blog: false, 50 | theme: { 51 | customCss: './src/css/custom.css', 52 | }, 53 | }), 54 | ], 55 | ], 56 | 57 | themeConfig: 58 | /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ 59 | ({ 60 | // Replace with your project's social card 61 | image: 'img/docusaurus-social-card.jpg', 62 | navbar: { 63 | title: 'JHub Apps', 64 | logo: { 65 | alt: 'Nebari Logo', 66 | src: 'img/logo.svg', 67 | }, 68 | items: [ 69 | { 70 | type: 'docSidebar', 71 | sidebarId: 'tutorialSidebar', 72 | position: 'left', 73 | label: 'Documentation', 74 | }, 75 | { 76 | href: 'https://github.com/nebari-dev/jhub-apps', 77 | label: 'GitHub', 78 | position: 'right', 79 | }, 80 | ], 81 | }, 82 | footer: { 83 | style: 'dark', 84 | links: [ 85 | { 86 | title: 'Community', 87 | items: [ 88 | { 89 | label: 'Stack Overflow', 90 | href: 'https://stackoverflow.com/questions/tagged/jhub-apps', 91 | }, 92 | { 93 | label: 'Nebari', 94 | href: 'https://nebari.dev', 95 | }, 96 | ], 97 | }, 98 | ], 99 | copyright: `Copyright © ${new Date().getFullYear()} JHub Apps Development Team. Built with Docusaurus.`, 100 | }, 101 | prism: { 102 | theme: prismThemes.github, 103 | darkTheme: prismThemes.dracula, 104 | }, 105 | }), 106 | }; 107 | 108 | export default config; 109 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jhub-apps", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids" 15 | }, 16 | "dependencies": { 17 | "@docusaurus/core": "3.1.0", 18 | "@docusaurus/preset-classic": "3.1.0", 19 | "@mdx-js/react": "^3.0.0", 20 | "clsx": "^2.0.0", 21 | "prism-react-renderer": "^2.3.0", 22 | "react": "^18.0.0", 23 | "react-dom": "^18.0.0" 24 | }, 25 | "devDependencies": { 26 | "@docusaurus/module-type-aliases": "3.1.0", 27 | "@docusaurus/types": "3.1.0" 28 | }, 29 | "browserslist": { 30 | "production": [ 31 | ">0.5%", 32 | "not dead", 33 | "not op_mini all" 34 | ], 35 | "development": [ 36 | "last 3 chrome version", 37 | "last 3 firefox version", 38 | "last 5 safari version" 39 | ] 40 | }, 41 | "engines": { 42 | "node": ">=18.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /docs/sidebars.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creating a sidebar enables you to: 3 | - create an ordered group of docs 4 | - render a sidebar for each doc of that group 5 | - provide next/previous navigation 6 | 7 | The sidebars can be generated from the filesystem, or explicitly defined here. 8 | 9 | Create as many sidebars as you want. 10 | */ 11 | 12 | // @ts-check 13 | 14 | /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ 15 | const sidebars = { 16 | // By default, Docusaurus generates a sidebar from the docs folder structure 17 | tutorialSidebar: [{type: 'autogenerated', dirName: '.'}], 18 | 19 | // But you can create a sidebar manually 20 | /* 21 | tutorialSidebar: [ 22 | 'intro', 23 | 'hello', 24 | { 25 | type: 'category', 26 | label: 'Tutorial', 27 | items: ['tutorial-basics/create-a-document'], 28 | }, 29 | ], 30 | */ 31 | }; 32 | 33 | export default sidebars; 34 | -------------------------------------------------------------------------------- /docs/src/components/HomepageFeatures/index.js: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import Heading from '@theme/Heading'; 3 | import styles from './styles.module.css'; 4 | 5 | const FeatureList = [ 6 | // { 7 | // title: 'Easy to Use', 8 | // Svg: require('@site/static/img/undraw_docusaurus_mountain.svg').default, 9 | // description: ( 10 | // <> 11 | // Docusaurus was designed from the ground up to be easily installed and 12 | // used to get your website up and running quickly. 13 | // 14 | // ), 15 | // }, 16 | // { 17 | // title: 'Focus on What Matters', 18 | // Svg: require('@site/static/img/undraw_docusaurus_tree.svg').default, 19 | // description: ( 20 | // <> 21 | // Docusaurus lets you focus on your docs, and we'll do the chores. Go 22 | // ahead and move your docs into the docs directory. 23 | // 24 | // ), 25 | // }, 26 | // { 27 | // title: 'Powered by React', 28 | // Svg: require('@site/static/img/undraw_docusaurus_react.svg').default, 29 | // description: ( 30 | // <> 31 | // Extend or customize your website layout by reusing React. Docusaurus can 32 | // be extended while reusing the same header and footer. 33 | // 34 | // ), 35 | // }, 36 | ]; 37 | 38 | function Feature({Svg, title, description}) { 39 | return ( 40 |
41 |
42 | 43 |
44 |
45 | {title} 46 |

{description}

47 |
48 |
49 | ); 50 | } 51 | 52 | export default function HomepageFeatures() { 53 | return ( 54 |
55 |
56 |
57 | {FeatureList.map((props, idx) => ( 58 | 59 | ))} 60 |
61 |
62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /docs/src/components/HomepageFeatures/styles.module.css: -------------------------------------------------------------------------------- 1 | .features { 2 | display: flex; 3 | align-items: center; 4 | padding: 2rem 0; 5 | width: 100%; 6 | } 7 | 8 | .featureSvg { 9 | height: 200px; 10 | width: 200px; 11 | } 12 | -------------------------------------------------------------------------------- /docs/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Any CSS included here will be global. The classic template 3 | * bundles Infima by default. Infima is a CSS framework designed to 4 | * work well for content-centric websites. 5 | */ 6 | 7 | /* You can override the default Infima variables here. */ 8 | :root { 9 | --ifm-color-primary: #2e8555; 10 | --ifm-color-primary-dark: #29784c; 11 | --ifm-color-primary-darker: #277148; 12 | --ifm-color-primary-darkest: #205d3b; 13 | --ifm-color-primary-light: #33925d; 14 | --ifm-color-primary-lighter: #359962; 15 | --ifm-color-primary-lightest: #3cad6e; 16 | --ifm-code-font-size: 95%; 17 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); 18 | } 19 | 20 | /* For readability concerns, you should choose a lighter palette in dark mode. */ 21 | [data-theme='dark'] { 22 | --ifm-color-primary: #25c2a0; 23 | --ifm-color-primary-dark: #21af90; 24 | --ifm-color-primary-darker: #1fa588; 25 | --ifm-color-primary-darkest: #1a8870; 26 | --ifm-color-primary-light: #29d5b0; 27 | --ifm-color-primary-lighter: #32d8b4; 28 | --ifm-color-primary-lightest: #4fddbf; 29 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); 30 | } 31 | -------------------------------------------------------------------------------- /docs/src/pages/index.module.css: -------------------------------------------------------------------------------- 1 | /** 2 | * CSS files with the .module.css suffix will be treated as CSS modules 3 | * and scoped locally. 4 | */ 5 | 6 | .heroBanner { 7 | padding: 4rem 0; 8 | text-align: center; 9 | position: relative; 10 | overflow: hidden; 11 | } 12 | 13 | @media screen and (max-width: 996px) { 14 | .heroBanner { 15 | padding: 2rem; 16 | } 17 | } 18 | 19 | .buttons { 20 | display: flex; 21 | align-items: center; 22 | justify-content: center; 23 | } 24 | 25 | .framework { 26 | display: flex; 27 | align-items: center; 28 | padding: 2rem 0; 29 | width: 100%; 30 | } 31 | -------------------------------------------------------------------------------- /docs/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/docs/static/.nojekyll -------------------------------------------------------------------------------- /docs/static/img/app_card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/docs/static/img/app_card.png -------------------------------------------------------------------------------- /docs/static/img/app_card_context_menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/docs/static/img/app_card_context_menu.png -------------------------------------------------------------------------------- /docs/static/img/create_app_env_var_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/docs/static/img/create_app_env_var_1.png -------------------------------------------------------------------------------- /docs/static/img/create_app_env_var_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/docs/static/img/create_app_env_var_2.png -------------------------------------------------------------------------------- /docs/static/img/create_app_info_and_configuration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/docs/static/img/create_app_info_and_configuration.png -------------------------------------------------------------------------------- /docs/static/img/create_app_select_server.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/docs/static/img/create_app_select_server.png -------------------------------------------------------------------------------- /docs/static/img/create_app_sharing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/docs/static/img/create_app_sharing.png -------------------------------------------------------------------------------- /docs/static/img/create_app_thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/docs/static/img/create_app_thumbnail.png -------------------------------------------------------------------------------- /docs/static/img/custom_app_creation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/docs/static/img/custom_app_creation.png -------------------------------------------------------------------------------- /docs/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/docs/static/img/favicon.ico -------------------------------------------------------------------------------- /docs/static/img/homepage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/docs/static/img/homepage.png -------------------------------------------------------------------------------- /docs/static/img/jhub_service_diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/docs/static/img/jhub_service_diagram.png -------------------------------------------------------------------------------- /docs/static/img/jhub_single_native_proxy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/docs/static/img/jhub_single_native_proxy.png -------------------------------------------------------------------------------- /docs/static/img/jhub_subsystems.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/docs/static/img/jhub_subsystems.png -------------------------------------------------------------------------------- /docs/static/img/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/static/img/logos/bokeh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/docs/static/img/logos/bokeh.png -------------------------------------------------------------------------------- /docs/static/img/logos/custom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/docs/static/img/logos/custom.png -------------------------------------------------------------------------------- /docs/static/img/logos/gradio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/docs/static/img/logos/gradio.png -------------------------------------------------------------------------------- /docs/static/img/logos/jupyter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/docs/static/img/logos/jupyter.png -------------------------------------------------------------------------------- /docs/static/img/logos/panel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/docs/static/img/logos/panel.png -------------------------------------------------------------------------------- /docs/static/img/logos/plotly-dash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/docs/static/img/logos/plotly-dash.png -------------------------------------------------------------------------------- /docs/static/img/logos/streamlit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/docs/static/img/logos/streamlit.png -------------------------------------------------------------------------------- /docs/static/img/logos/voila.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/docs/static/img/logos/voila.png -------------------------------------------------------------------------------- /docs/static/img/proxy-arg-overrides.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/docs/static/img/proxy-arg-overrides.png -------------------------------------------------------------------------------- /docs/static/img/streamlit_app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/docs/static/img/streamlit_app.png -------------------------------------------------------------------------------- /docs/static/img/voila_app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/docs/static/img/voila_app.png -------------------------------------------------------------------------------- /jhub_apps/__about__.py: -------------------------------------------------------------------------------- 1 | __version__ = "2025.8.1" 2 | -------------------------------------------------------------------------------- /jhub_apps/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from jhub_apps.config_utils import JAppsConfig # noqa: F401 4 | from jhub_apps.service_utils import service_for_jhub_apps # noqa: F401 5 | 6 | HERE = Path(__file__).parent.resolve() 7 | 8 | TEMPLATE_PATH = HERE.joinpath("templates") 9 | 10 | theme_template_paths = [TEMPLATE_PATH] 11 | -------------------------------------------------------------------------------- /jhub_apps/examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/jhub_apps/examples/__init__.py -------------------------------------------------------------------------------- /jhub_apps/examples/bokeh_basic.py: -------------------------------------------------------------------------------- 1 | from bokeh.models import ColumnDataSource 2 | from bokeh.plotting import figure 3 | from bokeh.io import curdoc 4 | 5 | 6 | def modify_doc(doc): 7 | """Add a plotted function to the document. 8 | 9 | Arguments: 10 | doc: A bokeh document to which elements can be added. 11 | """ 12 | x_values = list(range(10)) 13 | y_values = [x**2 for x in x_values] 14 | data_source = ColumnDataSource(data=dict(x=x_values, y=y_values)) 15 | plot = figure( 16 | title="f(x) = x^2", 17 | tools="crosshair,pan,reset,save,wheel_zoom", 18 | ) 19 | plot.line("x", "y", source=data_source, line_width=3, line_alpha=0.6) 20 | doc.add_root(plot) 21 | doc.title = "Hello World" 22 | 23 | 24 | def main(): 25 | modify_doc(curdoc()) 26 | 27 | 28 | main() 29 | -------------------------------------------------------------------------------- /jhub_apps/examples/gradio_basic.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import gradio as gr 3 | 4 | 5 | def greet(name): 6 | return "Hello " + name + "!" 7 | 8 | 9 | demo = gr.Interface(fn=greet, inputs="text", outputs="text") 10 | 11 | 12 | parser = argparse.ArgumentParser(description="Process CLI args for gradio") 13 | parser.add_argument( 14 | "--server-port", type=str, help="server_port for gradio app", default=8500 15 | ) 16 | parser.add_argument("--root-path", type=str, help="root_path for gradio", default=None) 17 | cli_args = parser.parse_args() 18 | 19 | demo.launch(server_port=int(cli_args.server_port), root_path=cli_args.root_path) 20 | -------------------------------------------------------------------------------- /jhub_apps/examples/panel_basic.py: -------------------------------------------------------------------------------- 1 | """We can use this to test the bokeh_root_cmd""" 2 | import panel as pn 3 | 4 | 5 | css = """ 6 | body { 7 | font-family: Mukta, sans-serif; 8 | } 9 | 10 | .center-text { 11 | text-align: center; 12 | } 13 | """ 14 | pn.extension(sizing_mode="stretch_width", raw_css=[css]) 15 | 16 | 17 | def test_panel_app(): 18 | """Returns a Panel test app that has been marked `.servable()` 19 | 20 | Returns: 21 | pn.Column: A Column based Panel app 22 | """ 23 | slider = pn.widgets.FloatSlider(name="Slider") 24 | return pn.template.FastListTemplate( 25 | title="Panel Test App", sidebar=[slider], main=[slider.param.value] 26 | ).servable() 27 | 28 | 29 | if __name__.startswith("bokeh"): 30 | test_panel_app() 31 | -------------------------------------------------------------------------------- /jhub_apps/examples/plotlydash_app.py: -------------------------------------------------------------------------------- 1 | from dash import Dash, html, dcc, callback, Output, Input 2 | import plotly.express as px 3 | import pandas as pd 4 | 5 | df = pd.read_csv( 6 | "https://raw.githubusercontent.com/plotly/datasets/master/gapminder_unfiltered.csv" 7 | ) 8 | 9 | app = Dash(__name__) 10 | 11 | app.layout = html.Div( 12 | [ 13 | html.H1(children="Plotly Dash App", style={"textAlign": "center"}), 14 | dcc.Dropdown(df.country.unique(), "Canada", id="dropdown-selection"), 15 | dcc.Graph(id="graph-content"), 16 | ] 17 | ) 18 | 19 | 20 | @callback(Output("graph-content", "figure"), Input("dropdown-selection", "value")) 21 | def update_graph(value): 22 | dff = df[df.country == value] 23 | return px.line(dff, x="year", y="pop") 24 | 25 | 26 | if __name__ == "__main__": 27 | app.run(debug=True) 28 | -------------------------------------------------------------------------------- /jhub_apps/examples/streamlit_app.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | import altair as alt 3 | import math 4 | import pandas as pd 5 | import streamlit as st 6 | 7 | """ 8 | # Welcome to Streamlit! 9 | 10 | Edit `/streamlit_app.py` to customize this app to your heart's desire :heart: 11 | 12 | If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community 13 | forums](https://discuss.streamlit.io). 14 | 15 | In the meantime, below is an example of what you can do with just a few lines of code: 16 | """ 17 | 18 | 19 | with st.echo(code_location="below"): 20 | total_points = st.slider("Number of points in spiral", 1, 5000, 2000) 21 | num_turns = st.slider("Number of turns in spiral", 1, 100, 9) 22 | 23 | Point = namedtuple("Point", "x y") 24 | data = [] 25 | 26 | points_per_turn = total_points / num_turns 27 | 28 | for curr_point_num in range(total_points): 29 | curr_turn, i = divmod(curr_point_num, points_per_turn) 30 | angle = (curr_turn + 1) * 2 * math.pi * i / points_per_turn 31 | radius = curr_point_num / total_points 32 | x = radius * math.cos(angle) 33 | y = radius * math.sin(angle) 34 | data.append(Point(x, y)) 35 | 36 | st.altair_chart( 37 | alt.Chart(pd.DataFrame(data), height=500, width=500) 38 | .mark_circle(color="#0068c9", opacity=0.5) 39 | .encode(x="x:Q", y="y:Q") 40 | ) 41 | -------------------------------------------------------------------------------- /jhub_apps/examples/voila_basic.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "source": [ 6 | "# So easy, *voilà*!\n", 7 | "\n", 8 | "In this example notebook, we demonstrate how Voilà can render Jupyter notebooks with interactions requiring a roundtrip to the kernel." 9 | ], 10 | "metadata": {} 11 | }, 12 | { 13 | "cell_type": "markdown", 14 | "source": [ 15 | "## Jupyter Widgets" 16 | ], 17 | "metadata": {} 18 | }, 19 | { 20 | "cell_type": "code", 21 | "execution_count": null, 22 | "source": [ 23 | "import ipywidgets as widgets\n", 24 | "\n", 25 | "slider = widgets.FloatSlider(description='$x$')\n", 26 | "text = widgets.FloatText(disabled=True, description='$x^2$')\n", 27 | "\n", 28 | "def compute(*ignore):\n", 29 | " text.value = str(slider.value ** 2)\n", 30 | "\n", 31 | "slider.observe(compute, 'value')\n", 32 | "\n", 33 | "slider.value = 4\n", 34 | "\n", 35 | "widgets.VBox([slider, text])" 36 | ], 37 | "outputs": [], 38 | "metadata": {} 39 | }, 40 | { 41 | "cell_type": "markdown", 42 | "source": [ 43 | "## Basic outputs of code cells" 44 | ], 45 | "metadata": {} 46 | }, 47 | { 48 | "cell_type": "code", 49 | "execution_count": null, 50 | "source": [ 51 | "import pandas as pd\n", 52 | "\n", 53 | "iris = pd.read_csv('https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv')\n", 54 | "iris" 55 | ], 56 | "outputs": [], 57 | "metadata": {} 58 | }, 59 | { 60 | "cell_type": "code", 61 | "execution_count": null, 62 | "source": [], 63 | "outputs": [], 64 | "metadata": {} 65 | } 66 | ], 67 | "metadata": { 68 | "kernelspec": { 69 | "display_name": "Python 3", 70 | "language": "python", 71 | "name": "python3" 72 | }, 73 | "language_info": { 74 | "codemirror_mode": { 75 | "name": "ipython", 76 | "version": 3 77 | }, 78 | "file_extension": ".py", 79 | "mimetype": "text/x-python", 80 | "name": "python", 81 | "nbconvert_exporter": "python", 82 | "pygments_lexer": "ipython3", 83 | "version": "3.8.5" 84 | } 85 | }, 86 | "nbformat": 4, 87 | "nbformat_minor": 4 88 | } 89 | -------------------------------------------------------------------------------- /jhub_apps/hub_client/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/jhub_apps/hub_client/__init__.py -------------------------------------------------------------------------------- /jhub_apps/hub_client/utils.py: -------------------------------------------------------------------------------- 1 | import jupyterhub 2 | 3 | 4 | def is_jupyterhub_5(): 5 | return jupyterhub.version_info[0] == 5 6 | -------------------------------------------------------------------------------- /jhub_apps/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from jhub_apps.version import get_version 4 | 5 | 6 | def app(): 7 | parser = argparse.ArgumentParser(description="Get the version of a package") 8 | parser.add_argument("--version", action="store_true", help="Print the version of jhub-apps") 9 | args = parser.parse_args() 10 | if args.version: 11 | version_info = get_version() 12 | print(version_info) 13 | -------------------------------------------------------------------------------- /jhub_apps/service/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/jhub_apps/service/__init__.py -------------------------------------------------------------------------------- /jhub_apps/service/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | from fastapi import FastAPI 5 | from fastapi.staticfiles import StaticFiles 6 | 7 | from jhub_apps.service.japps_routes import router as japps_router 8 | from jhub_apps.service.logging_utils import setup_logging 9 | from jhub_apps.service.middlewares import create_middlewares 10 | from jhub_apps.service.routes import router 11 | from jhub_apps.version import get_version 12 | import structlog 13 | 14 | logger = structlog.get_logger(__name__) 15 | setup_logging() 16 | 17 | ### When managed by Jupyterhub, the actual endpoints 18 | ### will be served out prefixed by /services/:name. 19 | ### One way to handle this with FastAPI is to use an APIRouter. 20 | ### All routes are defined in routes.py 21 | 22 | STATIC_DIR = Path(__file__).parent.parent / "static" 23 | 24 | logger.info("Starting jhub-apps service initialization", version=str(get_version()), static_dir=str(STATIC_DIR)) 25 | 26 | # Log critical environment variables 27 | logger.info("Loading environment variables", 28 | jupyterhub_api_url=os.environ.get("JUPYTERHUB_API_URL"), 29 | jupyterhub_service_prefix=os.environ.get("JUPYTERHUB_SERVICE_PREFIX"), 30 | jupyterhub_client_id=os.environ.get("JUPYTERHUB_CLIENT_ID"), 31 | public_host=os.environ.get("PUBLIC_HOST")) 32 | 33 | try: 34 | logger.info("Creating FastAPI application") 35 | app = FastAPI( 36 | title="JApps Service", 37 | version=str(get_version()), 38 | ### Serve out Swagger from the service prefix (/services/:name/docs) 39 | openapi_url=router.prefix + "/openapi.json", 40 | docs_url=router.prefix + "/docs", 41 | redoc_url=router.prefix + "/redoc", 42 | ### Add our service client id to the /docs Authorize form automatically 43 | swagger_ui_init_oauth={"clientId": os.environ["JUPYTERHUB_CLIENT_ID"]}, 44 | swagger_ui_parameters={"persistAuthorization": True}, 45 | ### Default /docs/oauth2 redirect will cause Hub 46 | ### to raise oauth2 redirect uri mismatch errors 47 | # swagger_ui_oauth2_redirect_url=os.environ["JUPYTERHUB_OAUTH_CALLBACK_URL"], 48 | ) 49 | logger.info("FastAPI application created successfully") 50 | 51 | logger.info("Mounting static files", static_dir=str(STATIC_DIR), prefix=f"{router.prefix}/static") 52 | static_files = StaticFiles(directory=STATIC_DIR) 53 | app.mount(f"{router.prefix}/static", static_files, name="static") 54 | logger.info("Static files mounted successfully") 55 | 56 | logger.info("Including main router", prefix=router.prefix) 57 | app.include_router(router) 58 | logger.info("Main router included successfully") 59 | 60 | logger.info("Including japps router") 61 | app.include_router(japps_router) 62 | logger.info("Japps router included successfully") 63 | 64 | logger.info("Creating middlewares") 65 | create_middlewares(app) 66 | logger.info("Middlewares created successfully") 67 | 68 | logger.info("jhub-apps service started successfully", version=str(get_version())) 69 | 70 | @app.on_event("startup") 71 | async def startup_event(): 72 | logger.info("FastAPI startup event triggered - application is ready to serve requests") 73 | 74 | @app.on_event("shutdown") 75 | async def shutdown_event(): 76 | logger.info("FastAPI shutdown event triggered - application is stopping") 77 | 78 | except Exception as e: 79 | logger.error("Failed to start jhub-apps service", error=str(e), error_type=type(e).__name__) 80 | raise 81 | -------------------------------------------------------------------------------- /jhub_apps/service/auth.py: -------------------------------------------------------------------------------- 1 | import structlog 2 | import os 3 | import typing 4 | from datetime import timedelta, datetime 5 | 6 | import jwt 7 | from fastapi import HTTPException, status 8 | 9 | logger = structlog.get_logger(__name__) 10 | 11 | 12 | def _create_access_token(data: dict, expires_delta: typing.Optional[timedelta] = None): 13 | logger.info("Creating access token") 14 | to_encode = data.copy() 15 | if expires_delta: 16 | expire = datetime.utcnow() + expires_delta 17 | else: 18 | expire = datetime.utcnow() + timedelta(minutes=15) 19 | to_encode.update({"exp": expire}) 20 | secret_key = os.environ["JHUB_APP_JWT_SECRET_KEY"] 21 | encoded_jwt = jwt.encode(to_encode, secret_key, algorithm="HS256") 22 | return encoded_jwt 23 | 24 | 25 | def _get_jhub_token_from_jwt_token(token): 26 | logger.info("Trying to get JHub Apps token from JWT Token") 27 | credentials_exception = HTTPException( 28 | status_code=status.HTTP_401_UNAUTHORIZED, 29 | detail={ 30 | "msg": "Could not validate credentials" 31 | }, 32 | headers={"WWW-Authenticate": "Bearer"}, 33 | ) 34 | try: 35 | payload = jwt.decode(token, os.environ["JHUB_APP_JWT_SECRET_KEY"], algorithms=["HS256"]) 36 | access_token_data: dict = payload.get("sub") 37 | if access_token_data is None: 38 | raise credentials_exception 39 | except jwt.PyJWTError as e: 40 | logger.warning("Authentication failed for token") 41 | logger.exception(e) 42 | raise credentials_exception 43 | logger.info("Fetched access token from JWT Token") 44 | return access_token_data["access_token"] 45 | -------------------------------------------------------------------------------- /jhub_apps/service/client.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import httpx 4 | import structlog 5 | 6 | 7 | logger = structlog.get_logger(__name__) 8 | 9 | 10 | # a minimal alternative to using HubOAuth class 11 | def get_client(): 12 | base_url = os.environ["JUPYTERHUB_API_URL"] 13 | token = os.environ["JUPYTERHUB_API_TOKEN"] 14 | headers = {"Authorization": "Bearer %s" % token} 15 | # Increase timeout to handle hairpin NAT delays in local clusters (kind/k3d) 16 | timeout = httpx.Timeout(30.0, connect=30.0) 17 | logger.info("Creating httpx client", base_url=base_url, timeout=str(timeout)) 18 | return httpx.AsyncClient(base_url=base_url, headers=headers, timeout=timeout) 19 | -------------------------------------------------------------------------------- /jhub_apps/service/japps_routes.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | from fastapi import APIRouter, FastAPI, Request 4 | from fastapi.responses import HTMLResponse 5 | from fastapi.templating import Jinja2Templates 6 | 7 | from jhub_apps import TEMPLATE_PATH, themes 8 | from jhub_apps.service.utils import get_jupyterhub_config, get_theme 9 | 10 | app = FastAPI() 11 | 12 | templates = Jinja2Templates(directory=TEMPLATE_PATH) 13 | router = APIRouter(prefix="/services/japps") 14 | 15 | 16 | @router.get("/create-app", response_class=HTMLResponse) 17 | @router.get("/edit-app", response_class=HTMLResponse) 18 | @router.get("/server-types", response_class=HTMLResponse) 19 | @router.get("/success", response_class=HTMLResponse) 20 | async def handle_apps(request: Request): 21 | now = datetime.now(timezone.utc) 22 | config = get_jupyterhub_config() 23 | theme = get_theme(config) 24 | if not theme: 25 | theme = themes.DEFAULT_THEME 26 | return templates.TemplateResponse( 27 | "japps_custom.html", 28 | { 29 | "request": request, 30 | "version_hash": now.strftime("%Y%m%d%H%M%S"), 31 | "hub_title": config.get("hub_title", "JupyterHub"), 32 | "favicon": theme.get("favicon", "/service/japps/static/favicon.ico"), 33 | **theme, 34 | }, 35 | ) 36 | -------------------------------------------------------------------------------- /jhub_apps/service/logging_utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import structlog 4 | 5 | 6 | def setup_logging(): 7 | logging_format = ( 8 | "%(asctime)s %(levelname)9s %(name)s:%(lineno)4s: %(message)s" 9 | ) 10 | logging.basicConfig( 11 | level=logging.INFO, format=logging_format 12 | ) 13 | structlog.configure( 14 | processors=[ 15 | structlog.processors.StackInfoRenderer(), 16 | structlog.processors.format_exc_info, 17 | structlog.contextvars.merge_contextvars, 18 | structlog.processors.KeyValueRenderer( 19 | key_order=["event", "view", "peer"] 20 | ), 21 | ], 22 | logger_factory=structlog.stdlib.LoggerFactory(), 23 | ) 24 | -------------------------------------------------------------------------------- /jhub_apps/service/middlewares.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from fastapi import Request, Response 4 | 5 | import structlog 6 | 7 | 8 | def create_middlewares(app): 9 | @app.middleware("http") 10 | async def logging_middleware(request: Request, call_next) -> Response: 11 | structlog.contextvars.clear_contextvars() 12 | structlog.contextvars.bind_contextvars( 13 | request_id=str(uuid.uuid4()), 14 | ) 15 | 16 | response: Response = await call_next(request) 17 | return response 18 | return app 19 | -------------------------------------------------------------------------------- /jhub_apps/service/models.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from datetime import datetime 3 | from typing import Any, Dict, List, Optional 4 | 5 | 6 | from pydantic import BaseModel 7 | 8 | # https://jupyterhub.readthedocs.io/en/stable/_static/rest-api/index.html 9 | class Server(BaseModel): 10 | name: str 11 | ready: bool 12 | pending: Optional[str] 13 | url: str 14 | progress_url: str 15 | started: datetime 16 | last_activity: datetime 17 | state: Optional[Any] 18 | user_options: Optional[Any] 19 | 20 | 21 | class SharePermissions(BaseModel): 22 | users: List[str] 23 | groups: List[str] 24 | 25 | 26 | class User(BaseModel): 27 | name: str 28 | admin: bool 29 | groups: Optional[List[str]] 30 | kind: str 31 | server: Optional[str] = None 32 | pending: Optional[str] = None 33 | last_activity: Optional[datetime] = None 34 | servers: Optional[Dict[str, Server]] = None 35 | scopes: List[str] 36 | auth_state: Optional[Dict] = None 37 | share_permissions: typing.Optional[SharePermissions] = None 38 | 39 | 40 | # https://stackoverflow.com/questions/64501193/fastapi-how-to-use-httpexception-in-responses 41 | class AuthorizationError(BaseModel): 42 | detail: str 43 | 44 | 45 | class HubResponse(BaseModel): 46 | msg: str 47 | request_url: str 48 | token: str 49 | response_code: int 50 | hub_response: dict 51 | 52 | 53 | class HubApiError(BaseModel): 54 | detail: HubResponse 55 | 56 | 57 | class Repository(BaseModel): 58 | url: str 59 | config_directory: str = "." 60 | # git ref 61 | ref: str = "main" 62 | 63 | 64 | class JHubAppConfig(BaseModel): 65 | display_name: str 66 | description: str 67 | thumbnail: str = None 68 | filepath: typing.Optional[str] = str() 69 | framework: str = "panel" 70 | custom_command: typing.Optional[str] = str() 71 | # Make app available to public (unauthenticated Hub users) 72 | public: typing.Optional[bool] = False 73 | # Keep app alive, even when there is no activity 74 | # So that it's not killed by idle culler 75 | keep_alive: typing.Optional[bool] = False 76 | # Environment variables 77 | env: typing.Optional[dict] = dict() 78 | repository: typing.Optional[Repository] = None 79 | 80 | 81 | class UserOptions(JHubAppConfig): 82 | conda_env: typing.Optional[str] = str() 83 | profile: typing.Optional[str] = str() 84 | profile_image: typing.Optional[str] = str() 85 | share_with: typing.Optional[SharePermissions] = None 86 | jhub_app: bool 87 | 88 | 89 | class ServerCreation(BaseModel): 90 | servername: str 91 | user_options: UserOptions 92 | 93 | @property 94 | def normalized_servername(self): 95 | from jhub_apps.hub_client.hub_client import HubClient 96 | return HubClient.normalize_server_name(self.servername) 97 | 98 | class JHubAppUserOptions(UserOptions): 99 | jhub_app: typing.Literal[True] = True 100 | 101 | class StartupApp(ServerCreation): 102 | username: str 103 | user_options: JHubAppUserOptions 104 | 105 | 106 | class AdditionalService(BaseModel): 107 | """Configuration for an additional external service in JupyterHub. 108 | 109 | These services appear in the JupyterHub UI services menu. 110 | Services with pinned=True also appear in the quick access section. 111 | """ 112 | name: str 113 | url: str 114 | description: Optional[str] = None 115 | pinned: bool = False 116 | thumbnail: Optional[str] = None -------------------------------------------------------------------------------- /jhub_apps/service/security.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | from fastapi import HTTPException, Security, status 5 | from fastapi.security import OAuth2AuthorizationCodeBearer, APIKeyCookie 6 | from fastapi.security.api_key import APIKeyQuery 7 | 8 | from jhub_apps.hub_client.hub_client import get_users_and_group_allowed_to_share_with, is_jupyterhub_5 9 | from .auth import _get_jhub_token_from_jwt_token 10 | from .client import get_client 11 | from .models import User 12 | 13 | ### Endpoints can require authentication using Depends(get_current_user) 14 | ### get_current_user will look for a token in url params or 15 | ### Authorization: bearer token (header). 16 | ### Hub technically supports cookie auth too, but it is deprecated so 17 | ### not being included here. 18 | JHUB_APPS_AUTH_COOKIE_NAME = "jhub_apps_access_token" 19 | 20 | auth_by_param = APIKeyQuery(name="token", auto_error=False) 21 | 22 | auth_by_cookie = APIKeyCookie(name=JHUB_APPS_AUTH_COOKIE_NAME) 23 | auth_by_cookie_deprecated = APIKeyCookie(name="access_token") # will be removed in next version 24 | auth_url = os.environ["PUBLIC_HOST"] + "/hub/api/oauth2/authorize" 25 | auth_by_header = OAuth2AuthorizationCodeBearer( 26 | authorizationUrl=auth_url, tokenUrl="oauth_callback", auto_error=False 27 | ) 28 | ### ^^ The flow for OAuth2 in Swagger is that the "authorize" button 29 | ### will redirect user (browser) to "auth_url", which is the Hub login page. 30 | ### After logging in, the browser will POST to our internal /get_token endpoint 31 | ### with the auth code. That endpoint POST's to Hub /oauth2/token with 32 | ### our client_secret (JUPYTERHUB_API_TOKEN) and that code to get an 33 | ### access_token, which it returns to browser, which places in Authorization header. 34 | 35 | if os.environ.get("JUPYTERHUB_OAUTH_SCOPES"): 36 | # typically ["access:services", "access:services!service=$service_name"] 37 | access_scopes = json.loads(os.environ["JUPYTERHUB_OAUTH_SCOPES"]) 38 | else: 39 | access_scopes = ["access:services"] 40 | 41 | 42 | ### For consideration: optimize performance with a cache instead of 43 | ### always hitting the Hub api? 44 | async def get_current_user( 45 | auth_param: str = Security(auth_by_param), 46 | auth_header: str = Security(auth_by_header), 47 | auth_cookie: str = Security(auth_by_cookie), 48 | # auth_cookie_deprecated: str = Security(auth_by_cookie_deprecated), 49 | ): 50 | token = auth_param or auth_header or auth_cookie or auth_by_cookie_deprecated 51 | if token is None: 52 | raise HTTPException( 53 | status.HTTP_401_UNAUTHORIZED, 54 | detail="Must login with token parameter or Authorization bearer header", 55 | ) 56 | 57 | token = _get_jhub_token_from_jwt_token(token) 58 | 59 | async with get_client() as client: 60 | endpoint = "/user" 61 | # normally we auth to Hub API with service api token, 62 | # but this time auth as the user token to get user model 63 | headers = {"Authorization": f"Bearer {token}"} 64 | resp = await client.get(endpoint, headers=headers) 65 | if resp.is_error: 66 | raise HTTPException( 67 | status.HTTP_401_UNAUTHORIZED, 68 | detail={ 69 | "msg": "Error getting user info from token", 70 | "request_url": str(resp.request.url), 71 | "token": token, 72 | "response_code": resp.status_code, 73 | "hub_response": resp.json(), 74 | }, 75 | ) 76 | user = User(**resp.json()) 77 | if is_jupyterhub_5(): 78 | user.share_permissions = get_users_and_group_allowed_to_share_with(user) 79 | if any(scope in user.scopes for scope in access_scopes): 80 | return user 81 | else: 82 | raise HTTPException( 83 | status.HTTP_403_FORBIDDEN, 84 | detail={ 85 | "msg": f"User not authorized: {user.name}", 86 | "request_url": str(resp.request.url), 87 | "token": token, 88 | "user": resp.json(), 89 | }, 90 | ) 91 | -------------------------------------------------------------------------------- /jhub_apps/service_utils.py: -------------------------------------------------------------------------------- 1 | """Utilities for configuring JupyterHub services.""" 2 | from typing import Dict, Any, Optional 3 | 4 | from jhub_apps.service.models import AdditionalService 5 | 6 | 7 | def service_for_jhub_apps( 8 | name: str, 9 | url: str, 10 | description: Optional[str] = None, 11 | pinned: bool = False, 12 | thumbnail: Optional[str] = None 13 | ) -> Dict[str, Any]: 14 | """Create a service configuration dict for JupyterHub services. 15 | 16 | This helper function creates the proper structure for external services 17 | that appear in the JupyterHub UI services menu. Services with pinned=True 18 | also appear in the quick access section. It validates the input using 19 | the AdditionalService Pydantic model. 20 | 21 | Args: 22 | name: Display name of the service 23 | url: URL path for the service 24 | description: Optional description of the service 25 | pinned: Whether the service should appear in the quick access section 26 | thumbnail: Optional thumbnail URL or base64-encoded data URL for the service icon 27 | 28 | Returns: 29 | Dictionary with JupyterHub service configuration 30 | 31 | Raises: 32 | ValidationError: If the input parameters don't pass Pydantic validation 33 | 34 | Example: 35 | >>> from jhub_apps import service_for_jhub_apps 36 | >>> service = service_for_jhub_apps( 37 | ... name="Monitoring", 38 | ... url="/grafana", 39 | ... pinned=True, 40 | ... description="System monitoring dashboard" 41 | ... ) 42 | """ 43 | # Validate inputs using Pydantic model 44 | additional_service = AdditionalService( 45 | name=name, 46 | url=url, 47 | description=description, 48 | pinned=pinned, 49 | thumbnail=thumbnail, 50 | ) 51 | 52 | return { 53 | "name": additional_service.name, 54 | "display": True, 55 | "info": { 56 | "name": additional_service.name, 57 | "description": additional_service.description, 58 | "url": additional_service.url, 59 | "external": True, 60 | "pinned": additional_service.pinned, 61 | "thumbnail": additional_service.thumbnail, 62 | }, 63 | } 64 | 65 | 66 | def additional_service_to_service_dict(additional_service: AdditionalService) -> Dict[str, Any]: 67 | """Convert an AdditionalService model to a JupyterHub service dict. 68 | 69 | Args: 70 | additional_service: AdditionalService model instance 71 | 72 | Returns: 73 | Dictionary with JupyterHub service configuration 74 | """ 75 | return service_for_jhub_apps( 76 | name=additional_service.name, 77 | url=additional_service.url, 78 | description=additional_service.description, 79 | pinned=additional_service.pinned, 80 | thumbnail=additional_service.thumbnail, 81 | ) 82 | -------------------------------------------------------------------------------- /jhub_apps/spawner/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/jhub_apps/spawner/__init__.py -------------------------------------------------------------------------------- /jhub_apps/spawner/env.py: -------------------------------------------------------------------------------- 1 | """Environment variable utilities for jhub-apps spawner.""" 2 | import shlex 3 | 4 | import structlog 5 | 6 | 7 | logger = structlog.get_logger(__name__) 8 | 9 | 10 | def parse_proxy_args_from_env(env): 11 | """Parse jhub-app-proxy arguments from environment variables. 12 | 13 | Args: 14 | env: Environment variables dict (can be None) 15 | 16 | Returns: 17 | List of argument strings to be added to jhub-app-proxy command 18 | 19 | Looks for JHUB_APP_PROXY_ARGS environment variable containing space-separated 20 | arguments for jhub-app-proxy CLI. 21 | 22 | Example: 23 | JHUB_APP_PROXY_ARGS="--ready-check-path=/health --ready-timeout=600" 24 | """ 25 | if env is None: 26 | return [] 27 | proxy_args_str = env.get("JHUB_APP_PROXY_ARGS", "") 28 | if not proxy_args_str: 29 | return [] 30 | 31 | try: 32 | # Use shlex to properly parse the arguments (handles quotes, spaces, etc.) 33 | return shlex.split(proxy_args_str) 34 | except ValueError as e: 35 | logger.warning(f"Failed to parse JHUB_APP_PROXY_ARGS: {e}") 36 | return [] 37 | 38 | 39 | def merge_proxy_args(base_args, env_args): 40 | """Merge environment-provided proxy args with base args, avoiding duplicates. 41 | 42 | Args: 43 | base_args: List of base command arguments 44 | env_args: List of environment-provided arguments 45 | 46 | Returns: 47 | List of merged arguments with duplicates removed 48 | 49 | When an argument appears in both base_args and env_args, the version from 50 | env_args takes precedence (allowing user override). 51 | 52 | Handles both --flag and --key=value style arguments. 53 | """ 54 | if not env_args: 55 | return base_args 56 | 57 | # Extract flag names from arguments (handles --flag and --key=value) 58 | def get_flag_name(arg): 59 | """Extract the flag name from an argument.""" 60 | if not isinstance(arg, str) or not arg.startswith("--"): 61 | return None 62 | # Handle --key=value format 63 | if "=" in arg: 64 | return arg.split("=")[0] 65 | # Handle --flag format 66 | return arg 67 | 68 | # Build set of flags from env_args that should override base_args 69 | env_flags = {get_flag_name(arg) for arg in env_args if get_flag_name(arg)} 70 | 71 | # Filter out base_args that are overridden by env_args 72 | filtered_base_args = [ 73 | arg for arg in base_args 74 | if get_flag_name(arg) not in env_flags 75 | ] 76 | 77 | # Combine filtered base args with env args 78 | return filtered_base_args + env_args 79 | -------------------------------------------------------------------------------- /jhub_apps/spawner/types.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from enum import Enum 3 | from pathlib import Path 4 | 5 | HERE = Path(__file__).parent.parent.resolve() 6 | 7 | LOGO_BASE_PATH = "/services/japps/static/img/logos/", 8 | STATIC_PATH = HERE.joinpath("static/img/logos") 9 | 10 | 11 | @dataclass 12 | class FrameworkConf: 13 | name: str 14 | display_name: str 15 | logo_path: Path 16 | # logo url 17 | logo: str 18 | 19 | def json(self): 20 | return { 21 | "name": self.name, 22 | "display_name": self.display_name, 23 | "logo": self.logo, 24 | } 25 | 26 | 27 | class Framework(Enum): 28 | panel = "panel" 29 | bokeh = "bokeh" 30 | streamlit = "streamlit" 31 | plotlydash = "plotlydash" 32 | voila = "voila" 33 | gradio = "gradio" 34 | jupyterlab = "jupyterlab" 35 | custom = "custom" 36 | 37 | @classmethod 38 | def values(cls): 39 | return [member.value for role, member in cls.__members__.items()] 40 | 41 | 42 | FRAMEWORKS = [ 43 | FrameworkConf( 44 | name=Framework.panel.value, 45 | display_name="Panel", 46 | logo_path=STATIC_PATH.joinpath("panel.png"), 47 | logo=f"{LOGO_BASE_PATH}/panel.png" 48 | ), 49 | FrameworkConf( 50 | name=Framework.bokeh.value, 51 | display_name="Bokeh", 52 | logo_path=STATIC_PATH.joinpath("bokeh.png"), 53 | logo=f"{LOGO_BASE_PATH}/bokeh.png" 54 | ), 55 | FrameworkConf( 56 | name=Framework.streamlit.value, 57 | display_name="Streamlit", 58 | logo_path=STATIC_PATH.joinpath("streamlit.png"), 59 | logo=f"{LOGO_BASE_PATH}/streamlit.png" 60 | ), 61 | FrameworkConf( 62 | name=Framework.voila.value, 63 | display_name="Voila", 64 | logo_path=STATIC_PATH.joinpath("voila.png"), 65 | logo=f"{LOGO_BASE_PATH}/voila.png" 66 | ), 67 | FrameworkConf( 68 | name=Framework.plotlydash.value, 69 | display_name="PlotlyDash", 70 | logo_path=STATIC_PATH.joinpath("plotly-dash.png"), 71 | logo=f"{LOGO_BASE_PATH}/plotly-dash.png" 72 | ), 73 | FrameworkConf( 74 | name=Framework.gradio.value, 75 | display_name="Gradio", 76 | logo_path=STATIC_PATH.joinpath("gradio.png"), 77 | logo=f"{LOGO_BASE_PATH}/gradio.png" 78 | ), 79 | FrameworkConf( 80 | name=Framework.custom.value, 81 | display_name="Custom Command", 82 | logo_path=STATIC_PATH.joinpath("custom.png"), 83 | logo=f"{LOGO_BASE_PATH}/custom.png" 84 | ), 85 | FrameworkConf( 86 | name=Framework.jupyterlab.value, 87 | display_name="JupyterLab", 88 | logo_path=STATIC_PATH.joinpath("jupyter.png"), 89 | logo=f"{LOGO_BASE_PATH}/jupyter.png", 90 | ), 91 | ] 92 | FRAMEWORKS_MAPPING = {framework.name: framework for framework in FRAMEWORKS} 93 | -------------------------------------------------------------------------------- /jhub_apps/spawner/utils.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlparse 2 | 3 | 4 | def get_origin_host(bind_url): 5 | parsed_url = urlparse(bind_url) 6 | if "0.0.0.0" in parsed_url.netloc: 7 | # Hack: Useful for local development when using docker 8 | # Maybe take it from the user via JAppsConfig? 9 | return "127.0.0.1:8000" 10 | return parsed_url.netloc 11 | -------------------------------------------------------------------------------- /jhub_apps/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/jhub_apps/static/favicon.ico -------------------------------------------------------------------------------- /jhub_apps/static/img/Nebari-logo-square.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /jhub_apps/static/img/logos/bokeh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/jhub_apps/static/img/logos/bokeh.png -------------------------------------------------------------------------------- /jhub_apps/static/img/logos/custom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/jhub_apps/static/img/logos/custom.png -------------------------------------------------------------------------------- /jhub_apps/static/img/logos/gradio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/jhub_apps/static/img/logos/gradio.png -------------------------------------------------------------------------------- /jhub_apps/static/img/logos/jupyter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/jhub_apps/static/img/logos/jupyter.png -------------------------------------------------------------------------------- /jhub_apps/static/img/logos/panel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/jhub_apps/static/img/logos/panel.png -------------------------------------------------------------------------------- /jhub_apps/static/img/logos/plotly-dash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/jhub_apps/static/img/logos/plotly-dash.png -------------------------------------------------------------------------------- /jhub_apps/static/img/logos/streamlit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/jhub_apps/static/img/logos/streamlit.png -------------------------------------------------------------------------------- /jhub_apps/static/img/logos/voila.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/jhub_apps/static/img/logos/voila.png -------------------------------------------------------------------------------- /jhub_apps/static/img/logos/vscode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/jhub_apps/static/img/logos/vscode.png -------------------------------------------------------------------------------- /jhub_apps/templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} {% block title %} 404 Page Not Found {% endblock %} {% 2 | block main %} 3 | 4 |
5 |

404 : PAGE NOT FOUND

6 |

Sorry, the page you are looking for does not exist.

7 | Back to Home 8 |
9 | 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /jhub_apps/templates/home.html: -------------------------------------------------------------------------------- 1 | {% extends "japps_page.html" %} 2 | -------------------------------------------------------------------------------- /jhub_apps/templates/japps_custom.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ hub_title}} 6 | 7 | 8 | 9 | 10 |
11 | 12 | 16 | 19 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /jhub_apps/templates/japps_page.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} {% block main %} 2 | 3 |
4 | 5 | 9 | 16 | 17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /jhub_apps/templates/launcher_base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | JupyterHub Apps 5 | 6 | 7 | 8 | 9 | 10 | 11 | 23 | 24 | 25 | 26 |
27 |
28 | 43 |
44 |
45 |
46 | {{ script|safe }} 47 |
48 | 49 | 50 | -------------------------------------------------------------------------------- /jhub_apps/templates/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% extends "templates/login.html" %} {% set announcement = '' %} {% block main %} 5 |
6 |

{{ hub_title or 'Nebari' }}

7 |

{{ hub_subtitle }}

8 |

9 | {% if welcome %} {{ (welcome | safe) }} {% else %} Welcome to Nebari. For more information about Nebari, visit https://nebari.dev. {% endif %} 10 |

11 |
12 | 13 | {{ super() }} {% endblock main%} 14 | -------------------------------------------------------------------------------- /jhub_apps/templates/not_running.html: -------------------------------------------------------------------------------- 1 | {% extends "japps_page.html" %} 2 | -------------------------------------------------------------------------------- /jhub_apps/templates/oauth.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} {% block login_widget %} {% endblock %} {% block main 2 | %} 3 |
4 |

Authorize access

5 | 6 |

7 | An application is requesting authorization to access data associated with 8 | your JupyterHub account 9 |

10 | 11 |

12 | {{ oauth_client.description }} (oauth URL: {{ oauth_client.redirect_uri }}) 13 | would like permission to identify you. {% if scope_descriptions | length == 14 | 1 and not scope_descriptions[0].scope %} It will not be able to take actions 15 | on your behalf. {% endif %} 16 |

17 | 18 |

This will grant the application permission to:

19 |
20 |
21 | 22 | {# these are the 'real' inputs to the form -#} {% for scope in 23 | allowed_scopes %} 24 | 25 | {% endfor %} {% for scope_info in scope_descriptions %} 26 |
27 | 36 |
37 | {% endfor %} 38 | 39 |
40 |
41 |
42 | 43 | {% endblock %} 44 | -------------------------------------------------------------------------------- /jhub_apps/templates/page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% extends "templates/page.html" %} {% block nav_bar %} 5 | 45 | {% endblock %} {% block footer %} {% if display_version %} 46 |
{{ version or 'v0.0.1'}}
47 | {% endif %} {% endblock footer%} {% block stylesheet %} 49 | 52 | 53 | {% set jsurls = nebari_theme_extra_js_urls | default([]) %} {% for jsurl in 54 | jsurls %} 55 | 56 | {% endfor %} {% endblock stylesheet %} 57 | 58 | 59 | {% block title %}{{ hub_title or 'JupyterHub'}}{% endblock %} {% block favicon 60 | %} {% endblock %} 62 | -------------------------------------------------------------------------------- /jhub_apps/templates/server_options.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 5 |
6 |
7 | 8 | 9 |
10 | -------------------------------------------------------------------------------- /jhub_apps/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/jhub_apps/tests/__init__.py -------------------------------------------------------------------------------- /jhub_apps/tests/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/jhub_apps/tests/common/__init__.py -------------------------------------------------------------------------------- /jhub_apps/tests/common/api_fixtures.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import pytest 4 | from fastapi.testclient import TestClient 5 | from jhub_apps.tests.common.constants import MOCK_USER 6 | 7 | 8 | @pytest.fixture 9 | def client(): 10 | logging_format = ( 11 | "%(asctime)s %(levelname)9s %(name)s:%(lineno)4s: %(message)s" 12 | ) 13 | logging.basicConfig( 14 | level=logging.INFO, format=logging_format 15 | ) 16 | os.environ["PUBLIC_HOST"] = "/" 17 | os.environ["JUPYTERHUB_CLIENT_ID"] = "test-client-id" 18 | os.environ["JUPYTERHUB_OAUTH_CALLBACK_URL"] = "/" 19 | from jhub_apps.service.app import app 20 | from jhub_apps.service.security import get_current_user 21 | 22 | async def mock_get_user_name(): 23 | return MOCK_USER 24 | 25 | app.dependency_overrides[get_current_user] = mock_get_user_name 26 | return TestClient(app=app) 27 | 28 | -------------------------------------------------------------------------------- /jhub_apps/tests/common/constants.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | MOCK_USER = Mock() 4 | MOCK_USER.name = "jovyan" 5 | 6 | JUPYTERHUB_HOSTNAME = "127.0.0.1:8000" 7 | JUPYTERHUB_USERNAME = "admin" 8 | JUPYTERHUB_PASSWORD = "admin" 9 | JHUB_APPS_API_BASE_URL = f"http://{JUPYTERHUB_HOSTNAME}/services/japps" 10 | -------------------------------------------------------------------------------- /jhub_apps/tests/conftest.py: -------------------------------------------------------------------------------- 1 | pytest_plugins = ["jhub_apps.tests.common.api_fixtures"] 2 | -------------------------------------------------------------------------------- /jhub_apps/tests/tests_e2e/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/jhub_apps/tests/tests_e2e/__init__.py -------------------------------------------------------------------------------- /jhub_apps/tests/tests_e2e/test_startup_apps.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import time 3 | import logging 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | def retry_test(max_attempts=5, delay=1): 8 | def decorator(test_func): 9 | @functools.wraps(test_func) 10 | def wrapper(*args, **kwargs): 11 | last_exception = None 12 | for attempt in range(max_attempts): 13 | try: 14 | logger.info(f"Attempt {attempt + 1}/{max_attempts} for {test_func.__name__}") 15 | result = test_func(*args, **kwargs) 16 | return result 17 | except AssertionError as e: 18 | last_exception = e 19 | logger.warning(f"Attempt {attempt + 1} failed: {e}") 20 | if attempt < max_attempts - 1: 21 | time.sleep(delay) 22 | raise last_exception 23 | return wrapper 24 | return decorator 25 | 26 | 27 | def test_startup_apps(jupyterhub_manager): 28 | from jhub_apps.hub_client.hub_client import HubClient 29 | 30 | # get admin servers 31 | hc = HubClient(username="admin") 32 | 33 | expected_servernames = [hc.normalize_server_name(name) for name in["admin's-startup-server", "admin's-2nd-startup-server"]] 34 | 35 | # retry is a hack since we don't have a way to tell when the startup servers are ready at the moment 36 | @retry_test() 37 | def check_for_servernames(hc, expected_servernames): 38 | admin_servers = hc.get_server("admin") 39 | 40 | for servername in expected_servernames: 41 | assert servername in admin_servers 42 | 43 | check_for_servernames(hc, expected_servernames) 44 | -------------------------------------------------------------------------------- /jhub_apps/tests/tests_unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/jhub_apps/tests/tests_unit/__init__.py -------------------------------------------------------------------------------- /jhub_apps/tests/tests_unit/test_app_from_git.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | from jhub_apps.service.app_from_git import _extract_jhub_apps_config_from_conda_project_config 4 | 5 | 6 | def test_extract_jhub_apps_config_from_conda_project_config(): 7 | conda_project_yaml = Mock(variables={ 8 | "JHUB_APP_CONFIG_name": "My Panel App (Git)", 9 | "JHUB_APP_CONFIG_description": "This is a panel app created from git repository", 10 | "JHUB_APP_CONFIG_framework": "panel", 11 | "JHUB_APP_CONFIG_filepath": "panel_basic.py", 12 | "JHUB_APP_CONFIG_keep_alive": "false", 13 | "JHUB_APP_CONFIG_public": "false", 14 | "JHUB_APP_CONFIG_thumbnail_path": "panel.png", 15 | "SOMETHING_FOO": "bar", 16 | "SOMETHING_BAR": "beta", 17 | }) 18 | jhub_apps_config = _extract_jhub_apps_config_from_conda_project_config(conda_project_yaml) 19 | assert jhub_apps_config == { 20 | "name": "My Panel App (Git)", 21 | "description": "This is a panel app created from git repository", 22 | "framework": "panel", 23 | "filepath": "panel_basic.py", 24 | "keep_alive": "false", 25 | "public": "false", 26 | "thumbnail_path": "panel.png", 27 | "environment": { 28 | "SOMETHING_FOO": "bar", 29 | "SOMETHING_BAR": "beta", 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /jhub_apps/tests/tests_unit/test_command_template.py: -------------------------------------------------------------------------------- 1 | from jhub_apps.spawner.command import TString, Command 2 | from jhub_apps.spawner.spawner_creation import wrap_command_with_proxy_installer 3 | 4 | 5 | def test_tstring(): 6 | filepath = "foo/bar" 7 | assert ( 8 | TString("random $filepath").replace(filepath=filepath) == f"random {filepath}" 9 | ) 10 | 11 | 12 | def test_cmd_templating(): 13 | cmd = Command( 14 | args=[TString("alpha $abc"), "beta", TString("$efg"), TString("$foo $bar")] 15 | ) 16 | s_args = cmd.get_substituted_args(abc="abc_", efg="_efg", foo="foo_", bar="_bar") 17 | assert s_args == ["alpha abc_", "beta", "_efg", "foo_ _bar"] 18 | 19 | 20 | def test_wrap_command_with_proxy_installer(): 21 | """Test that the wrapper correctly wraps commands with installation script""" 22 | cmd_list = ["jhub-app-proxy", "--authtype=oauth", "--destport=0"] 23 | proxy_version = "v0.1" 24 | wrapped = wrap_command_with_proxy_installer(cmd_list, proxy_version) 25 | 26 | assert len(wrapped) == 3 27 | assert wrapped[0] == "/bin/bash" 28 | assert wrapped[1] == "-c" 29 | assert "jhub-app-proxy" in wrapped[2] 30 | assert "Installing jhub-app-proxy" in wrapped[2] 31 | assert "curl -fsSL" in wrapped[2] 32 | assert "install.sh" in wrapped[2] 33 | assert "export PATH=" in wrapped[2] 34 | assert "$HOME/.local/bin" in wrapped[2] 35 | assert "exec jhub-app-proxy" in wrapped[2] 36 | assert proxy_version in wrapped[2] 37 | -------------------------------------------------------------------------------- /jhub_apps/tests/tests_unit/test_filter_users__groups_based_on_scopes.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from jhub_apps.hub_client.hub_client import filter_entity_based_on_scopes 4 | from jhub_apps.hub_client.utils import is_jupyterhub_5 5 | 6 | 7 | @pytest.mark.skipif(not is_jupyterhub_5(), reason="requires jupyterhub>=5") 8 | @pytest.mark.parametrize("name,entity_key,scopes,entities,expected_entities", [ 9 | ( 10 | "permissions-for-some-users", "user", 11 | [ 12 | "read:users:name!user=user_b", 13 | "read:users:name!user=user_c", 14 | "read:users:name!user=user_d", 15 | "read:users:name!user=user_f", 16 | ], 17 | ["user_a", "user_c", "user_d", "user_e"], 18 | ["user_c", "user_d"] 19 | ), 20 | ( 21 | "generic-permission-for-all-users", "user", 22 | [ 23 | "read:users:name", 24 | ], 25 | ["user_c", "user_a", "user_e", "user_d"], 26 | ["user_c", "user_a", "user_e", "user_d"] 27 | ), 28 | ( 29 | "no-permissions-for-users", "user", 30 | [], 31 | ["user_a", "user_b"], 32 | [] 33 | ), 34 | ( 35 | "permissions-for-some-groups", "group", 36 | [ 37 | "read:groups:name!group=group-x", 38 | "read:groups:name!group=group-b", 39 | "read:groups:name!group=group-c", 40 | "read:groups:name!group=group-y", 41 | ], 42 | ["group-a", "group-b", "group-c", "group-d"], 43 | ["group-b", "group-c"] 44 | ), 45 | ( 46 | "permissions-for-no-groups", "group", 47 | [], 48 | ["group-a", "group-b", "group-c", "group-d"], 49 | [] 50 | ), 51 | ]) 52 | def test_filter_users_based_on_scopes(name, entity_key, scopes, entities, expected_entities): 53 | filtered_entities = filter_entity_based_on_scopes( 54 | scopes=scopes, 55 | entities=entities, 56 | entity_key=entity_key 57 | ) 58 | assert set(filtered_entities) == set(expected_entities) 59 | -------------------------------------------------------------------------------- /jhub_apps/tests/tests_unit/test_hub_client.py: -------------------------------------------------------------------------------- 1 | from jhub_apps.hub_client.hub_client import HubClient 2 | 3 | 4 | def test_normalize_server_name(): 5 | hub_client = HubClient() 6 | # test escaping 7 | assert hub_client.normalize_server_name("../../another-endpoint") == "another-endpoint" 8 | # Test long server name 9 | assert hub_client.normalize_server_name("x"*1000) == "x"*240 10 | # Test all special characters 11 | assert hub_client.normalize_server_name("server!@£$%^&*<>:~`±") == "server" 12 | # Replace space with dash 13 | assert hub_client.normalize_server_name("some server name") == "some-server-name" 14 | # lowercase 15 | assert hub_client.normalize_server_name("SOMESERVERNAME") == "someservername" 16 | -------------------------------------------------------------------------------- /jhub_apps/themes/__init__.py: -------------------------------------------------------------------------------- 1 | from jhub_apps.version import get_version 2 | 3 | LOGO = "/services/japps/static/img/Nebari-Logo-Horizontal-Lockup-Black-text.svg" 4 | FAVICON = "/services/japps/static/favicon.ico" 5 | 6 | DEFAULT_THEME = { 7 | "logo": LOGO, 8 | "favicon": FAVICON, 9 | "primary_color": "#ba18da", 10 | "primary_color_light": "#BA18DA10", 11 | "primary_color_dark": "#9b00ce", 12 | "secondary_color": "#18817a", 13 | "secondary_color_dark": "#12635e", 14 | "accent_color": "#eda61d", 15 | "accent_color_dark": "#a16d14", 16 | "text_color": "#1c1d26", 17 | "h1_color": "#0f1015", 18 | "h2_color": "#0f1015", 19 | "navbar_text_color": "#2E2F33", 20 | "navbar_hover_color": "#00000008", 21 | "navbar_color": "#ffffff", 22 | "version": get_version(), 23 | } 24 | -------------------------------------------------------------------------------- /jhub_apps/version.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import version 2 | from packaging.version import Version 3 | 4 | 5 | def get_version(): 6 | return Version(version("jhub-apps")) 7 | -------------------------------------------------------------------------------- /jupyter_config_profile_list: -------------------------------------------------------------------------------- 1 | c.KubeSpawner.profile_list = [ 2 | { 3 | "description": "Stable environment with 0.5-1 cpu / 0.5-1 GB ram", 4 | "display_name": "Micro Instance", 5 | "slug": "micro-instance" 6 | }, 7 | { 8 | "description": "Stable environment with 1 cpu / 1 GB ram", 9 | "display_name": "Small Instance", 10 | "slug": "small-instance" 11 | }, 12 | ] 13 | -------------------------------------------------------------------------------- /k3s-dev/.gitignore: -------------------------------------------------------------------------------- 1 | # Tilt generated files 2 | .jupyterhub-values-configured.yaml 3 | 4 | # k3d 5 | kubeconfig.yaml 6 | 7 | # Helm 8 | charts/ 9 | *.tgz 10 | 11 | # Python 12 | __pycache__/ 13 | *.py[cod] 14 | *$py.class 15 | *.so 16 | .Python 17 | 18 | # IDEs 19 | .vscode/ 20 | .idea/ 21 | *.swp 22 | *.swo 23 | *~ 24 | 25 | # OS 26 | .DS_Store 27 | Thumbs.db 28 | -------------------------------------------------------------------------------- /k3s-dev/.tiltignore: -------------------------------------------------------------------------------- 1 | **/charts 2 | **/tmpcharts 3 | -------------------------------------------------------------------------------- /k3s-dev/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help up down clean 2 | 3 | # Get absolute path to jhub-apps source 4 | JHUB_APPS_SOURCE := $(shell cd .. && pwd) 5 | export JHUB_APPS_SOURCE 6 | 7 | help: ## Show this help message 8 | @echo "JHub Apps K3s Development Environment" 9 | @echo "" 10 | @echo "Available commands:" 11 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' 12 | 13 | up: ## Create cluster and start Tilt (recreates cluster if exists) 14 | @echo "Setting up JHub Apps development environment..." 15 | @if k3d cluster list | grep -q jhub-apps-dev; then \ 16 | echo ""; \ 17 | echo "⚠️ Cluster 'jhub-apps-dev' already exists"; \ 18 | read -p "Do you want to delete and recreate it? [y/N] " confirm; \ 19 | if [ "$$confirm" = "y" ] || [ "$$confirm" = "Y" ]; then \ 20 | echo "Deleting existing cluster..."; \ 21 | k3d cluster delete jhub-apps-dev; \ 22 | else \ 23 | echo "Using existing cluster..."; \ 24 | fi; \ 25 | fi 26 | @if ! k3d cluster list | grep -q jhub-apps-dev; then \ 27 | echo "Creating k3d cluster 'jhub-apps-dev'..."; \ 28 | echo "Mounting source from: $(JHUB_APPS_SOURCE)"; \ 29 | k3d cluster create -c k3d-config.yaml --wait; \ 30 | kubectl wait --for=condition=ready node --all --timeout=60s; \ 31 | echo "✓ Cluster ready!"; \ 32 | kubectl get nodes; \ 33 | fi 34 | @echo "" 35 | @echo "Starting Tilt..." 36 | @tilt up 37 | 38 | down: ## Stop Tilt and delete cluster 39 | @echo "Stopping Tilt..." 40 | @tilt down || true 41 | @echo "Deleting k3d cluster 'jhub-apps-dev'..." 42 | @k3d cluster delete jhub-apps-dev || true 43 | @echo "✓ Environment cleaned up!" 44 | 45 | clean: down ## Clean everything (alias for down) 46 | 47 | # Default target 48 | .DEFAULT_GOAL := help 49 | -------------------------------------------------------------------------------- /k3s-dev/config/00-jhub-apps.py: -------------------------------------------------------------------------------- 1 | """ 2 | Install and configure jhub-apps with KubeSpawner 3 | 4 | This file follows Nebari's pattern of separating configuration concerns. 5 | Similar to: nebari/src/_nebari/stages/kubernetes_services/.../02-spawner.py 6 | """ 7 | import sys 8 | 9 | # Add jhub-apps to Python path (mounted at /opt/jhub-apps) 10 | sys.path.insert(0, '/opt/jhub-apps') 11 | 12 | from kubespawner import KubeSpawner 13 | from jhub_apps.configuration import install_jhub_apps 14 | from z2jh import get_config 15 | 16 | # Get public URL from Helm values (Nebari pattern) 17 | _public_url = get_config("custom.external-url") 18 | 19 | # Basic JupyterHub settings 20 | c.JupyterHub.log_level = 10 # DEBUG for development 21 | # NOTE: We temporarily set bind_url to the public URL for install_jhub_apps to use it 22 | # for OAuth redirects, then override it after 23 | c.JupyterHub.bind_url = _public_url 24 | c.JupyterHub.hub_connect_url = "http://hub:8081" # Internal URL for services to connect to hub 25 | c.JupyterHub.default_url = "/hub/home" 26 | 27 | # jhub-apps configuration 28 | c.JAppsConfig.jupyterhub_config_path = "/usr/local/etc/jupyterhub/jupyterhub_config.py" 29 | c.JAppsConfig.hub_host = "hub" # Kubernetes service name (for proxy to reach japps) 30 | c.JAppsConfig.service_workers = 1 # Single worker for dev 31 | c.JAppsConfig.conda_envs = [] # No conda-store in dev environment 32 | c.Spawner.debug = True 33 | 34 | # Configure additional services via JAppsConfig 35 | c.JAppsConfig.additional_services = [ 36 | { 37 | "name": "MyAwesomeService", 38 | "url": "/services/japps", 39 | }, 40 | ] 41 | 42 | # Install jhub-apps - wraps KubeSpawner with jhub-apps functionality 43 | c = install_jhub_apps(c, spawner_to_subclass=KubeSpawner, oauth_no_confirm=True) 44 | 45 | # Now set bind_url to the correct internal value for the hub to bind 46 | c.JupyterHub.bind_url = "http://:8081" 47 | 48 | # Add template paths for jhub-apps UI 49 | from jhub_apps import theme_template_paths 50 | c.JupyterHub.template_paths = theme_template_paths 51 | 52 | # Theme configuration (mimics Nebari's look and feel) 53 | from jhub_apps import themes 54 | c.JupyterHub.template_vars = { 55 | "hub_title": "Welcome to Nebari", 56 | "hub_subtitle": "Local KubeSpawner Testing Environment", 57 | "welcome": "🚀 Development Mode", 58 | "display_version": True, 59 | **themes.DEFAULT_THEME, 60 | } 61 | 62 | # Load groups (optional - for testing share permissions) 63 | c.JupyterHub.load_groups = { 64 | "developers": {"users": ["admin"]}, 65 | "data-scientists": {"users": ["admin"]}, 66 | } 67 | 68 | # Add permission to share servers/apps (following Nebari's pattern) 69 | # This is handled by install_jhub_apps, but we can customize roles here 70 | from jhub_apps.hub_client.utils import is_jupyterhub_5 71 | 72 | for role in c.JupyterHub.load_roles: 73 | if role["name"] == "user": 74 | role["scopes"].extend( 75 | [ 76 | # Need scope 'read:users:name' to share with users by name 77 | "read:users:name", 78 | # Need scope 'read:groups:name' to share with groups by name 79 | "read:groups:name", 80 | ] 81 | + ["shares!user"] 82 | if is_jupyterhub_5() 83 | else [] 84 | ) 85 | break 86 | 87 | print("✅ jhub-apps configuration loaded") 88 | print(f" - JupyterHub bind URL: {c.JupyterHub.bind_url}") 89 | print(f" - Hub host: {c.JAppsConfig.hub_host}") 90 | print(f" - Service workers: {c.JAppsConfig.service_workers}") 91 | -------------------------------------------------------------------------------- /k3s-dev/config/01-spawner.py: -------------------------------------------------------------------------------- 1 | """ 2 | KubeSpawner configuration 3 | 4 | This file configures KubeSpawner for local k3s development. 5 | Simplified from Nebari's production config for local testing. 6 | """ 7 | 8 | # Basic spawner settings 9 | c.KubeSpawner.image = "quay.io/nebari/nebari-jupyterlab:main" 10 | c.KubeSpawner.start_timeout = 300 # 5 minutes 11 | c.KubeSpawner.http_timeout = 120 # 2 minutes 12 | 13 | # Resource limits (conservative for local dev) 14 | c.KubeSpawner.cpu_limit = 1 15 | c.KubeSpawner.mem_limit = "1G" 16 | c.KubeSpawner.cpu_guarantee = 0.1 17 | c.KubeSpawner.mem_guarantee = "128M" 18 | 19 | # Storage - use emptyDir for dev (ephemeral, no PVC needed) 20 | c.KubeSpawner.storage_pvc_ensure = False 21 | c.KubeSpawner.storage_class = None 22 | c.KubeSpawner.storage_capacity = None 23 | 24 | # Network - don't create per-user services (saves resources) 25 | c.KubeSpawner.services_enabled = False 26 | 27 | # Security - don't mount service account token in user pods 28 | # This prevents users from accessing the Kubernetes API 29 | c.KubeSpawner.automount_service_account_token = False 30 | 31 | # Debugging 32 | c.Spawner.debug = True 33 | 34 | # Environment variables for spawned pods 35 | c.KubeSpawner.environment = { 36 | "JUPYTERHUB_SINGLEUSER_APP": "jupyter_server.serverapp.ServerApp", 37 | } 38 | 39 | # Allow user pods to run as non-root 40 | # c.KubeSpawner.uid = 1000 41 | # c.KubeSpawner.gid = 100 42 | # c.KubeSpawner.fs_gid = 100 43 | 44 | print("✅ KubeSpawner configuration loaded") 45 | print(f" - Singleuser image: {c.KubeSpawner.image}") 46 | print(f" - CPU limit: {c.KubeSpawner.cpu_limit}, Memory limit: {c.KubeSpawner.mem_limit}") 47 | print(f" - Storage: Ephemeral (emptyDir)") 48 | -------------------------------------------------------------------------------- /k3s-dev/config/02-profiles.py: -------------------------------------------------------------------------------- 1 | """ 2 | KubeSpawner profiles for testing different instance types 3 | 4 | This file defines server profiles that users can select when creating apps. 5 | Similar to Nebari's profile configuration but simplified for local dev. 6 | """ 7 | 8 | c.KubeSpawner.profile_list = [ 9 | { 10 | "display_name": "Small Instance", 11 | "description": "1 CPU / 1 GB RAM - Good for testing lightweight apps", 12 | "slug": "small", 13 | "default": True, 14 | "kubespawner_override": { 15 | "image": "quay.io/nebari/nebari-jupyterlab:main", 16 | "cpu_limit": 1, 17 | "mem_limit": "1G", 18 | "cpu_guarantee": 0.1, 19 | "mem_guarantee": "128M", 20 | } 21 | }, 22 | { 23 | "display_name": "Medium Instance", 24 | "description": "2 CPU / 2 GB RAM - Good for Panel/Streamlit apps", 25 | "slug": "medium", 26 | "kubespawner_override": { 27 | "image": "quay.io/nebari/nebari-jupyterlab:main", 28 | "cpu_limit": 2, 29 | "mem_limit": "2G", 30 | "cpu_guarantee": 0.5, 31 | "mem_guarantee": "512M", 32 | } 33 | }, 34 | { 35 | "display_name": "Large Instance", 36 | "description": "4 CPU / 4 GB RAM - For resource-intensive apps", 37 | "slug": "large", 38 | "kubespawner_override": { 39 | "image": "quay.io/nebari/nebari-jupyterlab:main", 40 | "cpu_limit": 4, 41 | "mem_limit": "4G", 42 | "cpu_guarantee": 1, 43 | "mem_guarantee": "1G", 44 | } 45 | }, 46 | ] 47 | 48 | print("✅ KubeSpawner profiles loaded") 49 | print(f" - {len(c.KubeSpawner.profile_list)} profiles available") 50 | for profile in c.KubeSpawner.profile_list: 51 | image = profile.get('kubespawner_override', {}).get('image', 'default') 52 | print(f" • {profile['display_name']} ({profile['slug']}) - Image: {image}") 53 | -------------------------------------------------------------------------------- /k3s-dev/k3d-config.yaml: -------------------------------------------------------------------------------- 1 | # k3d cluster configuration for jhub-apps development 2 | # Usage: k3d cluster create -c k3d-config.yaml 3 | apiVersion: k3d.io/v1alpha5 4 | kind: Simple 5 | metadata: 6 | name: jhub-apps-dev 7 | 8 | servers: 1 9 | agents: 0 10 | 11 | ports: 12 | - port: 6550:6443 # API server port 13 | nodeFilters: 14 | - server:0 15 | - port: 8000:80 16 | nodeFilters: 17 | - loadbalancer 18 | 19 | # Mount jhub-apps source code into the cluster 20 | # Note: You must set JHUB_APPS_SOURCE environment variable before creating the cluster 21 | # Example: export JHUB_APPS_SOURCE=$(pwd) && k3d cluster create -c k3d-config.yaml 22 | volumes: 23 | - volume: ${JHUB_APPS_SOURCE}:/opt/jhub-apps 24 | nodeFilters: 25 | - server:0 26 | 27 | options: 28 | k3s: 29 | extraArgs: 30 | - arg: --disable=traefik 31 | nodeFilters: 32 | - server:0 33 | - arg: --kubelet-arg=eviction-hard= 34 | nodeFilters: 35 | - server:0 36 | - arg: --kubelet-arg=eviction-soft= 37 | nodeFilters: 38 | - server:0 39 | kubeconfig: 40 | updateDefaultKubeconfig: true 41 | switchCurrentContext: true 42 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jhub-apps", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": {} 6 | } 7 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "jhub-apps" 7 | description = 'JupyterHub Apps' 8 | readme = "README.md" 9 | requires-python = ">=3.8" 10 | license = "MIT" 11 | 12 | dependencies = [ 13 | "hatchling", 14 | "hatch", 15 | "requests", 16 | "fastapi", 17 | "uvicorn", 18 | "python-multipart", 19 | "jupyterhub>4", 20 | "jupyter", 21 | "plotlydash-tornado-cmd", 22 | "bokeh-root-cmd", 23 | "panel", 24 | "bokeh", 25 | "traitlets", 26 | "python-slugify", 27 | "cachetools", 28 | "structlog", 29 | "PyJWT<2.10.0", 30 | "GitPython", 31 | # pinning to avoid unexpected changes in spec causing 32 | # unexpected breakage 33 | "conda-project==0.4.2" 34 | ] 35 | dynamic = ["version"] 36 | 37 | [project.optional-dependencies] 38 | dev = [ 39 | "ruff", 40 | "voila", 41 | "dash", 42 | "streamlit", 43 | "gradio", 44 | "pytest", 45 | "playwright", 46 | "pytest-playwright", 47 | "pre-commit", 48 | "ipdb", 49 | ] 50 | 51 | [project.entry-points.jhub_apps] 52 | jhub_apps = "jhub_apps" 53 | 54 | 55 | [tool.hatch.version] 56 | path = "jhub_apps/__about__.py" 57 | 58 | [tool.hatch.envs.default] 59 | dependencies = [ 60 | "pytest", 61 | "pytest-cov", 62 | "jupyterhub", 63 | ] 64 | 65 | [[tool.hatch.envs.test.matrix]] 66 | python = ["38", "39", "310", "311", "312"] 67 | 68 | [tool.coverage.run] 69 | branch = true 70 | parallel = true 71 | omit = [ 72 | "jhub_apps/__about__.py", 73 | ] 74 | 75 | [tool.hatch.build.targets.wheel] 76 | include = ["jhub_apps"] 77 | 78 | [project.scripts] 79 | japps = "jhub_apps.main:app" 80 | 81 | [tool.ruff] 82 | exclude = ["k3s-dev"] 83 | 84 | [tool.pytest.ini_options] 85 | markers = [ 86 | "k3s: marks tests that can run on k3s deployment (deselect with '-m \"not k3s\"')", 87 | ] 88 | 89 | [dependency-groups] 90 | test-jupyterhub-4 = ["jupyterhub==4.1.5", "jupyter-telemetry==0.1.0", "async-generator==1.10"] 91 | test-jupyterhub-5 = ["jupyterhub==5.3.0"] 92 | 93 | [tool.uv] 94 | conflicts = [ 95 | [ 96 | { group = "test-jupyterhub-4" }, 97 | { group = "test-jupyterhub-5" }, 98 | ], 99 | ] 100 | -------------------------------------------------------------------------------- /ui/.env: -------------------------------------------------------------------------------- 1 | APP_BASE_URL=/hub 2 | API_BASE_URL=/services/japps -------------------------------------------------------------------------------- /ui/.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | build 3 | coverage 4 | -------------------------------------------------------------------------------- /ui/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "trailingComma": "all", 6 | "bracketSpacing": true, 7 | "useTabs": false, 8 | "plugins": ["prettier-plugin-organize-imports"] 9 | } 10 | -------------------------------------------------------------------------------- /ui/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to the JupyterHub UI! 2 | 3 | ## Table of Contents 4 | 5 | 1. [Running the Project Locally](#running-the-project-locally) 6 | 2. [Applying UI Changes to JupyterHub](#applying-ui-changes-to-jupyterhub) 7 | 3. [Running Unit Tests](#running-unit-tests) 8 | 4. [Running Code Quality Checks](#running-code-quality-checks) 9 | 10 | ## Running the Project Locally 11 | 12 | _Note: the below commands must be ran from the `ui` directory_ 13 | 14 | 1. To install dependencies, run the following: 15 | 16 | ```sh 17 | npm install 18 | ``` 19 | 20 | 2. To start the app, run the following: 21 | 22 | ```sh 23 | npm run dev 24 | ``` 25 | 26 | 3. To load the UI, navigate to the following: 27 | 28 | ``` 29 | http://localhost:8080/hub/home 30 | ``` 31 | 32 | ## Applying UI Changes to JupyterHub 33 | 34 | 1. To run a production build and apply changes, run the following: 35 | 36 | ```sh 37 | npm run build 38 | ``` 39 | 40 | 2. Restart JupyterHub to verify changes 41 | _Note: a hard refresh may be necessary to see ui changes_ 42 | 43 | ## Running Unit Tests 44 | 45 | To make sure your changes do not break any unit tests, run the following: 46 | 47 | ```sh 48 | npm run test 49 | ``` 50 | 51 | Ensure to review the coverage directory for code coverage details. 52 | 53 | ```sh 54 | npm run coverage 55 | ``` 56 | 57 | ## Running Code Quality Checks 58 | 59 | To make sure your changes adhere to additional code quality standards, run the following: 60 | 61 | ```sh 62 | npm run lint 63 | npm run format 64 | ``` 65 | 66 | You can also see the `.vscode/settings.json` file to find how to enable auto-formatting on save. 67 | -------------------------------------------------------------------------------- /ui/build-and-copy.sh: -------------------------------------------------------------------------------- 1 | # Make copy of assets 2 | cp dist/assets/index-*.js dist/assets/index.js 3 | cp dist/assets/index-*.css dist/assets/index.css 4 | 5 | # Copy assets to jhub_apps static folder 6 | cp -r dist/assets/index.js ../jhub_apps/static/js 7 | cp -r dist/assets/index.css ../jhub_apps/static/css 8 | -------------------------------------------------------------------------------- /ui/eslint.config.js: -------------------------------------------------------------------------------- 1 | import pluginJs from '@eslint/js'; 2 | import prettierRecommended from 'eslint-plugin-prettier/recommended'; 3 | import reactPlugin from 'eslint-plugin-react'; 4 | import hooksPlugin from 'eslint-plugin-react-hooks'; 5 | import tseslint from 'typescript-eslint'; 6 | 7 | export default [ 8 | // Flat Configs 9 | pluginJs.configs.recommended, 10 | ...tseslint.configs.recommended, 11 | prettierRecommended, 12 | reactPlugin.configs.flat.recommended, 13 | // Default Configs 14 | { 15 | files: ['**/*.{ts,tsx}'], 16 | settings: { 17 | react: { 18 | version: 'detect', 19 | }, 20 | }, 21 | languageOptions: { 22 | parserOptions: { 23 | ecmaFeatures: { 24 | jsx: true, 25 | }, 26 | }, 27 | }, 28 | plugins: { 29 | 'react-hooks': hooksPlugin, 30 | }, 31 | rules: { 32 | // Base Warnings 33 | 'no-console': 'warn', 34 | 35 | // Formatting 36 | 'prettier/prettier': [ 37 | 'error', 38 | { 39 | semi: true, 40 | tabWidth: 2, 41 | singleQuote: true, 42 | trailingComma: 'all', 43 | bracketSpacing: true, 44 | useTabs: false, 45 | }, 46 | ], 47 | 48 | // React 49 | ...hooksPlugin.configs.recommended.rules, 50 | 'react-hooks/exhaustive-deps': 'off', 51 | 'react/react-in-jsx-scope': 'off', 52 | }, 53 | }, 54 | ]; 55 | -------------------------------------------------------------------------------- /ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | JupyterHub 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nebari-hub", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "React UI for Nebari JupyterHub", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "tsc && vite build", 10 | "build:dev": "tsc && NODE_ENV=development vite build --mode development", 11 | "watch": "tsc && vite build --watch", 12 | "lint": "npx eslint src/ --fix --config eslint.config.js", 13 | "preview": "vite preview", 14 | "format": "npx prettier src --write", 15 | "test": "vitest --run", 16 | "test:coverage": "vitest --run --coverage", 17 | "prepare": "cd .. && husky" 18 | }, 19 | "dependencies": { 20 | "@emotion/react": "^11.14.0", 21 | "@emotion/styled": "^11.14.1", 22 | "@mui/icons-material": "^6.5.0", 23 | "@mui/lab": "^6.0.0-beta.32", 24 | "@mui/material": "6.5.0", 25 | "@tanstack/react-query": "5.90.2", 26 | "axios": "1.12.2", 27 | "axios-mock-adapter": "2.1.0", 28 | "classnames": "^2.5.1", 29 | "react": "18.2.0", 30 | "react-dom": "18.2.0", 31 | "react-hook-form": "7.63.0", 32 | "react-router-dom": "7.9.3", 33 | "recoil": "0.7.7" 34 | }, 35 | "devDependencies": { 36 | "@eslint/js": "^9.36.0", 37 | "@testing-library/jest-dom": "6.9.0", 38 | "@testing-library/react": "16.3.0", 39 | "@testing-library/user-event": "14.6.1", 40 | "@types/react": "18.2.17", 41 | "@types/react-dom": "18.2.7", 42 | "@vitejs/plugin-react": "5.0.4", 43 | "@vitest/coverage-v8": "^3.2.4", 44 | "eslint": "9.36.0", 45 | "eslint-config-prettier": "^10.1.8", 46 | "eslint-plugin-prettier": "^5.5.4", 47 | "eslint-plugin-react": "^7.37.5", 48 | "eslint-plugin-react-hooks": "5.2.0", 49 | "husky": "^9.1.7", 50 | "prettier": "3.6.2", 51 | "prettier-plugin-organize-imports": "4.3.0", 52 | "sass": "1.93.2", 53 | "tsconfig-paths": "4.2.0", 54 | "typescript": "5.9.3", 55 | "typescript-eslint": "^8.45.0", 56 | "vite": "7.0.6", 57 | "vite-plugin-environment": "1.1.3", 58 | "vite-plugin-eslint": "1.8.1", 59 | "vite-tsconfig-paths": "5.1.4", 60 | "vitest": "^3.0.4" 61 | }, 62 | "optionalDependencies": { 63 | "@rollup/rollup-linux-x64-gnu": "4.24.0", 64 | "@esbuild/linux-x64": "0.24.0" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /ui/public/env.js: -------------------------------------------------------------------------------- 1 | // Note: This is only used for local development 2 | window.jhdata = { 3 | base_url: '/hub/', 4 | prefix: '/', 5 | user: 'admin', 6 | admin_access: false, 7 | options_form: false, 8 | xsrf_token: '2|12345|12345|12345`', 9 | }; 10 | 11 | window.theme = { 12 | logo: '/img/logo.svg', 13 | }; 14 | -------------------------------------------------------------------------------- /ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/ui/public/favicon.ico -------------------------------------------------------------------------------- /ui/public/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ui/src/components/app-sharing/app-sharing.css: -------------------------------------------------------------------------------- 1 | #app-sharing .MuiTablePagination-selectLabel, 2 | #app-sharing .MuiTablePagination-input, 3 | #app-sharing .MuiTablePagination-displayedRows { 4 | display: none; 5 | } 6 | 7 | .MuiTablePagination-root { 8 | border-bottom: 0 !important; 9 | } 10 | -------------------------------------------------------------------------------- /ui/src/components/button-group/button-group.css: -------------------------------------------------------------------------------- 1 | .button-group { 2 | display: flex; 3 | flex-direction: row; 4 | justify-content: flex-end; 5 | } 6 | 7 | .button-group-item { 8 | margin: 0.25rem; 9 | list-style: none; 10 | } 11 | -------------------------------------------------------------------------------- /ui/src/components/button-group/button-group.test.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@mui/material'; 2 | import { render } from '@testing-library/react'; 3 | import { ButtonGroup } from '..'; 4 | 5 | describe('ButtonGroup', () => { 6 | test('renders default button group successfully', () => { 7 | const { baseElement } = render( 8 | 9 | 10 | , 11 | ); 12 | 13 | expect(baseElement.querySelector('button')).toBeTruthy(); 14 | }); 15 | 16 | test('renders button group with id', () => { 17 | const { baseElement } = render( 18 | 19 | 20 | , 21 | ); 22 | 23 | expect(baseElement.querySelector('#group')).toBeTruthy(); 24 | }); 25 | 26 | test('renders button group with custom class', () => { 27 | const { baseElement } = render( 28 | 29 | 30 | , 31 | ); 32 | 33 | expect(baseElement.querySelector('.custom-class')).toBeTruthy(); 34 | }); 35 | 36 | test('renders button group with multiple children', () => { 37 | const { baseElement } = render( 38 | 39 | 40 | 41 | , 42 | ); 43 | 44 | expect(baseElement.querySelectorAll('button').length).toBe(2); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /ui/src/components/button-group/button-group.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React, { Children, ReactNode } from 'react'; 3 | import './button-group.css'; 4 | 5 | export interface ButtonGroupProps { 6 | /** 7 | * The unique identifier for this component 8 | */ 9 | id?: string; 10 | /** 11 | * A custom class to apply to the component 12 | */ 13 | className?: string; 14 | /** 15 | * The contents of the label 16 | */ 17 | children?: ReactNode; 18 | } 19 | 20 | /** 21 | * A button group collects similar or related actions. 22 | */ 23 | export const ButtonGroup = ({ 24 | id = undefined, 25 | className, 26 | children, 27 | }: ButtonGroupProps): React.ReactElement => { 28 | const classes = classnames('button-group', className); 29 | 30 | return ( 31 |
    32 | {Children.map(children, (child: ReactNode, index) => { 33 | return ( 34 |
  • 35 | {child} 36 |
  • 37 | ); 38 | })} 39 |
40 | ); 41 | }; 42 | 43 | export default ButtonGroup; 44 | -------------------------------------------------------------------------------- /ui/src/components/context-menu/context-menu.css: -------------------------------------------------------------------------------- 1 | .context-menu { 2 | background-color: #ffffff; 3 | border: 1px solid #e6e6e6; 4 | border-radius: 50%; 5 | box-shadow: 6 | 0px 3px 5px -1px rgba(0, 0, 0, 0.2), 7 | 0px 6px 10px 0px rgba(0, 0, 0, 0.14), 8 | 0px 1px 18px 0px rgba(0, 0, 0, 0.12); 9 | color: #1b1b1b; 10 | cursor: pointer; 11 | display: block; 12 | height: 24px; 13 | position: absolute; 14 | right: 8px; 15 | top: 8px; 16 | width: 24px; 17 | z-index: 2; 18 | } 19 | 20 | .context-menu button { 21 | top: -11px; 22 | right: 21px; 23 | } 24 | 25 | .context-menu button:hover, 26 | .context-menu button:focus { 27 | background-color: transparent; 28 | } 29 | 30 | .context-menu-list { 31 | width: 151px; 32 | } 33 | -------------------------------------------------------------------------------- /ui/src/components/context-menu/context-menu.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render } from '@testing-library/react'; 2 | import { act } from 'react-dom/test-utils'; 3 | import ContextMenu from './context-menu'; // Adjust the import based on your file structure 4 | 5 | describe('ContextMenu', () => { 6 | test('renders without crashing', () => { 7 | const { baseElement } = render(); 8 | const menu = baseElement.querySelector('#menu-1'); 9 | expect(menu).toBeTruthy(); 10 | }); 11 | 12 | test('opens menu on button click', () => { 13 | const { getByTestId, getByRole } = render( 14 | , 15 | ); 16 | act(() => { 17 | fireEvent.click(getByTestId('context-menu-button-menu-1')); 18 | }); 19 | expect(getByRole('menu')).toBeVisible(); 20 | }); 21 | 22 | test('displays correct number of visible items', () => { 23 | const items = [ 24 | { id: 'item-1', title: 'Item 1', visible: true }, 25 | { id: 'item-2', title: 'Item 2', visible: false }, 26 | { id: 'item-3', title: 'Item 3', visible: true }, 27 | ]; 28 | const { getByTestId, getAllByRole } = render( 29 | , 30 | ); 31 | fireEvent.click(getByTestId('context-menu-button-menu-1')); 32 | expect(getAllByRole('menuitem')).toHaveLength(2); 33 | }); 34 | 35 | test('calls onClick when an enabled item is clicked', () => { 36 | const onClick = vi.fn(); 37 | const items = [{ id: 'item-1', title: 'Item 1', visible: true, onClick }]; 38 | const { getByTestId, getByText } = render( 39 | , 40 | ); 41 | fireEvent.click(getByTestId('context-menu-button-menu-1')); 42 | fireEvent.click(getByText('Item 1')); 43 | expect(onClick).toHaveBeenCalled(); 44 | }); 45 | 46 | test('does not call onClick when a disabled item is clicked', () => { 47 | const onClick = vi.fn(); 48 | const items = [ 49 | { id: 'item-1', title: 'Item 1', visible: true, disabled: true, onClick }, 50 | ]; 51 | const { getByTestId, getByText } = render( 52 | , 53 | ); 54 | fireEvent.click(getByTestId('context-menu-button-menu-1')); 55 | fireEvent.click(getByText('Item 1')); 56 | expect(onClick).not.toHaveBeenCalled(); 57 | }); 58 | 59 | test('closes menu after item click', () => { 60 | const onClick = vi.fn(); 61 | const items = [{ id: 'item-1', title: 'Item 1', visible: true, onClick }]; 62 | const { getByText, queryByRole, getByTestId } = render( 63 | , 64 | ); 65 | fireEvent.click(getByTestId('context-menu-button-menu-1')); 66 | fireEvent.click(getByText('Item 1')); 67 | expect(queryByRole('menu')).not.toBeInTheDocument(); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /ui/src/components/custom-label/custom-label.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import { CustomLabel } from './custom-label'; 3 | 4 | describe('CustomLabel', () => { 5 | beforeEach(() => { 6 | vi.clearAllMocks(); 7 | }); 8 | 9 | describe('when rendering the CustomLabel', () => { 10 | test('renders the label text', () => { 11 | render(); 12 | expect(screen.getByText('Username')).toBeInTheDocument(); 13 | }); 14 | 15 | test('does not show asterisk when not required', () => { 16 | render(); 17 | expect(screen.queryByText('*')).not.toBeInTheDocument(); 18 | }); 19 | 20 | test('shows asterisk when required', () => { 21 | render(); 22 | expect(screen.getByText('*')).toBeInTheDocument(); 23 | }); 24 | 25 | test('renders asterisk with provided required color when required', () => { 26 | const customProps = { 27 | requiredColor: 'red', 28 | }; 29 | 30 | render( 31 | , 36 | ); 37 | const asterisk = screen.getByText('*'); 38 | expect(asterisk).toHaveStyle({ color: 'red' }); 39 | }); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /ui/src/components/custom-label/custom-label.tsx: -------------------------------------------------------------------------------- 1 | export interface CustomLabelProps { 2 | label: string; 3 | required?: boolean; 4 | style?: React.CSSProperties & { requiredColor?: string }; 5 | } 6 | 7 | export const CustomLabel = ({ 8 | label, 9 | required, 10 | style, 11 | }: CustomLabelProps): React.ReactElement => { 12 | return ( 13 | 14 | {required && ( 15 | * 16 | )} 17 | {label} 18 | 19 | ); 20 | }; 21 | 22 | export default CustomLabel; 23 | -------------------------------------------------------------------------------- /ui/src/components/environment-variables/environment-variables.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, fireEvent, render, waitFor } from '@testing-library/react'; 2 | import { EnvironmentVariables } from '..'; 3 | 4 | describe('EnvironmentVariables', () => { 5 | test('renders default successfully', () => { 6 | const { baseElement } = render( 7 | , 8 | ); 9 | 10 | expect(baseElement).toBeTruthy(); 11 | }); 12 | 13 | test('renders with mock data', async () => { 14 | const { baseElement } = render( 15 | , 19 | ); 20 | 21 | waitFor(() => { 22 | const rows = baseElement.querySelectorAll('attr[name="key"]'); 23 | expect(rows).toHaveLength(1); 24 | }); 25 | }); 26 | 27 | test('Adds a new row', async () => { 28 | const { baseElement, getByText } = render( 29 | , 30 | ); 31 | 32 | const button = getByText('Add Variable'); 33 | if (button) { 34 | button?.click(); 35 | } 36 | 37 | waitFor(() => { 38 | const rows = baseElement.querySelectorAll('attr[name="key"]'); 39 | expect(rows).toHaveLength(1); 40 | }); 41 | }); 42 | 43 | test('Removes a row', async () => { 44 | const { baseElement, getAllByTestId } = render( 45 | , 49 | ); 50 | 51 | const button = getAllByTestId('CloseRoundedIcon')[0]; 52 | if (button) { 53 | await act(async () => { 54 | (button.parentNode as HTMLButtonElement)?.click(); 55 | }); 56 | } 57 | 58 | waitFor(() => { 59 | const rows = baseElement.querySelectorAll('attr[name="key"]'); 60 | expect(rows).toHaveLength(0); 61 | }); 62 | }); 63 | 64 | test('Updates a row', async () => { 65 | const { baseElement } = render( 66 | , 70 | ); 71 | let input = baseElement.querySelector( 72 | '#environment-variable-key-0', 73 | ) as HTMLButtonElement; 74 | if (input) { 75 | await act(async () => { 76 | fireEvent.change(input, { target: { value: 'new key' } }); 77 | }); 78 | expect(input.value).toBe('new key'); 79 | } 80 | 81 | input = baseElement.querySelector( 82 | '#environment-variable-value-0', 83 | ) as HTMLButtonElement; 84 | if (input) { 85 | await act(async () => { 86 | fireEvent.change(input, { target: { value: 'new value' } }); 87 | }); 88 | expect(input.value).toBe('new value'); 89 | } 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /ui/src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AppCard } from './app-card/app-card'; 2 | export { default as AppForm } from './app-form/app-form'; 3 | export { default as AppSharing } from './app-sharing/app-sharing'; 4 | export { default as ButtonGroup } from './button-group/button-group'; 5 | export { default as ContextMenu } from './context-menu/context-menu'; 6 | export { default as CustomLabel } from './custom-label/custom-label'; 7 | export { default as EnvironmentVariables } from './environment-variables/environment-variables'; 8 | export { default as Navigation } from './navigation/navigation'; 9 | export { default as NotificationBar } from './notification-bar/notification-bar'; 10 | export { default as StatusChip } from './status-chip/status-chip'; 11 | export { default as Thumbnail } from './thumbnail/thumbnail'; 12 | -------------------------------------------------------------------------------- /ui/src/components/navigation/navigation.css: -------------------------------------------------------------------------------- 1 | #app-bar #toolbar .button-menu { 2 | color: #0F1015; 3 | font-weight: 700; 4 | } 5 | 6 | #app-bar #toolbar .chip { 7 | background-color: #E0E0E0 !important; 8 | color: #0F1015 !important; 9 | } 10 | -------------------------------------------------------------------------------- /ui/src/components/notification-bar/notification-bar.css: -------------------------------------------------------------------------------- 1 | .alert-wrapper { 2 | width: 100%; 3 | padding: 0 30px 25px 30px; 4 | } 5 | -------------------------------------------------------------------------------- /ui/src/components/notification-bar/notification-bar.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import { NotificationBar } from '..'; 3 | 4 | describe('NotificationBar', () => { 5 | test('renders default notification bar successfully', () => { 6 | const { baseElement } = render(); 7 | 8 | expect(baseElement.querySelector('.MuiAlert-message')).toBeTruthy(); 9 | }); 10 | 11 | test('renders an error notification bar successfully', () => { 12 | const { baseElement } = render( 13 | , 14 | ); 15 | 16 | expect(baseElement.querySelector('.MuiAlert-message')).toBeTruthy(); 17 | expect(baseElement.querySelector('.MuiAlert-standardError')).toBeTruthy(); 18 | }); 19 | 20 | test('renders an warning notification bar successfully', () => { 21 | const { baseElement } = render( 22 | , 23 | ); 24 | 25 | expect(baseElement.querySelector('.MuiAlert-message')).toBeTruthy(); 26 | expect(baseElement.querySelector('.MuiAlert-standardWarning')).toBeTruthy(); 27 | }); 28 | 29 | test('renders an info notification bar successfully', () => { 30 | const { baseElement } = render( 31 | , 32 | ); 33 | 34 | expect(baseElement.querySelector('.MuiAlert-message')).toBeTruthy(); 35 | expect(baseElement.querySelector('.MuiAlert-standardInfo')).toBeTruthy(); 36 | }); 37 | 38 | test('renders an success notification bar successfully', () => { 39 | const { baseElement } = render( 40 | , 41 | ); 42 | 43 | expect(baseElement.querySelector('.MuiAlert-message')).toBeTruthy(); 44 | expect(baseElement.querySelector('.MuiAlert-standardSuccess')).toBeTruthy(); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /ui/src/components/notification-bar/notification-bar.tsx: -------------------------------------------------------------------------------- 1 | import { Alert } from '@mui/material'; 2 | import './notification-bar.css'; 3 | 4 | interface NotificationBarProps { 5 | /** 6 | * The severity of the notification 7 | */ 8 | severity?: 'error' | 'warning' | 'info' | 'success'; 9 | /** 10 | * The message to display in the notification 11 | */ 12 | message: string; 13 | /** 14 | * Callback to close the notification 15 | */ 16 | onClose?: () => void; 17 | } 18 | 19 | export const NotificationBar = ({ 20 | severity = 'error', 21 | message, 22 | onClose, 23 | }: NotificationBarProps): React.ReactElement => { 24 | return ( 25 |
26 | 27 | {message} 28 | 29 |
30 | ); 31 | }; 32 | 33 | export default NotificationBar; 34 | -------------------------------------------------------------------------------- /ui/src/components/status-chip/status-chip.css: -------------------------------------------------------------------------------- 1 | .card-content-header .chip-container span.MuiChip-label { 2 | padding-right: 2px; 3 | } 4 | 5 | .card-content-header .chip-container .chip-base span.MuiChip-label { 6 | padding-right: 8px; 7 | } 8 | -------------------------------------------------------------------------------- /ui/src/components/status-chip/status-chip.test.tsx: -------------------------------------------------------------------------------- 1 | import { apps } from '@src/data/api'; 2 | import { render, waitFor } from '@testing-library/react'; 3 | import { act } from 'react-dom/test-utils'; 4 | import { RecoilRoot } from 'recoil'; 5 | import { StatusChip } from '..'; 6 | 7 | describe('StatusChip', () => { 8 | test('renders default chip successfully', () => { 9 | const { baseElement } = render( 10 | 11 | 12 | , 13 | ); 14 | const chip = baseElement.querySelector('.MuiChip-root'); 15 | expect(chip).toBeTruthy(); 16 | expect(chip?.textContent).toBe('Ready'); 17 | }); 18 | 19 | test('renders pending chip successfully', () => { 20 | const { baseElement } = render( 21 | 22 | 23 | , 24 | ); 25 | const chip = baseElement.querySelector('.MuiChip-root'); 26 | expect(chip).toBeTruthy(); 27 | expect(chip?.textContent).toBe('Pending'); 28 | }); 29 | 30 | test('renders running chip successfully', () => { 31 | const { baseElement } = render( 32 | 33 | 34 | , 35 | ); 36 | const chip = baseElement.querySelector('.MuiChip-root'); 37 | expect(chip).toBeTruthy(); 38 | expect(chip?.textContent).toBe('Running'); 39 | }); 40 | 41 | test('renders unknown chip successfully', () => { 42 | const { baseElement } = render( 43 | 44 | 45 | , 46 | ); 47 | const chip = baseElement.querySelector('.MuiChip-root'); 48 | expect(chip).toBeTruthy(); 49 | expect(chip?.textContent).toBe('Unknown'); 50 | }); 51 | 52 | test('renders running chip with additional info', () => { 53 | const { baseElement } = render( 54 | 55 | 56 | , 57 | ); 58 | const chip = baseElement.querySelector('.MuiChip-root'); 59 | expect(chip).toBeTruthy(); 60 | expect(chip?.textContent).toBe('Running on small'); 61 | }); 62 | 63 | test('renders shared app chip running with no additional info', () => { 64 | const newApp = { ...apps[0], shared: true }; 65 | const { baseElement } = render( 66 | 67 | 68 | , 69 | ); 70 | const chip = baseElement.querySelector('.MuiChip-root'); 71 | expect(chip).toBeTruthy(); 72 | expect(chip?.textContent).toBe('Running'); 73 | }); 74 | 75 | test('simulates stopping app from chip button', async () => { 76 | const { baseElement } = render( 77 | 78 | 79 | , 80 | ); 81 | const stopButton = baseElement.querySelector( 82 | '.MuiIconButton-root', 83 | ) as HTMLButtonElement; 84 | if (stopButton) { 85 | act(() => { 86 | stopButton.click(); 87 | }); 88 | } 89 | waitFor(() => { 90 | const stopModal = baseElement.querySelector('.MuiDialog-root'); 91 | expect(stopModal).toBeTruthy(); 92 | }); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /ui/src/components/status-chip/status-chip.tsx: -------------------------------------------------------------------------------- 1 | import StopCircleRoundedIcon from '@mui/icons-material/StopCircleRounded'; 2 | import { Chip, IconButton } from '@mui/material'; 3 | import { JhApp } from '@src/types/jupyterhub'; 4 | import { useRecoilState } from 'recoil'; 5 | import { currentApp, isStopOpen } from '../../store'; 6 | import './status-chip.css'; 7 | 8 | interface StatusChipProps { 9 | status: string; 10 | additionalInfo?: string; 11 | app?: JhApp; 12 | size?: 'small' | 'medium'; 13 | } 14 | const getStatusStyles = (status: string) => { 15 | let styles; 16 | switch (status) { 17 | case 'Ready': 18 | styles = { 19 | bgcolor: 'rgb(255, 255, 255)', // #ffffff 20 | border: '1px solid rgb(46, 125, 50)', // #2E7D32 21 | color: 'rgb(46, 125, 50)', // #2E7D32 22 | }; 23 | break; 24 | case 'Pending': 25 | styles = { 26 | bgcolor: 'rgb(234, 181, 78)', // #EAB54E 27 | color: 'black', 28 | }; 29 | break; 30 | case 'Running': 31 | styles = { 32 | bgcolor: 'rgb(46, 125, 50)', // #2E7D32 33 | color: 'white', 34 | }; 35 | break; 36 | case 'Unknown': 37 | default: 38 | styles = { 39 | bgcolor: 'rgb(121, 121, 124)', // #79797C 40 | color: 'white', 41 | }; 42 | break; 43 | } 44 | return styles; 45 | }; 46 | 47 | export const StatusChip = ({ 48 | status, 49 | additionalInfo, 50 | app, 51 | size = 'small', 52 | }: StatusChipProps): React.ReactElement => { 53 | const [, setCurrentApp] = useRecoilState(currentApp); 54 | const [, setIsStopOpen] = useRecoilState(isStopOpen); 55 | 56 | const getLabel = () => { 57 | if (status === 'Running' && additionalInfo) { 58 | return ( 59 | <> 60 | {app && !app.shared ? ( 61 | <> 62 | 66 | {status} on {additionalInfo} 67 | 68 | { 70 | event.preventDefault(); 71 | event.stopPropagation(); 72 | setCurrentApp(app); 73 | setIsStopOpen(true); 74 | }} 75 | aria-label="Stop" 76 | sx={{ 77 | pl: 0, 78 | position: 'relative', 79 | top: 0, 80 | left: '6px', 81 | }} 82 | color="inherit" 83 | disabled={app.shared} 84 | > 85 | 90 | 91 | 92 | ) : ( 93 | {status} 94 | )} 95 | 96 | ); 97 | } 98 | return status || 'Default'; 99 | }; 100 | 101 | return ( 102 | 116 | ); 117 | }; 118 | export default StatusChip; 119 | -------------------------------------------------------------------------------- /ui/src/components/thumbnail/thumbnail.css: -------------------------------------------------------------------------------- 1 | .thumbnail-body.selected { 2 | background-color: transparent; 3 | } 4 | 5 | .thumbnail-body.dragging { 6 | border: 2px dashed var(--primary); 7 | } 8 | 9 | .error-msg { 10 | color: #5F2120; 11 | } 12 | 13 | .weight600 { 14 | font-weight: 600; 15 | } -------------------------------------------------------------------------------- /ui/src/data/jupyterhub.ts: -------------------------------------------------------------------------------- 1 | import { JhService, JhServiceApp, JhServiceFull } from '../types/jupyterhub'; 2 | 3 | export const services: JhService[] = [ 4 | { 5 | name: 'Argo Workflows', 6 | url: '/hub/argo', 7 | external: false, 8 | pinned: false, 9 | }, 10 | { 11 | name: 'User Management', 12 | url: '/auth/admin/nebari/console/', 13 | external: false, 14 | pinned: false, 15 | }, 16 | { 17 | name: 'Environments', 18 | url: '/hub/conda-store', 19 | external: false, 20 | pinned: true, 21 | }, 22 | { 23 | name: 'Monitoring', 24 | url: '/hub/monitoring', 25 | external: false, 26 | pinned: false, 27 | }, 28 | ]; 29 | 30 | export const serviceApps: JhServiceApp[] = [ 31 | { 32 | id: '1', 33 | name: 'Service 1', 34 | description: 'Service 1 description', 35 | thumbnail: 'service1.png', 36 | framework: 'service', 37 | url: 'http://service1.com/[USER]', 38 | status: 'running', 39 | username: 'test', 40 | }, 41 | { 42 | id: '2', 43 | name: 'Service 2', 44 | description: 'Service 2 description', 45 | thumbnail: 'service2.png', 46 | framework: 'service', 47 | url: 'http://service2.com/[USER]', 48 | status: 'running', 49 | username: 'test', 50 | }, 51 | { 52 | id: '3', 53 | name: 'Service 3', 54 | description: 'Service 3 description', 55 | thumbnail: 'service3.png', 56 | framework: 'service', 57 | url: 'http://service3.com/[USER]', 58 | status: 'running', 59 | username: 'test', 60 | }, 61 | ]; 62 | 63 | export const servicesFull: JhServiceFull[] = [ 64 | { 65 | display: true, 66 | info: { 67 | name: 'Environments', 68 | description: 'Manage your environments', 69 | url: 'http://service1.com/service1', 70 | external: true, 71 | pinned: true, 72 | thumbnail: 'https://example.com/thumbnail.png', 73 | }, 74 | prefix: '/services', 75 | kind: '', 76 | admin: true, 77 | roles: [], 78 | pid: 0, 79 | url: '', 80 | name: 'service1', 81 | command: [], 82 | }, 83 | { 84 | display: false, 85 | info: { 86 | name: 'JupyterLab', 87 | url: 'http://service2.com/service2', 88 | external: false, 89 | }, 90 | prefix: '/services', 91 | kind: '', 92 | admin: false, 93 | roles: [], 94 | pid: 0, 95 | url: '', 96 | name: 'service2', 97 | command: [], 98 | }, 99 | { 100 | display: true, 101 | info: { 102 | name: 'VSCode', 103 | url: 'http://service3.com/service3', 104 | external: false, 105 | }, 106 | prefix: '/services', 107 | kind: '', 108 | admin: true, 109 | roles: [], 110 | pid: 0, 111 | url: '', 112 | name: 'service3', 113 | command: [], 114 | }, 115 | ]; 116 | 117 | export const jhData = { 118 | base_url: '/hub/', 119 | prefix: '/', 120 | user: 'test', 121 | admin_access: false, 122 | options_form: false, 123 | xsrf_token: '2|12345|12345|12345', 124 | }; 125 | -------------------------------------------------------------------------------- /ui/src/data/user.ts: -------------------------------------------------------------------------------- 1 | import { UserState } from '../types/user'; 2 | 3 | export const currentUser: UserState = { 4 | name: 'testuser1@email.com', 5 | username: 'testuser1', 6 | admin: true, 7 | groups: [], 8 | roles: [], 9 | scopes: [], 10 | auth_state: null, 11 | servers: {}, 12 | server: null, 13 | session_id: null, 14 | last_activity: null, 15 | pending: null, 16 | kind: null, 17 | created: null, 18 | share_permissions: { 19 | users: ['alpha@example.com', 'admin', 'testuser', 'john@doe.com'], 20 | groups: ['developer', 'superadmin'], 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /ui/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* Colors */ 3 | --primary-light: #ba18da10; 4 | --primary: #ba18da; 5 | --primary-dark: #9b00ce; 6 | --secondary-light: #20aaa110; 7 | --secondary: #18817a; 8 | --secondary-dark: #12635e; 9 | --white: #ffffff; 10 | --black: #000000; 11 | --gray-lightest: #f0f0f0; 12 | --gray-lighter: #e6e6e6; 13 | --gray-light: #adadad; 14 | --gray: #757575; 15 | --gray-dark: #454545; 16 | --gray-darker: #171717; 17 | --gray-darkest: #5c5c5c; 18 | --success: #00a91c; 19 | --success-light: #ecf3ec; 20 | --warning: #ffbe2e; 21 | --warning-light: #fde68a; 22 | --error: #d54309; 23 | --error-light: #f4e3db; 24 | --info: #00bde3; 25 | --info-light: #e7f6f8; 26 | 27 | /* Misc Colors */ 28 | --text-color: #1c1d26; 29 | --link-text-color: #276be9; 30 | } 31 | 32 | /* Global Styling */ 33 | html { 34 | background: #fafbfc; 35 | } 36 | main { 37 | margin: 24px 0; 38 | } 39 | 40 | blockquote, 41 | dl, 42 | dd, 43 | h1, 44 | h2, 45 | h3, 46 | h4, 47 | h5, 48 | h6, 49 | hr, 50 | figure, 51 | p, 52 | pre { 53 | margin: 0; 54 | } 55 | 56 | h1 { 57 | font-size: 30px; 58 | font-weight: 700; 59 | padding: 0 0 8px 0; 60 | } 61 | 62 | h2 { 63 | font-size: 20px; 64 | } 65 | 66 | hr { 67 | color: var(--gray-lightest); 68 | } 69 | 70 | p { 71 | font-size: 16px; 72 | } 73 | 74 | a { 75 | color: var(--link-text-color); 76 | text-decoration: none; 77 | } 78 | 79 | /* Grid Styling */ 80 | .container { 81 | margin-left: auto; 82 | margin-right: auto; 83 | padding-left: 30px; 84 | padding-right: 30px; 85 | } 86 | 87 | .container:before { 88 | content: unset; 89 | } 90 | 91 | .grid-heading-left { 92 | width: 120px; 93 | display: flex; 94 | justify-content: flex-start; 95 | } 96 | 97 | .grid-heading-left > h2, 98 | .grid-heading-right > h2 { 99 | font-weight: 700; 100 | white-space: nowrap; 101 | } 102 | 103 | .grid-heading-right { 104 | width: 120px; 105 | display: flex; 106 | justify-content: flex-end; 107 | } 108 | 109 | .grid-spacer { 110 | position: relative; 111 | top: 12px; 112 | } 113 | 114 | /* Utility Classes */ 115 | .font-bold { 116 | font-weight: 700; 117 | } 118 | 119 | /* Misc */ 120 | #search { 121 | padding: 10px 0; 122 | } 123 | 124 | @media only screen and (min-width: 1537px) { 125 | .grid-spacer { 126 | width: 1460px; 127 | } 128 | } 129 | 130 | @media only screen and (min-width: 1537px) and (max-width: 1920px) { 131 | .grid-spacer { 132 | width: 1160px; 133 | } 134 | } 135 | 136 | @media only screen and (min-width: 1281px) and (max-width: 1536px) { 137 | .grid-spacer { 138 | width: 960px; 139 | } 140 | } 141 | 142 | @media only screen and (min-width: 1025px) and (max-width: 1280px) { 143 | .grid-spacer { 144 | width: 660px; 145 | } 146 | } 147 | 148 | @media only screen and (min-width: 769px) and (max-width: 1024px) { 149 | .grid-spacer { 150 | width: 460px; 151 | } 152 | } 153 | 154 | @media only screen and (min-width: 641px) and (max-width: 768px) { 155 | h1 { 156 | padding-bottom: 24px; 157 | } 158 | 159 | .grid-spacer { 160 | width: 320px; 161 | } 162 | 163 | #create-app { 164 | width: 150px; 165 | } 166 | } 167 | 168 | @media only screen and (max-width: 640px) { 169 | .container { 170 | padding-left: 15px; 171 | padding-right: 15px; 172 | } 173 | 174 | h1 { 175 | padding-bottom: 24px; 176 | } 177 | 178 | .grid-heading-center, 179 | .grid-heading-right, 180 | .grid-heading-right > h2 { 181 | display: none; 182 | } 183 | 184 | #create-app { 185 | width: 150px; 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /ui/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { CssBaseline, ThemeProvider } from '@mui/material'; 2 | import { API_BASE_URL, APP_BASE_URL } from '@src/utils/constants.ts'; 3 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 4 | import React from 'react'; 5 | import ReactDOM from 'react-dom/client'; 6 | import { BrowserRouter } from 'react-router-dom'; 7 | import { RecoilRoot } from 'recoil'; 8 | import { App } from './App.tsx'; 9 | import './index.css'; 10 | import { theme } from './theme/theme.tsx'; 11 | 12 | const currentUrl = new URL(window.location.href); 13 | const queryClient = new QueryClient(); 14 | 15 | ReactDOM.createRoot(document.getElementById('root')!).render( 16 | 17 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | , 32 | ); 33 | -------------------------------------------------------------------------------- /ui/src/pages/create-app/create-app.tsx: -------------------------------------------------------------------------------- 1 | import ArrowBackIcon from '@mui/icons-material/ArrowBackRounded'; 2 | import { 3 | Box, 4 | Button, 5 | FormControl, 6 | FormControlLabel, 7 | Radio, 8 | RadioGroup, 9 | Stack, 10 | Typography, 11 | } from '@mui/material'; 12 | import { AppForm } from '@src/components'; 13 | import { APP_BASE_URL } from '@src/utils/constants'; 14 | import { navigateToUrl } from '@src/utils/jupyterhub'; 15 | import React, { useState } from 'react'; 16 | import { useRecoilState } from 'recoil'; 17 | import { isHeadless as defaultIsHeadless } from '../../store'; 18 | import { StyledFormParagraph } from '../../styles/styled-form-paragraph'; 19 | import { Item } from '../../styles/styled-item'; 20 | 21 | export const CreateApp = (): React.ReactElement => { 22 | const [isHeadless] = useRecoilState(defaultIsHeadless); 23 | const [deployOption, setDeployOption] = useState('launcher'); // Track selected deployment option 24 | 25 | const handleDeployOptionChange = ( 26 | event: React.ChangeEvent, 27 | ) => { 28 | setDeployOption(event.target.value); 29 | }; 30 | return ( 31 | 32 | 33 | 48 | 49 | 50 | 51 | {deployOption === 'launcher' 52 | ? 'Deploy a new app' 53 | : 'Deploy an app from a Git repository'} 54 | 55 | 56 | Begin your project by entering the details below. For more 57 | information about deploying an app,{' '} 58 | 64 | visit our docs 65 | 66 | . 67 | 68 | 69 | 90 | 91 | {/* Pass the selected deployment option to the AppForm */} 92 | 93 | 94 | 95 | 96 | ); 97 | }; 98 | -------------------------------------------------------------------------------- /ui/src/pages/edit-app/edit-app.test.tsx: -------------------------------------------------------------------------------- 1 | import axios from '@src/utils/axios'; 2 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 3 | import { act, render } from '@testing-library/react'; 4 | import MockAdapter from 'axios-mock-adapter'; 5 | import { BrowserRouter } from 'react-router-dom'; 6 | import { RecoilRoot } from 'recoil'; 7 | import { EditApp } from './edit-app'; 8 | 9 | describe('EditApp', () => { 10 | const queryClient = new QueryClient({ 11 | defaultOptions: { 12 | queries: { 13 | retry: false, 14 | }, 15 | }, 16 | }); 17 | const mock = new MockAdapter(axios); 18 | beforeAll(() => { 19 | mock.reset(); 20 | }); 21 | 22 | const componentWrapper = ( 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ); 31 | 32 | test('should render successfully', async () => { 33 | const { baseElement } = render(componentWrapper); 34 | await act(async () => { 35 | expect(baseElement).toBeTruthy(); 36 | expect(baseElement.querySelector('h1')?.textContent).toEqual('Edit app'); 37 | }); 38 | }); 39 | 40 | test('simulates editing an app', async () => { 41 | const mockSearchParamsGet = vi.spyOn(URLSearchParams.prototype, 'get'); 42 | mockSearchParamsGet.mockReturnValue('app-1'); 43 | 44 | render( 45 | 46 | 47 | 48 | 49 | 50 | 51 | , 52 | ); 53 | 54 | expect(mockSearchParamsGet).toHaveBeenCalledWith('id'); 55 | }); 56 | 57 | test('clicks back to home', async () => { 58 | const { baseElement } = render( 59 | 60 | 61 | 62 | 63 | 64 | 65 | , 66 | ); 67 | const btn = baseElement.querySelector('#back-btn') as HTMLButtonElement; 68 | expect(btn).toBeInTheDocument(); 69 | expect(btn).toHaveTextContent('Back'); 70 | expect(btn).not.toHaveAttribute('disabled', 'disabled'); 71 | await act(async () => { 72 | btn.click(); 73 | }); 74 | expect(window.location.pathname).toBe('/'); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /ui/src/pages/edit-app/edit-app.tsx: -------------------------------------------------------------------------------- 1 | import ArrowBackIcon from '@mui/icons-material/ArrowBackRounded'; 2 | import { Box, Button, Stack, Typography } from '@mui/material'; 3 | import { AppForm } from '@src/components'; 4 | import { APP_BASE_URL } from '@src/utils/constants'; 5 | import { navigateToUrl } from '@src/utils/jupyterhub'; 6 | import React from 'react'; 7 | import { useSearchParams } from 'react-router-dom'; 8 | import { useRecoilState } from 'recoil'; 9 | import { isHeadless as defaultIsHeadless } from '../../store'; 10 | import { StyledFormParagraph } from '../../styles/styled-form-paragraph'; 11 | import { Item } from '../../styles/styled-item'; 12 | 13 | export const EditApp = (): React.ReactElement => { 14 | const [isHeadless] = useRecoilState(defaultIsHeadless); 15 | const [searchParams] = useSearchParams(); 16 | const id = searchParams.get('id'); 17 | 18 | return ( 19 | 20 | 21 | 35 | 36 | 37 | Edit app 38 | 39 | 40 | Edit your app details here. For more information on editing your 41 | app,{' '} 42 | 48 | visit our docs 49 | 50 | . 51 | 52 | 53 | 54 | <>{id ? : <>No app found.} 55 | 56 | 57 | 58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /ui/src/pages/home/apps-section/app-filters/app-filters.css: -------------------------------------------------------------------------------- 1 | #filters-list { 2 | position: absolute; 3 | top: 3px !important; 4 | } 5 | -------------------------------------------------------------------------------- /ui/src/pages/home/apps-section/app-grid/app-grid.test.tsx: -------------------------------------------------------------------------------- 1 | import { apps } from '@src/data/api'; 2 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 3 | import { act, render } from '@testing-library/react'; 4 | import { BrowserRouter } from 'react-router-dom'; 5 | import { RecoilRoot } from 'recoil'; 6 | import { AppGrid } from './app-grid'; 7 | 8 | describe('AppGrid', () => { 9 | const queryClient = new QueryClient({ 10 | defaultOptions: { 11 | queries: { 12 | retry: false, 13 | }, 14 | }, 15 | }); 16 | test('should render successfully', async () => { 17 | const { baseElement } = render( 18 | 19 | 20 | 21 | 22 | , 23 | ); 24 | await act(async () => { 25 | expect(baseElement).toBeTruthy(); 26 | }); 27 | }); 28 | 29 | test('should render with mock data', async () => { 30 | const { baseElement } = render( 31 | 32 | 33 | 34 | 35 | 36 | 37 | , 38 | ); 39 | await act(async () => { 40 | const cards = baseElement.querySelectorAll('.card'); 41 | expect(cards).toHaveLength(apps.length); 42 | }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /ui/src/pages/home/apps-section/app-grid/app-grid.tsx: -------------------------------------------------------------------------------- 1 | import { JhApp } from '@src/types/jupyterhub'; 2 | import React from 'react'; 3 | import AppCard from '../../../../components/app-card/app-card'; 4 | 5 | interface AppsGridProps { 6 | apps: JhApp[]; 7 | } 8 | 9 | export const AppGrid = ({ apps }: AppsGridProps): React.ReactElement => { 10 | return ( 11 | <> 12 | {apps.map((app: JhApp, index: number) => ( 13 | 23 | ))} 24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /ui/src/pages/home/apps-section/app-table/app-table.css: -------------------------------------------------------------------------------- 1 | .align-vertical-center { 2 | vertical-align: middle; 3 | position: relative; 4 | top: -6px; /* Adjust this value as needed */ 5 | } 6 | 7 | .icon-button { 8 | padding: 0 20px !important; 9 | } 10 | 11 | .icon-text { 12 | padding-left: 0.5rem; 13 | position: relative; 14 | top: -3px; 15 | } 16 | 17 | .actions { 18 | padding: 0; 19 | } 20 | 21 | .actions .action-button { 22 | min-width: 0px; 23 | padding: 0px 24px; 24 | } 25 | 26 | .actions .action-button:hover { 27 | background-color: transparent; 28 | border-radius: 50%; 29 | } 30 | 31 | .actions button.button-icon { 32 | min-width: 0px !important; 33 | } 34 | 35 | .actions button.button-icon:hover { 36 | background-color: transparent; 37 | border-radius: 50%; 38 | } 39 | 40 | .action-button { 41 | min-width: 0px !important; 42 | border-radius: 50% !important; 43 | margin: 0 1rem !important; 44 | } 45 | 46 | .truncate { 47 | width: 140px; 48 | white-space: nowrap; 49 | overflow: hidden; 50 | text-overflow: ellipsis; 51 | } 52 | 53 | .MuiDataGrid-menuIcon.base-Popper-root.MuiDataGrid-menu { 54 | display: none; 55 | } 56 | 57 | .app-header th { 58 | font-weight: 600; 59 | } 60 | -------------------------------------------------------------------------------- /ui/src/pages/home/home.css: -------------------------------------------------------------------------------- 1 | .card-dialog-body-wrapper { 2 | padding: 0px 24px 8px 24px; 3 | } 4 | 5 | .card-dialog-body { 6 | padding-bottom: 8px; 7 | } 8 | .card-dialog-note { 9 | color: var(--text-secondary, rgba(15, 16, 21, 0.6)); 10 | font-family: 'Inter', sans-serif; 11 | font-size: 14px; 12 | font-style: normal; 13 | font-weight: 400; 14 | } 15 | 16 | .card-dialog-button-group { 17 | display: flex; 18 | padding: 8px; 19 | justify-content: flex-end; 20 | align-items: center; 21 | gap: 8px; 22 | align-self: stretch; 23 | background: rgba(238, 238, 238, 1); 24 | margin-bottom: 0px; 25 | } 26 | -------------------------------------------------------------------------------- /ui/src/pages/home/services-section/service-grid/service-grid.test.tsx: -------------------------------------------------------------------------------- 1 | import { apps } from '@src/data/api'; 2 | import { serviceApps } from '@src/data/jupyterhub'; 3 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 4 | import { act, render } from '@testing-library/react'; 5 | import { BrowserRouter } from 'react-router-dom'; 6 | import { RecoilRoot } from 'recoil'; 7 | import { ServiceGrid } from './service-grid'; 8 | 9 | describe('ServiceGrid', () => { 10 | const queryClient = new QueryClient({ 11 | defaultOptions: { 12 | queries: { 13 | retry: false, 14 | }, 15 | }, 16 | }); 17 | test('should render successfully', async () => { 18 | const { baseElement } = render( 19 | 20 | 21 | 22 | 23 | , 24 | ); 25 | await act(async () => { 26 | expect(baseElement).toBeTruthy(); 27 | }); 28 | }); 29 | 30 | test('should render with mock data', async () => { 31 | const { baseElement } = render( 32 | 33 | 34 | 35 | 36 | 37 | 38 | , 39 | ); 40 | await act(async () => { 41 | const cards = baseElement.querySelectorAll('.card'); 42 | expect(cards).toHaveLength(apps.length + serviceApps.length); 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /ui/src/pages/home/services-section/service-grid/service-grid.tsx: -------------------------------------------------------------------------------- 1 | import { AppCard } from '@src/components'; 2 | import { JhApp, JhServiceApp } from '@src/types/jupyterhub'; 3 | import React from 'react'; 4 | 5 | interface ServiceGridProps { 6 | services: JhServiceApp[]; 7 | apps: JhApp[]; 8 | } 9 | 10 | export const ServiceGrid = ({ 11 | services, 12 | apps, 13 | }: ServiceGridProps): React.ReactElement => { 14 | return ( 15 | <> 16 | {apps.map((app: JhApp, index: number) => ( 17 | 25 | ))} 26 | {services.map((service: JhServiceApp, index: number) => ( 27 | 34 | ))} 35 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /ui/src/pages/not-running/not-running.tsx: -------------------------------------------------------------------------------- 1 | import { Box, CircularProgress } from '@mui/material'; 2 | import { AppQueryGetProps } from '@src/types/api'; 3 | import { UserState } from '@src/types/user'; 4 | import axios from '@src/utils/axios'; 5 | import { APP_BASE_URL } from '@src/utils/constants'; 6 | import { getSpawnPendingUrl, storeAppToStart } from '@src/utils/jupyterhub'; 7 | import { useQuery } from '@tanstack/react-query'; 8 | import React, { useEffect } from 'react'; 9 | import { useRecoilState } from 'recoil'; 10 | import { currentUser as defaultUser } from '../../store'; 11 | 12 | export const NotRunning = (): React.ReactElement => { 13 | const [id, setId] = React.useState(null); 14 | const [currentUser] = useRecoilState(defaultUser); 15 | const { data: formData } = useQuery({ 16 | queryKey: ['app-form', id], 17 | queryFn: () => 18 | axios 19 | .get(`/server/${id}`) 20 | .then((response) => { 21 | return response.data; 22 | }) 23 | .catch((error) => { 24 | return { message: error.message }; 25 | }), 26 | enabled: !!id, 27 | }); 28 | 29 | useEffect(() => { 30 | if (currentUser) { 31 | const currentId = window.location.pathname 32 | .replace(/\/$/, '') 33 | .split('/') 34 | .pop(); 35 | if (currentId) { 36 | setId(currentId); 37 | } 38 | } 39 | }, [currentUser]); 40 | 41 | useEffect(() => { 42 | if (!formData) { 43 | return; 44 | } 45 | 46 | if (formData?.started) { 47 | window.location.assign(window.location.href.replace('/hub', '')); 48 | } else if (formData?.pending && currentUser && id) { 49 | window.location.assign(getSpawnPendingUrl(currentUser, id)); 50 | } else if (formData?.stopped && id) { 51 | storeAppToStart(id); // TODO: Update this to store in global state when everything is running in single react app 52 | window.location.assign(APP_BASE_URL); 53 | } else { 54 | window.location.assign(APP_BASE_URL); 55 | } 56 | }, [formData, id, currentUser]); 57 | 58 | return ( 59 | 60 | 61 | 62 | ); 63 | }; 64 | -------------------------------------------------------------------------------- /ui/src/pages/server-types/server-types.css: -------------------------------------------------------------------------------- 1 | .server-type-card { 2 | margin: 8px 0; 3 | cursor: pointer; 4 | } 5 | 6 | .server-type-card:hover { 7 | background: var(--gray-lightest); 8 | } 9 | 10 | .server-type-card:focus { 11 | outline: 2px solid var(--primary); 12 | } 13 | 14 | .server-type-card p { 15 | font-size: 14px; 16 | } 17 | -------------------------------------------------------------------------------- /ui/src/pages/stop-pending/stop-pending.css: -------------------------------------------------------------------------------- 1 | #stop-pending.container { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | text-align: center; 6 | font-family: Arial, sans-serif; 7 | padding-top: 50px; 8 | } 9 | 10 | .home-button { 11 | background-color: #a020f0; /* Purple color for the button */ 12 | color: white; 13 | border: none; 14 | padding: 10px 20px; 15 | border-radius: 5px; 16 | cursor: pointer; 17 | font-size: 16px; 18 | } 19 | 20 | .home-button:hover { 21 | background-color: #800080; /* Darker purple for hover effect */ 22 | } -------------------------------------------------------------------------------- /ui/src/pages/stop-pending/stop-pending.test.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 2 | import { render, screen } from '@testing-library/react'; 3 | import { BrowserRouter } from 'react-router-dom'; 4 | import { RecoilRoot } from 'recoil'; 5 | import { StopPending } from './stop-pending'; 6 | 7 | const queryClient = new QueryClient({ 8 | defaultOptions: { 9 | queries: { 10 | retry: false, 11 | }, 12 | }, 13 | }); 14 | 15 | const componentWrapper = ( 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ); 24 | 25 | describe('StopPending', () => { 26 | test('should render successfully', async () => { 27 | const { baseElement } = render(componentWrapper); 28 | expect(baseElement).toBeTruthy(); 29 | expect( 30 | screen.getByText(/Thank you for your patience/i), 31 | ).toBeInTheDocument(); 32 | expect( 33 | screen.getByText( 34 | /We are stopping your application, you may start it again when we have finished/i, 35 | ), 36 | ).toBeInTheDocument(); 37 | expect(screen.getByRole('progressbar')).toBeInTheDocument(); 38 | expect( 39 | screen.getByText(/You may return to the Application Screen at any time/i), 40 | ).toBeInTheDocument(); 41 | expect( 42 | screen.getByRole('button', { name: /Back To Home/i }), 43 | ).toBeInTheDocument(); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /ui/src/pages/stop-pending/stop-pending.tsx: -------------------------------------------------------------------------------- 1 | // src/pages/stop-pending/StopPending.tsx 2 | import { Box, Button, CircularProgress, Typography } from '@mui/material'; 3 | import { APP_BASE_URL } from '@src/utils/constants'; 4 | import { navigateToUrl } from '@src/utils/jupyterhub'; 5 | import React from 'react'; 6 | import './stop-pending.css'; 7 | 8 | export const StopPending = (): React.ReactElement => { 9 | return ( 10 | 19 | 25 | 31 | Thank you for your patience 32 |
33 | We are stopping your application, you may start it again when we have 34 | finished 35 |
36 | 37 |
38 | 39 | 40 | You may return to the Application Screen at any time 41 | 42 | 51 | 52 |
53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /ui/src/pages/success/success.test.tsx: -------------------------------------------------------------------------------- 1 | import axios from '@src/utils/axios'; 2 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 3 | import { act, render } from '@testing-library/react'; 4 | import MockAdapter from 'axios-mock-adapter'; 5 | import { BrowserRouter } from 'react-router-dom'; 6 | import { RecoilRoot } from 'recoil'; 7 | import { describe, expect } from 'vitest'; 8 | import { Success } from './success'; 9 | 10 | describe('Success Page', () => { 11 | const queryClient = new QueryClient({ 12 | defaultOptions: { 13 | queries: { 14 | retry: false, 15 | }, 16 | }, 17 | }); 18 | const mock = new MockAdapter(axios); 19 | beforeAll(() => { 20 | mock.reset(); 21 | }); 22 | 23 | const componentWrapper = ( 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | 33 | test('should render successfully', async () => { 34 | const { baseElement } = render(componentWrapper); 35 | await act(async () => { 36 | expect(baseElement).toBeTruthy(); 37 | expect(baseElement.querySelector('h1')?.textContent).toEqual( 38 | 'App Submitted Successfully!', 39 | ); 40 | }); 41 | }); 42 | 43 | test('should navigate to spawn-pending page', async () => { 44 | const spy = vi.spyOn(window, 'open'); 45 | const { baseElement } = render(componentWrapper); 46 | await act(async () => { 47 | const link = baseElement.querySelector('a'); 48 | expect(link).toBeTruthy(); 49 | if (link) { 50 | link.click(); 51 | expect(spy).toHaveBeenCalledWith( 52 | '/hub/spawn-pending/undefined/', 53 | '_blank', 54 | ); 55 | } 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /ui/src/pages/success/success.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Stack, Typography } from '@mui/material'; 2 | import { UserState } from '@src/types/user'; 3 | import { APP_BASE_URL } from '@src/utils/constants'; 4 | import React, { SyntheticEvent, useEffect } from 'react'; 5 | import { useSearchParams } from 'react-router-dom'; 6 | import { useRecoilState } from 'recoil'; 7 | import { StyledFormParagraph } from 'src/styles/styled-form-paragraph'; 8 | import { Item } from 'src/styles/styled-item'; 9 | import { currentUser as defaultUser } from '../../store'; 10 | 11 | export const Success = (): React.ReactElement => { 12 | const [currentUser] = useRecoilState(defaultUser); 13 | const [searchParams] = useSearchParams(); 14 | const username = currentUser?.name; 15 | const server = searchParams.get('id') || ''; 16 | 17 | const handleNavigate = (event: SyntheticEvent) => { 18 | event.preventDefault(); 19 | // Assume this page is only used when headless and inside an iframe 20 | window.parent.open( 21 | `${APP_BASE_URL}/spawn-pending/${username}/${server}`, 22 | '_blank', 23 | ); 24 | }; 25 | 26 | useEffect(() => { 27 | window.scrollTo(0, 0); 28 | }, []); 29 | 30 | return ( 31 | 38 | 39 | 40 | 41 | App Submitted Successfully! 42 | 43 | 44 | To view the status of your app deployment, please click{' '} 45 | 50 | here 51 | 52 | . 53 | 54 | 55 | 56 | 57 | ); 58 | }; 59 | -------------------------------------------------------------------------------- /ui/src/store.ts: -------------------------------------------------------------------------------- 1 | import { UserState } from '@src/types/user'; 2 | import { atom } from 'recoil'; 3 | import { AppProfileProps } from './types/api'; 4 | import { AppFormInput } from './types/form'; 5 | import { JhApp, JhData } from './types/jupyterhub'; 6 | 7 | const currentUser = atom({ 8 | key: 'currentUser', 9 | default: undefined, 10 | }); 11 | 12 | const currentJhData = atom({ 13 | key: 'currentJhData', 14 | default: { 15 | admin_access: false, 16 | base_url: '/hub', 17 | options_form: false, 18 | prefix: '/', 19 | user: '', 20 | xsrf_token: '', 21 | }, 22 | }); 23 | 24 | const currentNotification = atom({ 25 | key: 'currentNotification', 26 | default: undefined, 27 | }); 28 | 29 | const currentApp = atom({ 30 | key: 'currentApp', 31 | default: undefined, 32 | }); 33 | 34 | const currentServerName = atom({ 35 | key: 'currentServerName', 36 | default: undefined, 37 | }); 38 | 39 | const currentFormInput = atom({ 40 | key: 'currentFormInput', 41 | default: undefined, 42 | }); 43 | 44 | const currentImage = atom({ 45 | key: 'currentImage', 46 | default: undefined, 47 | }); 48 | 49 | const currentFile = atom({ 50 | key: 'currentFile', 51 | default: undefined, 52 | }); 53 | 54 | const currentSearchValue = atom({ 55 | key: 'currentSearchValue', 56 | default: '', 57 | }); 58 | 59 | const currentFrameworks = atom({ 60 | key: 'currentFrameworks', 61 | default: [], 62 | }); 63 | 64 | const currentProfiles = atom({ 65 | key: 'currentProfiles', 66 | default: [], 67 | }); 68 | 69 | const currentOwnershipValue = atom({ 70 | key: 'currentOwnershipValue', 71 | default: 'Any', 72 | }); 73 | 74 | const currentSortValue = atom({ 75 | key: 'currentSortValue', 76 | default: 'Recently modified', 77 | }); 78 | 79 | const currentServerStatuses = atom({ 80 | key: 'currentServerStatuses', 81 | default: [], 82 | }); 83 | 84 | const isStartOpen = atom({ 85 | key: 'isStartOpen', 86 | default: false, 87 | }); 88 | 89 | const isStopOpen = atom({ 90 | key: 'isStopOpen', 91 | default: false, 92 | }); 93 | 94 | const isDeleteOpen = atom({ 95 | key: 'isDeleteOpen', 96 | default: false, 97 | }); 98 | 99 | const isStartNotRunningOpen = atom({ 100 | key: 'isStartNotRunningOpen', 101 | default: false, 102 | }); 103 | 104 | const isHeadless = atom({ 105 | key: 'isHeadless', 106 | default: false, 107 | }); 108 | 109 | export { 110 | currentApp, 111 | currentFile, 112 | currentFormInput, 113 | currentFrameworks, 114 | currentImage, 115 | currentJhData, 116 | currentNotification, 117 | currentOwnershipValue, 118 | currentProfiles, 119 | currentSearchValue, 120 | currentServerName, 121 | currentServerStatuses, 122 | currentSortValue, 123 | currentUser, 124 | isDeleteOpen, 125 | isHeadless, 126 | isStartNotRunningOpen, 127 | isStartOpen, 128 | isStopOpen, 129 | }; 130 | -------------------------------------------------------------------------------- /ui/src/styles/styled-filter-button.tsx: -------------------------------------------------------------------------------- 1 | import { Button, styled } from '@mui/material'; 2 | 3 | export const StyledFilterButton = styled(Button)(({ theme }) => ({ 4 | color: theme.palette.common.black, 5 | borderColor: theme.palette.common.black, 6 | '&:hover': { 7 | color: theme.palette.common.black, 8 | borderColor: theme.palette.common.black, 9 | }, 10 | marginRight: '16px', 11 | })); 12 | -------------------------------------------------------------------------------- /ui/src/styles/styled-form-paragraph.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from '@mui/material'; 2 | 3 | export const StyledFormParagraph = styled('p')(() => ({ 4 | maxWidth: 600, 5 | paddingBottom: '30px', 6 | })); 7 | -------------------------------------------------------------------------------- /ui/src/styles/styled-form-section.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from '@mui/material'; 2 | 3 | export const StyledFormSection = styled('div')(() => ({ 4 | paddingBottom: '30px', 5 | })); 6 | -------------------------------------------------------------------------------- /ui/src/styles/styled-item.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from '@mui/material'; 2 | 3 | export const Item = styled('div')(({ theme }) => ({ 4 | padding: theme.spacing(0), 5 | })); 6 | -------------------------------------------------------------------------------- /ui/src/theme/colors.tsx: -------------------------------------------------------------------------------- 1 | export const blue = { 2 | 50: '#FAFBFC', 3 | 100: '#2491FF', 4 | 200: '#2491FF', 5 | 300: '#2491FF', 6 | 400: '#2491FF', 7 | 500: '#005EA2', 8 | 600: '#1A4480', 9 | 700: '#1A4480', 10 | 800: '#1A4480', 11 | 900: '#162E51', 12 | }; 13 | 14 | export const gray = { 15 | 50: 'rgba(0, 0, 0, .08)', 16 | 100: '#E1E3E4', 17 | 200: '#EEEEEE', 18 | 300: '#E0E0E0', 19 | 400: '#90969C', 20 | 500: '#5B5F63', 21 | 600: '#44474A', 22 | 700: '#3C3C3B', 23 | 800: '#242628', 24 | 900: '#1A1C1D', 25 | }; 26 | 27 | export const purple = '#BA18DA'; 28 | export const purpleLight = '#BA18DA10'; 29 | export const purpleDark = '#9B00CE'; 30 | 31 | export const green = '#18817A'; 32 | export const greenLight = '#18817A10'; 33 | export const greenDark = '#12635E'; 34 | 35 | export const red = '#D72D47'; 36 | 37 | export const orange = '#F66A0A'; 38 | 39 | export const white = '#FFFFFF'; 40 | 41 | export const black = '#0F1015'; 42 | 43 | export const disabled = '#0F101561'; 44 | 45 | export const grayLighter = gray[100]; 46 | -------------------------------------------------------------------------------- /ui/src/types.d.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jhub-apps/e9c46d542b1cc7f71452eee7b6caf6887a096e29/ui/src/types.d.ts -------------------------------------------------------------------------------- /ui/src/types/api.ts: -------------------------------------------------------------------------------- 1 | export interface SharePermissions { 2 | users: string[]; 3 | groups: string[]; 4 | } 5 | 6 | export interface KeyValuePair { 7 | key: string; 8 | value: string; 9 | } 10 | 11 | export interface UserOptions { 12 | jhub_app: boolean; 13 | display_name: string; 14 | description: string; 15 | thumbnail: string; 16 | filepath: string; 17 | framework: string; 18 | custom_command: string; 19 | conda_env: string; 20 | profile: string; 21 | profile_image?: string; 22 | public: boolean; 23 | share_with: SharePermissions; 24 | keep_alive: boolean; 25 | env: any; // eslint-disable-line @typescript-eslint/no-explicit-any 26 | repository?: { 27 | url: string; 28 | }; 29 | } 30 | 31 | export interface AppQueryUpdateProps { 32 | servername: string; 33 | user_options: UserOptions; 34 | } 35 | 36 | export interface AppQueryPostProps { 37 | id: string; 38 | full_name?: string; 39 | } 40 | 41 | export interface AppQueryDeleteProps { 42 | id: string; 43 | remove: boolean; 44 | } 45 | 46 | export interface AppQueryGetProps { 47 | name: string; 48 | last_activity: string; 49 | pending: null; 50 | ready: boolean; 51 | started: string; 52 | stopped: boolean; 53 | url: string; 54 | user_options: UserOptions; 55 | progress_url: string; 56 | state: Record; 57 | defaultBranch?: string; 58 | condaPath?: string; 59 | } 60 | 61 | export interface AppFrameworkProps { 62 | name: string; 63 | display_name: string; 64 | logo: string; 65 | } 66 | 67 | export interface AppProfileProps { 68 | display_name: string; 69 | slug: string; 70 | description: string; 71 | default?: boolean; 72 | kubespawner_override?: { 73 | image?: string; 74 | cpu_limit?: number; 75 | cpu_guarantee?: number; 76 | mem_limit?: string; 77 | mem_guarantee?: string; 78 | }; 79 | } 80 | -------------------------------------------------------------------------------- /ui/src/types/form.ts: -------------------------------------------------------------------------------- 1 | import { SharePermissions } from './api'; 2 | 3 | export interface FormInput { 4 | username: string; 5 | password: string; 6 | } 7 | 8 | export interface AppFormInput { 9 | conda_env?: string; 10 | custom_command?: string; 11 | description?: string; 12 | display_name: string; 13 | env?: string; 14 | filepath?: string; 15 | framework: string; 16 | is_public: boolean; 17 | keep_alive: boolean; 18 | jhub_app: boolean; 19 | profile?: string; 20 | profile_image?: string; 21 | thumbnail?: string; 22 | share_with: SharePermissions; 23 | repository?: { 24 | url: string; 25 | config_directory?: string; //conda_project_yml 26 | ref?: string; // branch 27 | }; 28 | } 29 | 30 | export interface AppSharingItem { 31 | name: string; 32 | type: 'user' | 'group'; 33 | } 34 | 35 | export interface AppFormProps { 36 | deployOption?: string; 37 | id?: string; 38 | isEditMode: boolean; 39 | } 40 | 41 | export interface RepoData { 42 | display_name: string; 43 | description: string; 44 | thumbnail: string; // Base64 encoded image string 45 | filepath: string; 46 | framework: string; 47 | custom_command: string; 48 | conda_project_yml: string; 49 | env: { 50 | conda_env: string; 51 | SOMETHING_BAR: string; 52 | SOMETHING_FOO: string; 53 | }; 54 | keep_alive: boolean; 55 | public: boolean; 56 | repository: { 57 | config_directory: string; 58 | ref: string; 59 | url: string; 60 | }; 61 | } 62 | -------------------------------------------------------------------------------- /ui/src/types/jupyterhub.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | export interface JhData { 3 | admin_access: boolean; 4 | base_url: string; 5 | options_form: boolean; 6 | prefix: string; 7 | user: string; 8 | xsrf_token: string; 9 | logo?: string; 10 | } 11 | 12 | export interface JhApp { 13 | id: string; 14 | name: string; 15 | description?: string; 16 | framework: string; 17 | profile?: string; 18 | url: string; 19 | thumbnail?: string; 20 | username?: string; 21 | ready: boolean; 22 | public: boolean; 23 | shared: boolean; 24 | last_activity: Date; 25 | pending?: boolean; 26 | stopped?: boolean; 27 | status: string; 28 | full_name?: string; 29 | } 30 | 31 | export interface JhServiceApp { 32 | id: string; 33 | name: string; 34 | description?: string; 35 | framework: string; 36 | url: string; 37 | thumbnail?: string; 38 | username?: string; 39 | status: string; 40 | } 41 | 42 | export interface JhService { 43 | name: string; 44 | description?: string; 45 | url: string; 46 | external: boolean; 47 | pinned: boolean; 48 | thumbnail?: string; 49 | } 50 | 51 | export interface JhServiceFull { 52 | prefix: string; 53 | kind: string; 54 | info: any; 55 | admin: boolean; 56 | display: boolean; 57 | roles: string[]; 58 | pid: number; 59 | url: string; 60 | name: string; 61 | command: string[]; 62 | } 63 | -------------------------------------------------------------------------------- /ui/src/types/user.ts: -------------------------------------------------------------------------------- 1 | import { SharePermissions } from './api'; 2 | 3 | /* eslint-disable @typescript-eslint/no-explicit-any */ 4 | export interface UserState { 5 | username: string | undefined; 6 | admin: boolean; 7 | auth_state: string | null; 8 | created: string | null; 9 | groups: string[]; 10 | kind: string | null; 11 | last_activity: string | null; 12 | name: string; 13 | pending: boolean | null; 14 | roles: string[]; 15 | scopes: string[]; 16 | server: string | null; 17 | servers: any; 18 | session_id: string | null; 19 | share_permissions: SharePermissions; 20 | } 21 | -------------------------------------------------------------------------------- /ui/src/utils/axios.ts: -------------------------------------------------------------------------------- 1 | import { 2 | environments, 3 | frameworks, 4 | profiles, 5 | serverApps, 6 | services, 7 | } from '@src/data/api'; 8 | import { currentUser } from '@src/data/user'; 9 | import axios from 'axios'; 10 | 11 | const instance = axios.create({ 12 | baseURL: process.env.API_BASE_URL, 13 | headers: { 14 | 'Content-Type': 'application/json', 15 | }, 16 | }); 17 | 18 | instance.interceptors.response.use( 19 | (response) => { 20 | const env = process.env.NODE_ENV; 21 | // If development, mock api calls 22 | if ( 23 | env === 'development' && 24 | response.config.method === 'get' && 25 | response.config.url 26 | ) { 27 | const url = response.config.url; 28 | // url data maps for basic endpoints 29 | const urlPathResponseDataMap = { 30 | '/user': currentUser, 31 | '/services/': services, 32 | '/server/': serverApps, 33 | '/frameworks/': frameworks, 34 | '/conda-environments/': environments, 35 | '/spawner-profiles/': profiles, 36 | } as any; //eslint-disable-line 37 | 38 | const data = urlPathResponseDataMap[url]; 39 | if (data) { 40 | response.data = data; 41 | } else if (url?.match(/^\/server\/.*$/)) { 42 | let serverName = url.split('/')[2]; 43 | if (serverName === 'lab' || serverName === 'vscode') { 44 | serverName = ''; 45 | } 46 | 47 | const serverApp = serverApps.user_apps.find( 48 | (app) => app.name === serverName, 49 | ); 50 | if (serverApp) { 51 | response.data = serverApp; 52 | } 53 | } 54 | } 55 | 56 | return response; 57 | }, 58 | (error) => { 59 | const status = error.response.status; 60 | if (error.response.status === 401 || status === 403) { 61 | window.location.href = '/services/japps/jhub-login'; 62 | } 63 | }, 64 | ); 65 | 66 | export default instance; 67 | -------------------------------------------------------------------------------- /ui/src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const APP_TITLE = 'JupyterHub'; 2 | export const APP_BASE_URL = process.env.APP_BASE_URL || '/'; 3 | export const API_BASE_URL = process.env.API_BASE_URL || '/'; 4 | export const REQUIRED_FIELD_MESSAGE = 'This field is required.'; 5 | 6 | export const REQUIRED_FORM_FIELDS_RULES = { 7 | required: REQUIRED_FIELD_MESSAGE, 8 | }; 9 | 10 | export const OWNERSHIP_TYPES = ['Any', 'Owned by me', 'Shared with me']; 11 | export const SORT_TYPES = ['Recently modified', 'Name: A-Z', 'Name: Z-A']; 12 | export const SERVER_STATUSES = ['Running', 'Ready', 'Pending', 'Unknown']; 13 | 14 | export const APP_TO_START_KEY = 'startAppId'; 15 | -------------------------------------------------------------------------------- /ui/src/utils/jupyterhub-axios.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { getJhData } from './jupyterhub'; 3 | 4 | const instance = axios.create({ 5 | baseURL: '/hub/api', 6 | headers: { 7 | 'Content-Type': 'application/json', 8 | Authorization: `Authorization: token ${process.env.JUPYERHUB_API_TOKEN}`, 9 | }, 10 | params: { 11 | _xsrf: getJhData().xsrf_token, 12 | }, 13 | }); 14 | 15 | export default instance; 16 | -------------------------------------------------------------------------------- /ui/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /// 3 | export {}; 4 | 5 | declare global { 6 | interface Window { 7 | jhdata: any; 8 | theme: any; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | "esModuleInterop": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | 19 | /* Testing */ 20 | "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"], 21 | 22 | /* Linting */ 23 | "strict": true, 24 | "noUnusedLocals": true, 25 | "noUnusedParameters": true, 26 | "noFallthroughCasesInSwitch": true, 27 | 28 | /* Paths */ 29 | "paths": { 30 | "@src/components": ["src/components"], 31 | "@src/data/*": ["src/data/*"], 32 | "@src/hooks/*": ["src/hooks/*"], 33 | "@src/pages/*": ["src/pages/*"], 34 | "@src/types/*": ["src/types/*"], 35 | "@src/utils/*": ["src/utils/*"] 36 | } 37 | }, 38 | "include": ["src"], 39 | "references": [{ "path": "./tsconfig.node.json" }] 40 | } 41 | -------------------------------------------------------------------------------- /ui/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "types": ["vite/client", "vitest/globals"] 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /ui/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [ 3 | {"source": "/(.*)", "destination": "/"} 4 | ] 5 | } 6 | 7 | -------------------------------------------------------------------------------- /ui/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react'; 2 | import { exec } from 'child_process'; 3 | import { defineConfig } from 'vite'; 4 | import EnvironmentPlugin from 'vite-plugin-environment'; 5 | import eslint from 'vite-plugin-eslint'; 6 | import tsconfigPaths from 'vite-tsconfig-paths'; 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig({ 10 | plugins: [ 11 | react(), 12 | tsconfigPaths(), 13 | eslint(), 14 | EnvironmentPlugin('all'), 15 | { 16 | name: 'run-build-script', 17 | apply: 'build', 18 | writeBundle() { 19 | exec('./build-and-copy.sh', (error) => { 20 | if (error) { 21 | console.error(`Build error: ${error}`); 22 | return; 23 | } 24 | console.log(`Build and copy complete.`); 25 | }); 26 | }, 27 | }, 28 | ], 29 | server: { 30 | port: 8080, 31 | }, 32 | test: { 33 | globals: true, 34 | environment: 'jsdom', 35 | setupFiles: './vitest.setup.ts', 36 | minWorkers: 1, 37 | maxWorkers: 1, 38 | coverage: { 39 | all: false, 40 | provider: 'v8', 41 | thresholds: { 42 | global: { 43 | statements: 80, 44 | branches: 80, 45 | functions: 80, 46 | lines: 80, 47 | }, 48 | }, 49 | }, 50 | }, 51 | }); 52 | -------------------------------------------------------------------------------- /ui/vitest.setup.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import '@testing-library/jest-dom/vitest'; 3 | export {}; 4 | 5 | declare global { 6 | interface Window { 7 | jhdata: any; 8 | theme: any; 9 | } 10 | } 11 | 12 | window.jhdata = { 13 | base_url: '/hub/', 14 | prefix: '/', 15 | user: 'test', 16 | admin_access: false, 17 | options_form: false, 18 | xsrf_token: '2|12345|12345|12345', 19 | }; 20 | 21 | window.theme = { 22 | logo: '/img/logo.png', 23 | }; 24 | --------------------------------------------------------------------------------