diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 1aafe20..f2155c4 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -64,17 +64,17 @@ This Python configuration file controls all JupyterHub behavior: **Environment Variables** (set in compose.yml or compose_override.yml): - `JUPYTERHUB_ADMIN`: Admin username (default: `admin`) -- `DOCKER_NOTEBOOK_IMAGE`: JupyterLab image to spawn (default: `stellars/stellars-jupyterlab-ds:latest`) -- `DOCKER_NETWORK_NAME`: Network for spawned containers (default: `jupyterhub_network`) +- `JUPYTERHUB_NOTEBOOK_IMAGE`: JupyterLab image to spawn (default: `stellars/stellars-jupyterlab-ds:latest`) +- `JUPYTERHUB_NETWORK_NAME`: Network for spawned containers (default: `jupyterhub_network`) - `JUPYTERHUB_BASE_URL`: URL prefix (default: `/jupyterhub`) -- `ENABLE_GPU_SUPPORT`: GPU mode - `0` (disabled), `1` (enabled), `2` (auto-detect) -- `ENABLE_JUPYTERHUB_SSL`: Direct SSL config - `0` (disabled), `1` (enabled) -- `ENABLE_SERVICE_MLFLOW`: Enable MLflow tracking (`0`/`1`) -- `ENABLE_SERVICE_GLANCES`: Enable resource monitor (`0`/`1`) -- `ENABLE_SERVICE_TENSORBOARD`: Enable TensorBoard (`0`/`1`) -- `NVIDIA_AUTODETECT_IMAGE`: Image for GPU detection (default: `nvidia/cuda:12.9.1-base-ubuntu24.04`) +- `JUPYTERHUB_GPU_ENABLED`: GPU mode - `0` (disabled), `1` (enabled), `2` (auto-detect) +- `JUPYTERHUB_SSL_ENABLED`: Direct SSL config - `0` (disabled), `1` (enabled) +- `JUPYTERHUB_SERVICE_MLFLOW`: Enable MLflow tracking (`0`/`1`) +- `JUPYTERHUB_SERVICE_GLANCES`: Enable resource monitor (`0`/`1`) +- `JUPYTERHUB_SERVICE_TENSORBOARD`: Enable TensorBoard (`0`/`1`) +- `JUPYTERHUB_NVIDIA_IMAGE`: Image for GPU detection (default: `nvidia/cuda:12.9.1-base-ubuntu24.04`) -**GPU Auto-Detection**: When `ENABLE_GPU_SUPPORT=2`, the platform attempts to run `nvidia-smi` in a CUDA container. If successful, GPU support is enabled for all spawned user containers via `device_requests`. +**GPU Auto-Detection**: When `JUPYTERHUB_GPU_ENABLED=2`, the platform attempts to run `nvidia-smi` in a CUDA container. If successful, GPU support is enabled for all spawned user containers via `device_requests`. **User Container Configuration**: - Spawned containers use `DockerSpawner` with per-user volumes @@ -96,7 +96,7 @@ services: volumes: - ./config/jupyterhub_config_override.py:/srv/jupyterhub/jupyterhub_config.py:ro environment: - - ENABLE_GPU_SUPPORT=1 + - JUPYTERHUB_GPU_ENABLED=1 ``` **IMPORTANT**: `compose_override.yml` contains deployment-specific credentials (CIFS passwords, etc.) and should never be committed. @@ -300,8 +300,8 @@ User renames via JupyterHub admin panel automatically sync to NativeAuthenticato **GPU not detected**: - Verify NVIDIA Docker runtime: `docker run --rm --gpus all nvidia/cuda:12.9.1-base-ubuntu24.04 nvidia-smi` -- Check `NVIDIA_AUTODETECT_IMAGE` matches your CUDA version -- Manually enable with `ENABLE_GPU_SUPPORT=1` +- Check `JUPYTERHUB_NVIDIA_IMAGE` matches your CUDA version +- Manually enable with `JUPYTERHUB_GPU_ENABLED=1` **Container spawn failures**: - Check Docker socket permissions: `/var/run/docker.sock` must be accessible diff --git a/.claude/JOURNAL.md b/.claude/JOURNAL.md index 503f517..93f2739 100644 --- a/.claude/JOURNAL.md +++ b/.claude/JOURNAL.md @@ -141,3 +141,6 @@ This journal tracks substantive work on documents, diagrams, and documentation c 46. **Task - Idle server culler**: Implemented automatic shutdown of inactive servers
**Result**: Added jupyterhub-idle-culler package to Dockerfile. Added configuration with environment variables: IDLE_CULLER_ENABLED (default 0), IDLE_CULLER_TIMEOUT (default 86400s/24h), IDLE_CULLER_CULL_EVERY (default 600s/10min), IDLE_CULLER_MAX_AGE (default 0/unlimited). Service runs as managed JupyterHub service with role-based scopes. Disabled by default, opt-in via IDLE_CULLER_ENABLED=1. Bumped version to 3.6.0 + +47. **Task - Standardize env vars with JUPYTERHUB_ prefix**: Renamed all environment variables to use consistent JUPYTERHUB_ prefix and added admin Settings page
+ **Result**: Renamed 13 environment variables (ENABLE_GPU_SUPPORT->JUPYTERHUB_GPU_ENABLED, ENABLE_JUPYTERHUB_SSL->JUPYTERHUB_SSL_ENABLED, ENABLE_SERVICE_*->JUPYTERHUB_SERVICE_*, ENABLE_SIGNUP->JUPYTERHUB_SIGNUP_ENABLED, DOCKER_NOTEBOOK_IMAGE->JUPYTERHUB_NOTEBOOK_IMAGE, DOCKER_NETWORK_NAME->JUPYTERHUB_NETWORK_NAME, NVIDIA_AUTODETECT_IMAGE->JUPYTERHUB_NVIDIA_IMAGE, IDLE_CULLER_*->JUPYTERHUB_IDLE_CULLER_*). Created SettingsPageHandler with admin-only access at /settings showing read-only banded table of all configuration values. Updated compose.yml, jupyterhub_config.py, custom_handlers.py, README.md, CLAUDE.md. Added Settings link to admin navbar. No backward compatibility for old names. Bumped version to 3.6.1 diff --git a/README.md b/README.md index 497c350..de1c216 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Multi-user JupyterHub 4 deployment platform with data science stack, GPU support - **User Self-Service**: Users can restart their JupyterLab containers and selectively reset persistent volumes (home/workspace/cache) without admin intervention - **Docker Access Control**: Group-based access via `docker-sock` (container orchestration) and `docker-privileged` (full container privileges) - **Isolated Environments**: Each user gets dedicated JupyterLab container with persistent volumes via DockerSpawner -- **Native Authentication**: Built-in user management with NativeAuthenticator supporting optional self-registration (`ENABLE_SIGNUP`) and admin approval. Authorization page protects existing users from accidental discard - only pending signup requests can be discarded +- **Native Authentication**: Built-in user management with NativeAuthenticator supporting optional self-registration (`JUPYTERHUB_SIGNUP_ENABLED`) and admin approval. Authorization page protects existing users from accidental discard - only pending signup requests can be discarded - **Admin User Creation**: Batch user creation from admin panel with auto-generated mnemonic passwords (e.g., `storm-apple-ocean`). Credentials modal with copy/download options - **Shared Storage**: Optional CIFS/NAS mount support for shared datasets across all users - **Idle Server Culler**: Automatic shutdown of inactive servers after configurable timeout (default: 24 hours). Frees resources when users leave servers running @@ -95,19 +95,19 @@ graph TB subgraph ENV["Environment Variables (compose.yml)"] ADMIN[JUPYTERHUB_ADMIN
Admin username] BASEURL[JUPYTERHUB_BASE_URL
URL prefix] - IMG[DOCKER_NOTEBOOK_IMAGE
User container image] - NET[DOCKER_NETWORK_NAME
Container network] - SSL[ENABLE_JUPYTERHUB_SSL
0=off, 1=on] - GPU[ENABLE_GPU_SUPPORT
0=off, 1=on, 2=auto] - SIGNUP[ENABLE_SIGNUP
0=admin-only, 1=self-register] + IMG[JUPYTERHUB_NOTEBOOK_IMAGE
User container image] + NET[JUPYTERHUB_NETWORK_NAME
Container network] + SSL[JUPYTERHUB_SSL_ENABLED
0=off, 1=on] + GPU[JUPYTERHUB_GPU_ENABLED
0=off, 1=on, 2=auto] + SIGNUP[JUPYTERHUB_SIGNUP_ENABLED
0=admin-only, 1=self-register] TFLOG[TF_CPP_MIN_LOG_LEVEL
TensorFlow verbosity] - NVIMG[NVIDIA_AUTODETECT_IMAGE
CUDA test image] + NVIMG[JUPYTERHUB_NVIDIA_IMAGE
CUDA test image] - subgraph SVCEN["ENABLE_SERVICE_*
Passed to Lab as env"] + subgraph SVCEN["JUPYTERHUB_SERVICE_*
Passed to Lab as env"] direction LR - MLF[ENABLE_SERVICE_MLFLOW] - GLN[ENABLE_SERVICE_GLANCES] - TNS[ENABLE_SERVICE_TENSORBOARD] + MLF[JUPYTERHUB_SERVICE_MLFLOW] + GLN[JUPYTERHUB_SERVICE_GLANCES] + TNS[JUPYTERHUB_SERVICE_TENSORBOARD] SVC_MORE[...] end end @@ -170,7 +170,7 @@ Environment variables defined in `compose.yml` are consumed by `config/jupyterhu ```mermaid graph LR - START[ENABLE_GPU_SUPPORT=2] --> CHECK{Check value} + START[JUPYTERHUB_GPU_ENABLED=2] --> CHECK{Check value} CHECK -->|0| DISABLED[GPU Disabled] CHECK -->|1| ENABLED[GPU Enabled] CHECK -->|2| DETECT[Auto-detect] @@ -179,8 +179,8 @@ graph LR SPAWN --> RUN[Execute nvidia-smi
with runtime=nvidia] RUN --> SUCCESS{Success?} - SUCCESS -->|Yes| SET_ON[Set ENABLE_GPU_SUPPORT=1
Set NVIDIA_DETECTED=1] - SUCCESS -->|No| SET_OFF[Set ENABLE_GPU_SUPPORT=0
Set NVIDIA_DETECTED=0] + SUCCESS -->|Yes| SET_ON[Set JUPYTERHUB_GPU_ENABLED=1
Set NVIDIA_DETECTED=1] + SUCCESS -->|No| SET_OFF[Set JUPYTERHUB_GPU_ENABLED=0
Set NVIDIA_DETECTED=0] SET_ON --> CLEANUP1[Remove test container
jupyterhub_nvidia_autodetect] SET_OFF --> CLEANUP2[Remove test container
jupyterhub_nvidia_autodetect] @@ -199,7 +199,7 @@ graph LR style APPLY_OFF stroke:#6b7280,stroke-width:2px ``` -When `ENABLE_GPU_SUPPORT=2` (auto-detect mode), JupyterHub spawns a temporary CUDA container running `nvidia-smi` with `runtime=nvidia`. If the command succeeds, GPU support is enabled and `device_requests` are added to spawned user containers. If it fails, GPU support is disabled. The test container is always removed after detection. Manual override is possible by setting `ENABLE_GPU_SUPPORT=1` (force enable) or `ENABLE_GPU_SUPPORT=0` (force disable). +When `JUPYTERHUB_GPU_ENABLED=2` (auto-detect mode), JupyterHub spawns a temporary CUDA container running `nvidia-smi` with `runtime=nvidia`. If the command succeeds, GPU support is enabled and `device_requests` are added to spawned user containers. If it fails, GPU support is disabled. The test container is always removed after detection. Manual override is possible by setting `JUPYTERHUB_GPU_ENABLED=1` (force enable) or `JUPYTERHUB_GPU_ENABLED=0` (force disable). ## User Self-Service Workflow @@ -340,14 +340,14 @@ services: #### Enable GPU No changes required in the configuration if you allow NVidia autodetection to be performed. -Otherwise change the `ENABLE_GPU_SUPPORT = 1` +Otherwise change the `JUPYTERHUB_GPU_ENABLED=1` Changes in your `compose_override.yml`: ```yaml services: jupyterhub: environment: - - ENABLE_GPU_SUPPORT=1 # enable NVIDIA GPU, values: 0 - disabled, 1 - enabled, 2 - auto-detect + - JUPYTERHUB_GPU_ENABLED=1 # enable NVIDIA GPU, values: 0 - disabled, 1 - enabled, 2 - auto-detect ``` #### Disable self-registration @@ -358,7 +358,7 @@ By default, users can self-register and require admin approval. To disable self- services: jupyterhub: environment: - - ENABLE_SIGNUP=0 # disable self-registration, admin creates users + - JUPYTERHUB_SIGNUP_ENABLED=0 # disable self-registration, admin creates users ``` #### Idle Server Culler @@ -369,15 +369,15 @@ Automatically stop user servers after a period of inactivity to free up resource services: jupyterhub: environment: - - IDLE_CULLER_ENABLED=1 # enable idle culler - - IDLE_CULLER_TIMEOUT=86400 # 24 hours (default) - stop after this many seconds of inactivity - - IDLE_CULLER_CULL_EVERY=600 # 10 minutes (default) - how often to check for idle servers - - IDLE_CULLER_MAX_AGE=0 # 0 (default) - max server age regardless of activity (0=unlimited) + - JUPYTERHUB_IDLE_CULLER_ENABLED=1 # enable idle culler + - JUPYTERHUB_IDLE_CULLER_TIMEOUT=86400 # 24 hours (default) - stop after this many seconds of inactivity + - JUPYTERHUB_IDLE_CULLER_INTERVAL=600 # 10 minutes (default) - how often to check for idle servers + - JUPYTERHUB_IDLE_CULLER_MAX_AGE=0 # 0 (default) - max server age regardless of activity (0=unlimited) ``` **Behavior**: -- `IDLE_CULLER_TIMEOUT`: Server is stopped after this many seconds without activity. Active servers are never culled -- `IDLE_CULLER_MAX_AGE`: Force stop servers older than this (useful to force image updates). Set to 0 to disable +- `JUPYTERHUB_IDLE_CULLER_TIMEOUT`: Server is stopped after this many seconds without activity. Active servers are never culled +- `JUPYTERHUB_IDLE_CULLER_MAX_AGE`: Force stop servers older than this (useful to force image updates). Set to 0 to disable #### Custom Branding diff --git a/compose.yml b/compose.yml index bda459d..e82e840 100644 --- a/compose.yml +++ b/compose.yml @@ -59,20 +59,25 @@ services: - jupyterhub_shared:/mnt/shared # shared volume across environments - /var/run/docker.sock:/var/run/docker.sock:ro # for nvidia autodetection environment: + # Core settings - JUPYTERHUB_ADMIN=admin # this username will be a JupyterHub admin - - DOCKER_NOTEBOOK_IMAGE=stellars/stellars-jupyterlab-ds:latest # jupyterlab image to spawn - - DOCKER_NETWORK_NAME=jupyterhub_network # spawned containers will join this network - - JUPYTERHUB_BASE_URL=/jupyterhub # default prefix - - CERTIFICATE_DOMAIN_NAME=localhost # domain name for self-signed certificate generation - - ENABLE_GPU_SUPPORT=2 # gpu status: 0 - disabled, 1 - enabled, 2 - auto-detect - - ENABLE_JUPYTERHUB_SSL=0 # if using traefik - you do need direct SSL config - - ENABLE_SERVICE_MLFLOW=1 # enable mlflow for experiment tracking - - ENABLE_SERVICE_GLANCES=1 # enable resources monitor - - ENABLE_SERVICE_TENSORBOARD=1 # enable tensorflow training tracker - - ENABLE_SIGNUP=1 # user self-registration: 0 - disabled (admin creates users), 1 - enabled - - TF_CPP_MIN_LOG_LEVEL=3 # make tensorflow less verbose - - NVIDIA_AUTODETECT_IMAGE=nvidia/cuda:12.9.1-base-ubuntu24.04 # image with `nvidia-smi` for gpu autodetection - - JUPYTERHUB_CUSTOM_LOGO_URI= # custom logo URL, file:/// path, or data URI (empty = stock logo) + - JUPYTERHUB_BASE_URL=/jupyterhub # default URL prefix + - JUPYTERHUB_SIGNUP_ENABLED=1 # user self-registration: 0=disabled, 1=enabled + - JUPYTERHUB_SSL_ENABLED=0 # direct SSL: 0=disabled (use Traefik), 1=enabled + # Docker spawner + - JUPYTERHUB_NOTEBOOK_IMAGE=stellars/stellars-jupyterlab-ds:latest # user container image + - JUPYTERHUB_NETWORK_NAME=jupyterhub_network # container network + # GPU support + - JUPYTERHUB_GPU_ENABLED=2 # 0=disabled, 1=enabled, 2=auto-detect + - JUPYTERHUB_NVIDIA_IMAGE=nvidia/cuda:13.0.2-base-ubuntu24.04 # GPU detection image + # User environment services + - JUPYTERHUB_SERVICE_MLFLOW=1 # MLflow experiment tracking + - JUPYTERHUB_SERVICE_GLANCES=1 # system resources monitor + - JUPYTERHUB_SERVICE_TENSORBOARD=1 # TensorFlow training tracker + # Misc + - TF_CPP_MIN_LOG_LEVEL=3 # TensorFlow verbosity + - CERTIFICATE_DOMAIN_NAME=localhost # self-signed certificate domain + - JUPYTERHUB_CUSTOM_LOGO_URI= # custom logo (empty = stock logo) labels: # Enable proxy support from Traefik - "traefik.enable=true" diff --git a/config/jupyterhub_config.py b/config/jupyterhub_config.py index 524a6d3..4b05dc0 100644 --- a/config/jupyterhub_config.py +++ b/config/jupyterhub_config.py @@ -182,40 +182,42 @@ def detect_nvidia(nvidia_autodetect_image='nvidia/cuda:12.9.1-base-ubuntu24.04') return result -# standard variables imported from env -ENABLE_JUPYTERHUB_SSL = int(os.environ.get("ENABLE_JUPYTERHUB_SSL", 1)) -ENABLE_GPU_SUPPORT = int(os.environ.get("ENABLE_GPU_SUPPORT", 2)) -ENABLE_SERVICE_MLFLOW = int(os.environ.get("ENABLE_SERVICE_MLFLOW", 1)) -ENABLE_SERVICE_GLANCES = int(os.environ.get("ENABLE_SERVICE_GLANCES", 1)) -ENABLE_SERVICE_TENSORBOARD = int(os.environ.get("ENABLE_SERVICE_TENSORBOARD", 1)) -ENABLE_SIGNUP = int(os.environ.get("ENABLE_SIGNUP", 1)) # 0 - disabled (admin creates users), 1 - enabled (self-registration) -IDLE_CULLER_ENABLED = int(os.environ.get("IDLE_CULLER_ENABLED", 0)) # 0 - disabled, 1 - enabled -IDLE_CULLER_TIMEOUT = int(os.environ.get("IDLE_CULLER_TIMEOUT", 86400)) # idle timeout in seconds (default: 24 hours) -IDLE_CULLER_CULL_EVERY = int(os.environ.get("IDLE_CULLER_CULL_EVERY", 600)) # check interval in seconds (default: 10 min) -IDLE_CULLER_MAX_AGE = int(os.environ.get("IDLE_CULLER_MAX_AGE", 0)) # max server age in seconds (0 = unlimited) -TF_CPP_MIN_LOG_LEVEL = int(os.environ.get("TF_CPP_MIN_LOG_LEVEL", 3)) +# Standard variables imported from env (all use JUPYTERHUB_ prefix) +JUPYTERHUB_SSL_ENABLED = int(os.environ.get("JUPYTERHUB_SSL_ENABLED", 1)) +JUPYTERHUB_GPU_ENABLED = int(os.environ.get("JUPYTERHUB_GPU_ENABLED", 2)) +JUPYTERHUB_SERVICE_MLFLOW = int(os.environ.get("JUPYTERHUB_SERVICE_MLFLOW", 1)) +JUPYTERHUB_SERVICE_GLANCES = int(os.environ.get("JUPYTERHUB_SERVICE_GLANCES", 1)) +JUPYTERHUB_SERVICE_TENSORBOARD = int(os.environ.get("JUPYTERHUB_SERVICE_TENSORBOARD", 1)) +JUPYTERHUB_SIGNUP_ENABLED = int(os.environ.get("JUPYTERHUB_SIGNUP_ENABLED", 1)) # 0=disabled, 1=enabled +JUPYTERHUB_IDLE_CULLER_ENABLED = int(os.environ.get("JUPYTERHUB_IDLE_CULLER_ENABLED", 0)) +JUPYTERHUB_IDLE_CULLER_TIMEOUT = int(os.environ.get("JUPYTERHUB_IDLE_CULLER_TIMEOUT", 86400)) +JUPYTERHUB_IDLE_CULLER_INTERVAL = int(os.environ.get("JUPYTERHUB_IDLE_CULLER_INTERVAL", 600)) +JUPYTERHUB_IDLE_CULLER_MAX_AGE = int(os.environ.get("JUPYTERHUB_IDLE_CULLER_MAX_AGE", 0)) +TF_CPP_MIN_LOG_LEVEL = int(os.environ.get("TF_CPP_MIN_LOG_LEVEL", 3)) DOCKER_NOTEBOOK_DIR = "/home/lab/workspace" JUPYTERHUB_BASE_URL = os.environ.get("JUPYTERHUB_BASE_URL") +JUPYTERHUB_NETWORK_NAME = os.environ.get("JUPYTERHUB_NETWORK_NAME", "jupyterhub_network") +JUPYTERHUB_NOTEBOOK_IMAGE = os.environ.get("JUPYTERHUB_NOTEBOOK_IMAGE", "stellars/stellars-jupyterlab-ds:latest") +JUPYTERHUB_NVIDIA_IMAGE = os.environ.get("JUPYTERHUB_NVIDIA_IMAGE", "nvidia/cuda:12.9.1-base-ubuntu24.04") # Normalize base URL - use empty string for root path to avoid double slashes if JUPYTERHUB_BASE_URL in ['/', '', None]: JUPYTERHUB_BASE_URL_PREFIX = '' else: JUPYTERHUB_BASE_URL_PREFIX = JUPYTERHUB_BASE_URL -JUPYTERHUB_ADMIN = os.environ.get("JUPYTERHUB_ADMIN") -NETWORK_NAME = os.environ["DOCKER_NETWORK_NAME"] -NVIDIA_AUTODETECT_IMAGE = os.environ.get("NVIDIA_AUTODETECT_IMAGE", 'nvidia/cuda:12.9.1-base-ubuntu24.04') +JUPYTERHUB_ADMIN = os.environ.get("JUPYTERHUB_ADMIN") -# perform autodetection when ENABLE_GPU_SUPPORT is set to autodetect +# perform autodetection when JUPYTERHUB_GPU_ENABLED is set to autodetect # gpu support: 0 - disabled, 1 - enabled, 2 - autodetect -if ENABLE_GPU_SUPPORT == 2: - NVIDIA_DETECTED = detect_nvidia(NVIDIA_AUTODETECT_IMAGE) - if NVIDIA_DETECTED: ENABLE_GPU_SUPPORT = 1 # means - gpu enabled - else: ENABLE_GPU_SUPPORT = 0 # means - disable +NVIDIA_DETECTED = 0 # Initialize before potential auto-detection +if JUPYTERHUB_GPU_ENABLED == 2: + NVIDIA_DETECTED = detect_nvidia(JUPYTERHUB_NVIDIA_IMAGE) + if NVIDIA_DETECTED: JUPYTERHUB_GPU_ENABLED = 1 # means - gpu enabled + else: JUPYTERHUB_GPU_ENABLED = 0 # means - disable # Apply JupyterHub configuration (only when loaded by JupyterHub, not when imported) if c is not None: # ensure that we are using SSL, it should be enabled by default - if ENABLE_JUPYTERHUB_SSL == 1: + if JUPYTERHUB_SSL_ENABLED == 1: c.JupyterHub.ssl_cert = '/mnt/certs/server.crt' c.JupyterHub.ssl_key = '/mnt/certs/server.key' @@ -230,16 +232,16 @@ if c is not None: 'MLFLOW_PORT':5000, 'MLFLOW_HOST':'0.0.0.0', # new 3.5 mlflow launched with guinicorn requires this 'MLFLOW_WORKERS':1, - 'ENABLE_SERVICE_MLFLOW': ENABLE_SERVICE_MLFLOW, - 'ENABLE_SERVICE_GLANCES': ENABLE_SERVICE_GLANCES, - 'ENABLE_SERVICE_TENSORBOARD': ENABLE_SERVICE_TENSORBOARD, - 'ENABLE_GPU_SUPPORT': ENABLE_GPU_SUPPORT, - 'ENABLE_GPUSTAT': ENABLE_GPU_SUPPORT, + 'JUPYTERHUB_SERVICE_MLFLOW': JUPYTERHUB_SERVICE_MLFLOW, + 'JUPYTERHUB_SERVICE_GLANCES': JUPYTERHUB_SERVICE_GLANCES, + 'JUPYTERHUB_SERVICE_TENSORBOARD': JUPYTERHUB_SERVICE_TENSORBOARD, + 'JUPYTERHUB_GPU_ENABLED': JUPYTERHUB_GPU_ENABLED, + 'ENABLE_GPUSTAT': JUPYTERHUB_GPU_ENABLED, 'NVIDIA_DETECTED': NVIDIA_DETECTED, } # configure access to GPU if possible - if ENABLE_GPU_SUPPORT == 1: + if JUPYTERHUB_GPU_ENABLED == 1: c.DockerSpawner.extra_host_config = { 'device_requests': [ { @@ -251,11 +253,11 @@ if c is not None: } # spawn containers from this image - c.DockerSpawner.image = os.environ["DOCKER_NOTEBOOK_IMAGE"] + c.DockerSpawner.image = JUPYTERHUB_NOTEBOOK_IMAGE - # networking congfiguration + # networking configuration c.DockerSpawner.use_internal_ip = True - c.DockerSpawner.network_name = NETWORK_NAME + c.DockerSpawner.network_name = JUPYTERHUB_NETWORK_NAME # prevent auto-spawn for admin users # Redirect admin to admin panel instead @@ -394,7 +396,7 @@ if c is not None: # allow anyone to sign-up without approval # allow all signed-up users to login c.NativeAuthenticator.open_signup = False - c.NativeAuthenticator.enable_signup = bool(ENABLE_SIGNUP) # controlled by ENABLE_SIGNUP env var + c.NativeAuthenticator.enable_signup = bool(JUPYTERHUB_SIGNUP_ENABLED) # controlled by JUPYTERHUB_SIGNUP_ENABLED env var c.Authenticator.allow_all = True # allowed admins @@ -413,7 +415,8 @@ if c is not None: NotificationsPageHandler, ActiveServersHandler, BroadcastNotificationHandler, - GetUserCredentialsHandler + GetUserCredentialsHandler, + SettingsPageHandler ) c.JupyterHub.extra_handlers = [ @@ -423,10 +426,11 @@ if c is not None: (r'/api/notifications/broadcast', BroadcastNotificationHandler), (r'/api/admin/credentials', GetUserCredentialsHandler), (r'/notifications', NotificationsPageHandler), + (r'/settings', SettingsPageHandler), ] # Idle culler service - automatically stops servers after inactivity - if IDLE_CULLER_ENABLED == 1: + if JUPYTERHUB_IDLE_CULLER_ENABLED == 1: import sys # Define role with required scopes for idle culler @@ -447,11 +451,11 @@ if c is not None: culler_cmd = [ sys.executable, "-m", "jupyterhub_idle_culler", - f"--timeout={IDLE_CULLER_TIMEOUT}", - f"--cull-every={IDLE_CULLER_CULL_EVERY}", + f"--timeout={JUPYTERHUB_IDLE_CULLER_TIMEOUT}", + f"--cull-every={JUPYTERHUB_IDLE_CULLER_INTERVAL}", ] - if IDLE_CULLER_MAX_AGE > 0: - culler_cmd.append(f"--max-age={IDLE_CULLER_MAX_AGE}") + if JUPYTERHUB_IDLE_CULLER_MAX_AGE > 0: + culler_cmd.append(f"--max-age={JUPYTERHUB_IDLE_CULLER_MAX_AGE}") c.JupyterHub.services = [ { @@ -460,6 +464,6 @@ if c is not None: } ] - print(f"[Idle Culler] Enabled - timeout={IDLE_CULLER_TIMEOUT}s, check every={IDLE_CULLER_CULL_EVERY}s, max_age={IDLE_CULLER_MAX_AGE}s") + print(f"[Idle Culler] Enabled - timeout={JUPYTERHUB_IDLE_CULLER_TIMEOUT}s, check every={JUPYTERHUB_IDLE_CULLER_INTERVAL}s, max_age={JUPYTERHUB_IDLE_CULLER_MAX_AGE}s") # EOF diff --git a/project.env b/project.env index 9846073..ec1100c 100644 --- a/project.env +++ b/project.env @@ -3,8 +3,8 @@ PROJECT_NAME="stellars-jupyterhub-ds" PROJECT_DESCRIPTION="Multi-user JupyterHub 4 deployment platform with data science stack, GPU auto-detection, NativeAuthenticator, and isolated per-user environments spawned via DockerSpawner" # Version -VERSION="3.6.0_cuda-12.9.1_jh-5.4.2" -VERSION_COMMENT="Idle server culler, selective notifications, volume encoding fix" +VERSION="3.6.3_cuda-13.0.2_jh-5.4.2" +VERSION_COMMENT="Standardize env vars with JUPYTERHUB_ prefix, admin settings page" RELEASE_TAG="RELEASE_3.2.11" RELEASE_DATE="2025-11-09" diff --git a/services/jupyterhub/conf/bin/custom_handlers.py b/services/jupyterhub/conf/bin/custom_handlers.py index bc29ab4..f5de323 100755 --- a/services/jupyterhub/conf/bin/custom_handlers.py +++ b/services/jupyterhub/conf/bin/custom_handlers.py @@ -10,6 +10,7 @@ import docker import json import asyncio import time +import os # ============================================================================= @@ -560,3 +561,58 @@ class GetUserCredentialsHandler(BaseHandler): self.log.info(f"[Get Credentials] Returning {len(credentials)} credential(s)") self.finish({"credentials": credentials}) + + +class SettingsPageHandler(BaseHandler): + """Handler for rendering the settings page (admin only, read-only)""" + + @web.authenticated + async def get(self): + """ + Render the settings page showing key environment variables + + GET /settings + """ + current_user = self.current_user + + # Only admins can access settings + if not current_user.admin: + raise web.HTTPError(403, "Only administrators can access this page") + + self.log.info(f"[Settings Page] Admin {current_user.name} accessed settings panel") + + # Collect environment variables and settings + settings = [] + + # JupyterHub Core Settings + settings.append({"category": "JupyterHub Core", "name": "JUPYTERHUB_ADMIN", "value": os.environ.get("JUPYTERHUB_ADMIN", "admin"), "description": "Admin username"}) + settings.append({"category": "JupyterHub Core", "name": "JUPYTERHUB_BASE_URL", "value": os.environ.get("JUPYTERHUB_BASE_URL", "/jupyterhub"), "description": "Base URL path"}) + settings.append({"category": "JupyterHub Core", "name": "JUPYTERHUB_SIGNUP_ENABLED", "value": os.environ.get("JUPYTERHUB_SIGNUP_ENABLED", "1"), "description": "User self-registration (0=disabled, 1=enabled)"}) + settings.append({"category": "JupyterHub Core", "name": "JUPYTERHUB_SSL_ENABLED", "value": os.environ.get("JUPYTERHUB_SSL_ENABLED", "1"), "description": "SSL/TLS (0=disabled, 1=enabled)"}) + + # Docker/Spawner Settings + settings.append({"category": "Docker Spawner", "name": "JUPYTERHUB_NOTEBOOK_IMAGE", "value": os.environ.get("JUPYTERHUB_NOTEBOOK_IMAGE", "stellars/stellars-jupyterlab-ds:latest"), "description": "User container image"}) + settings.append({"category": "Docker Spawner", "name": "JUPYTERHUB_NETWORK_NAME", "value": os.environ.get("JUPYTERHUB_NETWORK_NAME", "jupyterhub_network"), "description": "Docker network for containers"}) + + # GPU Settings + settings.append({"category": "GPU", "name": "JUPYTERHUB_GPU_ENABLED", "value": os.environ.get("JUPYTERHUB_GPU_ENABLED", "2"), "description": "GPU support (0=disabled, 1=enabled, 2=auto-detect)"}) + settings.append({"category": "GPU", "name": "JUPYTERHUB_NVIDIA_IMAGE", "value": os.environ.get("JUPYTERHUB_NVIDIA_IMAGE", "nvidia/cuda:12.9.1-base-ubuntu24.04"), "description": "CUDA image for GPU detection"}) + + # Services + settings.append({"category": "Services", "name": "JUPYTERHUB_SERVICE_MLFLOW", "value": os.environ.get("JUPYTERHUB_SERVICE_MLFLOW", "1"), "description": "MLflow service (0=disabled, 1=enabled)"}) + settings.append({"category": "Services", "name": "JUPYTERHUB_SERVICE_GLANCES", "value": os.environ.get("JUPYTERHUB_SERVICE_GLANCES", "1"), "description": "Glances service (0=disabled, 1=enabled)"}) + settings.append({"category": "Services", "name": "JUPYTERHUB_SERVICE_TENSORBOARD", "value": os.environ.get("JUPYTERHUB_SERVICE_TENSORBOARD", "1"), "description": "TensorBoard service (0=disabled, 1=enabled)"}) + + # Idle Culler + settings.append({"category": "Idle Culler", "name": "JUPYTERHUB_IDLE_CULLER_ENABLED", "value": os.environ.get("JUPYTERHUB_IDLE_CULLER_ENABLED", "0"), "description": "Idle culler (0=disabled, 1=enabled)"}) + settings.append({"category": "Idle Culler", "name": "JUPYTERHUB_IDLE_CULLER_TIMEOUT", "value": os.environ.get("JUPYTERHUB_IDLE_CULLER_TIMEOUT", "86400"), "description": "Idle timeout in seconds"}) + settings.append({"category": "Idle Culler", "name": "JUPYTERHUB_IDLE_CULLER_INTERVAL", "value": os.environ.get("JUPYTERHUB_IDLE_CULLER_INTERVAL", "600"), "description": "Check interval in seconds"}) + settings.append({"category": "Idle Culler", "name": "JUPYTERHUB_IDLE_CULLER_MAX_AGE", "value": os.environ.get("JUPYTERHUB_IDLE_CULLER_MAX_AGE", "0"), "description": "Max server age (0=unlimited)"}) + + # Branding + settings.append({"category": "Branding", "name": "JUPYTERHUB_CUSTOM_LOGO_URI", "value": os.environ.get("JUPYTERHUB_CUSTOM_LOGO_URI", "") or "(default)", "description": "Custom logo URI"}) + settings.append({"category": "Branding", "name": "STELLARS_JUPYTERHUB_VERSION", "value": os.environ.get("STELLARS_JUPYTERHUB_VERSION", "unknown"), "description": "Platform version"}) + + # Render the template + html = self.render_template("settings.html", sync=True, user=current_user, settings=settings) + self.finish(html) diff --git a/services/jupyterhub/templates/404.html b/services/jupyterhub/templates/404.html new file mode 100644 index 0000000..04c7494 --- /dev/null +++ b/services/jupyterhub/templates/404.html @@ -0,0 +1,4 @@ +{% extends "error.html" %} +{% block error_detail %} +

Jupyter has lots of moons, but this is not one...

+{% endblock error_detail %} diff --git a/services/jupyterhub/templates/accept-share.html b/services/jupyterhub/templates/accept-share.html new file mode 100644 index 0000000..450795e --- /dev/null +++ b/services/jupyterhub/templates/accept-share.html @@ -0,0 +1,52 @@ +{% extends "page.html" %} +{% block login_widget %} +{% endblock login_widget %} +{% block main %} +
+
+
+

Accept sharing invitation

+

+ You ({{ user.name }}) have been invited to access {{ owner.name }}'s server + {%- if spawner.name %}({{ spawner.name }}){%- endif %} at {{ spawner_url }} +

+ {% if not spawner_ready %} +

+ The server at {{ spawner_url }} is not currently running. + After accepting permission, you may need to ask {{ owner.name }} + to start the server before you can access it. +

+ {% endif %} +
+
+
+ By accepting the invitation, you will be granted the following permissions, + restricted to this particular server: +
+
+ {# these are the 'real' inputs to the form -#} + + + {% for scope_info in scope_descriptions -%} +
+ +
+ {% endfor -%} +
+ +
+
+
+
+
+{% endblock main %} diff --git a/services/jupyterhub/templates/admin.html b/services/jupyterhub/templates/admin.html new file mode 100644 index 0000000..2b6b9af --- /dev/null +++ b/services/jupyterhub/templates/admin.html @@ -0,0 +1,315 @@ +{% extends "page.html" %} +{% block main %} +
+ + +
+ + + + + + +{% endblock main %} + +{% block footer %} + +{% endblock footer %} + +{% block script %} +{{ super() }} + +{% endblock script %} diff --git a/services/jupyterhub/templates/authorization-area.html b/services/jupyterhub/templates/authorization-area.html new file mode 100644 index 0000000..ab49741 --- /dev/null +++ b/services/jupyterhub/templates/authorization-area.html @@ -0,0 +1,49 @@ +{% extends "page.html" %} + +{% block main %} +
+

Authorization area

+ + + + + + {% if ask_email %}{% endif %} + + + + + + + {% for user in users %} + {% if user.is_authorized %} + + + {% if ask_email %}{% endif %} + + + + + {% else %} + + + {% if ask_email %}{% endif %} + + + + + {% endif %} + {% endfor %} + +
UsernameEmail2FAAuthorizedActions
{{ user.username }}{{ user.email }}{{ user.has_2fa }}Yes + Unauthorize + Change password +
{{ user.username }}{{ user.email }}{{ user.has_2fa }}No + Authorize + Change password + {% if user.username not in hub_usernames %} + Discard + {% endif %} +
+
+{% endblock %} diff --git a/services/jupyterhub/templates/change-password-admin.html b/services/jupyterhub/templates/change-password-admin.html new file mode 100644 index 0000000..e7bfbfd --- /dev/null +++ b/services/jupyterhub/templates/change-password-admin.html @@ -0,0 +1,58 @@ +{% extends "page.html" %} + +{% block script %} +{{ super() }} + +{% endblock script %} + +{% block main %} +
+
+

+ Change password for {{user_name}} +

+ +

Please enter the new password you want to set for {{user_name}}.

+ +
+ +
+ + + + +
+

+ + +
+ +
+

+ + +
+
+ + {% if result_message %} + + {% endif %} +
+{% endblock %} diff --git a/services/jupyterhub/templates/change-password.html b/services/jupyterhub/templates/change-password.html new file mode 100644 index 0000000..69b453e --- /dev/null +++ b/services/jupyterhub/templates/change-password.html @@ -0,0 +1,68 @@ +{% extends "page.html" %} + +{% block script %} +{{ super() }} + +{% endblock script %} + +{% block main %} +
+
+

+ Change password for {{user_name}} +

+ +

Please enter your current password and the new password you want to set it to. If you have forgotten your password, an admin can reset it for you.

+ +
+ +
+ + + + +
+

+ + +
+ +
+

+ + +
+ + +
+

+ + +
+
+ + {% if result_message %} + + {% endif %} +
+{% endblock %} diff --git a/services/jupyterhub/templates/error.html b/services/jupyterhub/templates/error.html new file mode 100644 index 0000000..41a2bfe --- /dev/null +++ b/services/jupyterhub/templates/error.html @@ -0,0 +1,47 @@ +{% extends "page.html" %} +{% block login_widget %} +{% endblock login_widget %} +{% block main %} +
+ {% block h1_error %} +

{{ status_code }} : {{ status_message }}

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

{{ message_html | safe }}

+ {% elif message %} +

{{ message }}

+ {% endif %} + {% if extra_error_html %}

{{ extra_error_html | safe }}

{% endif %} + {% endblock error_detail %} +
+{% endblock main %} +{% block script %} + {{ super() }} + +{% endblock script %} diff --git a/services/jupyterhub/templates/home.html b/services/jupyterhub/templates/home.html index d0486fb..d7d73af 100644 --- a/services/jupyterhub/templates/home.html +++ b/services/jupyterhub/templates/home.html @@ -45,16 +45,6 @@ - {# Admin-only: Notifications link #} - {% if user.admin %} -
-
- - Broadcast Notifications to Active Servers - -
-
- {% endif %} {% if allow_named_servers %}

Named Servers

@@ -208,9 +198,7 @@ +{% endblock script %} diff --git a/services/jupyterhub/templates/logout.html b/services/jupyterhub/templates/logout.html new file mode 100644 index 0000000..994e776 --- /dev/null +++ b/services/jupyterhub/templates/logout.html @@ -0,0 +1,9 @@ +{% extends "page.html" %} +{% if announcement_logout is string %} + {% set announcement = announcement_logout %} +{% endif %} +{% block main %} +
+

Successfully logged out.

+
+{% endblock main %} diff --git a/services/jupyterhub/templates/my_message.html b/services/jupyterhub/templates/my_message.html new file mode 100644 index 0000000..ae0195f --- /dev/null +++ b/services/jupyterhub/templates/my_message.html @@ -0,0 +1,11 @@ +{% extends "page.html" %} + +{% block main %} +
+

{{message}}

+
+ +
+ If you are authorized, you can try to login. +
+{% endblock main %} diff --git a/services/jupyterhub/templates/native-login.html b/services/jupyterhub/templates/native-login.html new file mode 100644 index 0000000..865de0a --- /dev/null +++ b/services/jupyterhub/templates/native-login.html @@ -0,0 +1,84 @@ +{% extends "page.html" %} + +{% if announcement_login %} + {% set announcement = announcement_login %} +{% endif %} + +{% block script %} +{{ super() }} + +{% endblock script %} + +{% block main %} +{% block login %} +
+
+
+ Sign In +
+ +
+ + + {% if login_error %} + + {% endif %} + + + +

+ + +
+ + + + +
+

+ + {% if two_factor_auth %} + + +

+ {% endif %} + + +

+ + {% if enable_signup %} +
+

+ Sign up to create a new user. +

+ {% endif %} +
+
+
+{% endblock login %} +{% endblock main %} diff --git a/services/jupyterhub/templates/not_running.html b/services/jupyterhub/templates/not_running.html new file mode 100644 index 0000000..489de4a --- /dev/null +++ b/services/jupyterhub/templates/not_running.html @@ -0,0 +1,75 @@ +{% extends "page.html" %} +{% block main %} +
+
+
+ {% block heading %} +

+ {% if failed %} + Spawn failed + {% else %} + Server not running + {% endif %} +

+ {% endblock heading %} + {% block message %} +

+ {% if failed %} + The latest attempt to start your server {{ server_name }} has failed. + {% if failed_html_message %} +

+

{{ failed_html_message | safe }}

+

{% elif failed_message %}

+

{{ failed_message }}

+

+ {% endif %} + Would you like to retry starting it? + {% else %} + Your server {{ server_name }} is not running. + {% if implicit_spawn_seconds %} + It will be restarted automatically. + If you are not redirected in a few seconds, + click below to launch your server. + {% else %} + Would you like to start it? + {% endif %} + {% endif %} +

+ {% endblock message %} + {% block start_button %} + + {% if failed %} + Relaunch + {% else %} + Launch + {% endif %} + Server {{ server_name }} + + {% endblock start_button %} +
+
+
+{% endblock main %} +{% block script %} + {{ super () }} + {% if implicit_spawn_seconds %} + {# djlint:off #} + + {# djlint:on #} + {% endif %} + +{% endblock script %} diff --git a/services/jupyterhub/templates/oauth.html b/services/jupyterhub/templates/oauth.html new file mode 100644 index 0000000..edb11bc --- /dev/null +++ b/services/jupyterhub/templates/oauth.html @@ -0,0 +1,52 @@ +{% extends "page.html" %} +{% block login_widget %} +{% endblock login_widget %} +{% block main %} +
+
+
+

Authorize access

+

An application is requesting authorization to access data associated with your JupyterHub account

+

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

+
+
+
+

This will grant the application permission to:

+
+
+ + {# these are the 'real' inputs to the form -#} + {% for scope in allowed_scopes %}{% endfor %} + {% for scope_info in scope_descriptions %} +
+ +
+ {% endfor %} +
+ +
+
+
+
+
+{% endblock main %} diff --git a/services/jupyterhub/templates/page.html b/services/jupyterhub/templates/page.html index ac7d14f..d547821 100644 --- a/services/jupyterhub/templates/page.html +++ b/services/jupyterhub/templates/page.html @@ -159,6 +159,9 @@ + {% endif %} + {% endif %}