feat: standardize env vars with JUPYTERHUB_ prefix, add admin settings page

- Rename 13 environment variables to use JUPYTERHUB_ prefix:
  - 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_*
- Add SettingsPageHandler with admin-only access at /settings
- Add Settings link to admin navbar
- Sync templates from templates_enhanced to templates
- Update README.md and CLAUDE.md documentation
- No backward compatibility for old variable names
This commit is contained in:
stellarshenson
2026-01-14 16:39:34 +01:00
parent a40bb19f5b
commit 022e970dbf
31 changed files with 1700 additions and 112 deletions

View File

@@ -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

View File

@@ -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<br>
**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<br>
**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

View File

@@ -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<br/>Admin username]
BASEURL[JUPYTERHUB_BASE_URL<br/>URL prefix]
IMG[DOCKER_NOTEBOOK_IMAGE<br/>User container image]
NET[DOCKER_NETWORK_NAME<br/>Container network]
SSL[ENABLE_JUPYTERHUB_SSL<br/>0=off, 1=on]
GPU[ENABLE_GPU_SUPPORT<br/>0=off, 1=on, 2=auto]
SIGNUP[ENABLE_SIGNUP<br/>0=admin-only, 1=self-register]
IMG[JUPYTERHUB_NOTEBOOK_IMAGE<br/>User container image]
NET[JUPYTERHUB_NETWORK_NAME<br/>Container network]
SSL[JUPYTERHUB_SSL_ENABLED<br/>0=off, 1=on]
GPU[JUPYTERHUB_GPU_ENABLED<br/>0=off, 1=on, 2=auto]
SIGNUP[JUPYTERHUB_SIGNUP_ENABLED<br/>0=admin-only, 1=self-register]
TFLOG[TF_CPP_MIN_LOG_LEVEL<br/>TensorFlow verbosity]
NVIMG[NVIDIA_AUTODETECT_IMAGE<br/>CUDA test image]
NVIMG[JUPYTERHUB_NVIDIA_IMAGE<br/>CUDA test image]
subgraph SVCEN["ENABLE_SERVICE_*<br/>Passed to Lab as env"]
subgraph SVCEN["JUPYTERHUB_SERVICE_*<br/>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<br/>with runtime=nvidia]
RUN --> SUCCESS{Success?}
SUCCESS -->|Yes| SET_ON[Set ENABLE_GPU_SUPPORT=1<br/>Set NVIDIA_DETECTED=1]
SUCCESS -->|No| SET_OFF[Set ENABLE_GPU_SUPPORT=0<br/>Set NVIDIA_DETECTED=0]
SUCCESS -->|Yes| SET_ON[Set JUPYTERHUB_GPU_ENABLED=1<br/>Set NVIDIA_DETECTED=1]
SUCCESS -->|No| SET_OFF[Set JUPYTERHUB_GPU_ENABLED=0<br/>Set NVIDIA_DETECTED=0]
SET_ON --> CLEANUP1[Remove test container<br/>jupyterhub_nvidia_autodetect]
SET_OFF --> CLEANUP2[Remove test container<br/>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

View File

@@ -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"

View File

@@ -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

View File

@@ -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"

View File

@@ -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)

View File

@@ -0,0 +1,4 @@
{% extends "error.html" %}
{% block error_detail %}
<p>Jupyter has lots of moons, but this is not one...</p>
{% endblock error_detail %}

View File

@@ -0,0 +1,52 @@
{% extends "page.html" %}
{% block login_widget %}
{% endblock login_widget %}
{% block main %}
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6">
<h1 class="text-center">Accept sharing invitation</h1>
<p class="lead">
You ({{ user.name }}) have been invited to access {{ owner.name }}'s server
{%- if spawner.name %}({{ spawner.name }}){%- endif %} at <a href="{{ spawner_url | safe }}">{{ spawner_url }}</a>
</p>
{% if not spawner_ready %}
<p class="alert alert-danger">
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.
</p>
{% endif %}
<form method="post" action="">
<div class="card">
<div class="card-header">
By accepting the invitation, you will be granted the following permissions,
restricted to this particular server:
</div>
<div class="card-body">
{# these are the 'real' inputs to the form -#}
<input type="hidden" name="_xsrf" value="{{ xsrf }}" />
<input type="hidden" name="code" value="{{ code }}" />
{% for scope_info in scope_descriptions -%}
<div class="form-check input-group">
<label>
<span>
{{ scope_info['description'] }}
{% if scope_info['filter'] %}Applies to {{ scope_info['filter'] }}.{% endif %}
</span>
</label>
</div>
{% endfor -%}
</div>
<div class="card-footer">
<button type="submit" class="form-control btn btn-jupyter">Accept invitation</button>
<p class="small">
After accepting the invitation, you will be redirected to <a href="{{ next_url | safe }}">{{ next_url }}</a>.
</p>
</div>
</div>
</form>
</div>
</div>
</div>
{% endblock main %}

View File

@@ -0,0 +1,315 @@
{% extends "page.html" %}
{% block main %}
<div id="react-admin-hook">
<script id="jupyterhub-admin-config">
window.api_page_limit = parseInt("{{ api_page_limit|safe }}")
window.base_url = "{{ base_url|safe }}"
</script>
<script src={{ static_url("js/admin-react.js") }}></script>
</div>
<!-- User Credentials Modal -->
<div class="modal fade" id="user-credentials-modal" tabindex="-1" role="dialog" aria-labelledby="userCredentialsModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="userCredentialsModalLabel">
<i class="fa fa-user-plus me-2"></i>New Users Created
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-success">
<strong>Success:</strong> Users created and authorized.
Share the credentials below with each user.
</div>
<div class="mb-3">
<button type="button" class="btn btn-outline-secondary me-2" id="copy-credentials-btn">
<i class="fa fa-copy me-1"></i>Copy to Clipboard
</button>
<button type="button" class="btn btn-primary" id="download-credentials-btn">
<i class="fa fa-download me-1"></i>Download as TXT
</button>
</div>
<div style="max-height: 300px; overflow-y: auto;">
<table class="table table-striped" id="credentials-table">
<thead>
<tr>
<th>Username</th>
<th>Password</th>
<th style="width: 40px;"></th>
</tr>
</thead>
<tbody id="credentials-body">
<!-- Populated dynamically -->
</tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Loading Spinner Modal -->
<div class="modal fade" id="loading-modal" tabindex="-1" role="dialog" data-bs-backdrop="static" data-bs-keyboard="false" aria-hidden="true">
<div class="modal-dialog modal-sm modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-body text-center py-4">
<div class="spinner-border text-primary mb-3" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<div>Generating credentials...</div>
</div>
</div>
</div>
</div>
{% endblock main %}
{% block footer %}
<div class="py-2 px-4 bg-body-tertiary small version_footer">JupyterHub {{ server_version }}</div>
{% endblock footer %}
{% block script %}
{{ super() }}
<script type="text/javascript">
(function() {
const baseUrl = "{{ base_url|safe }}";
// Store for tracking newly created users
let pendingUsernames = [];
// Helper to get CSRF token
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
// Loading spinner functions
let loadingModal = null;
function showLoadingSpinner() {
const modalEl = document.getElementById('loading-modal');
if (!loadingModal) {
loadingModal = new bootstrap.Modal(modalEl);
}
loadingModal.show();
}
function hideLoadingSpinner() {
if (loadingModal) {
loadingModal.hide();
}
}
// Intercept fetch to detect user creation
const originalFetch = window.fetch;
window.fetch = async function(...args) {
const [urlArg, options] = args;
// Convert URL object to string if needed
const url = typeof urlArg === 'string' ? urlArg : urlArg.toString();
const method = options?.method || 'GET';
// console.log('[Admin Fetch]', method, url);
// Check for user creation - POST to api/users (may have ?_xsrf query param)
// Exclude URLs with other query params like include_stopped_servers
const urlPath = url.split('?')[0];
const isUserCreation = method === 'POST' && urlPath.endsWith('api/users');
// Capture request body before fetch (for batch user creation)
let requestUsernames = [];
if (isUserCreation) {
// console.log('[Admin] User creation POST detected!');
try {
if (options.body) {
const bodyData = JSON.parse(options.body);
if (bodyData.usernames && Array.isArray(bodyData.usernames)) {
requestUsernames = bodyData.usernames;
// console.log('[Admin] Batch user creation request:', requestUsernames);
}
}
} catch (e) {
// Body might not be JSON, ignore
}
}
const response = await originalFetch.apply(this, args);
// console.log('[Admin Fetch] Response status:', response.status);
// Check if this is a POST to create users
if (isUserCreation) {
try {
// Only process successful responses
if (response.ok || response.status === 201) {
// Clone response to read body
const clonedResponse = response.clone();
const responseData = await clonedResponse.json();
// Extract created usernames from response
let createdUsers = [];
if (Array.isArray(responseData)) {
// Batch response: array of user objects
createdUsers = responseData.map(u => u.name);
} else if (responseData.name) {
// Single user response
createdUsers = [responseData.name];
}
if (createdUsers.length > 0) {
// console.log('[Admin] Users created:', createdUsers);
pendingUsernames.push(...createdUsers);
// Show loading spinner
showLoadingSpinner();
// Debounce - wait for batch completion then fetch credentials
clearTimeout(window._credentialsFetchTimeout);
window._credentialsFetchTimeout = setTimeout(() => {
if (pendingUsernames.length > 0) {
fetchAndShowCredentials([...pendingUsernames]);
pendingUsernames = [];
}
}, 1000);
}
}
} catch (e) {
// console.error('[Admin] Error processing user creation:', e);
}
}
return response;
};
// Fetch credentials for created users and show modal
async function fetchAndShowCredentials(usernames) {
// console.log('[Admin] Fetching credentials for:', usernames);
try {
const response = await originalFetch(`${baseUrl}api/admin/credentials`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-XSRFToken': getCookie('_xsrf')
},
body: JSON.stringify({ usernames: usernames })
});
if (response.ok) {
const data = await response.json();
// console.log('[Admin] Credentials received:', data);
if (data.credentials && data.credentials.length > 0) {
showCredentialsModal(data.credentials);
}
} else {
// console.error('[Admin] Failed to fetch credentials:', response.status);
hideLoadingSpinner();
}
} catch (e) {
// console.error('[Admin] Error fetching credentials:', e);
hideLoadingSpinner();
}
}
// Show modal with credentials
function showCredentialsModal(credentials) {
hideLoadingSpinner();
const tbody = document.getElementById('credentials-body');
tbody.innerHTML = '';
credentials.forEach(cred => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${escapeHtml(cred.username)}</td>
<td>${escapeHtml(cred.password)}</td>
<td><i class="fa fa-copy copy-row-btn" style="opacity: 0.4; cursor: pointer;" title="Copy to clipboard"></i></td>
`;
// Add click handler for row copy
row.querySelector('.copy-row-btn').addEventListener('click', function() {
const text = 'Username: ' + cred.username + '\nPassword: ' + cred.password;
navigator.clipboard.writeText(text).then(() => {
this.classList.remove('fa-copy');
this.classList.add('fa-check');
this.style.opacity = '1';
setTimeout(() => {
this.classList.remove('fa-check');
this.classList.add('fa-copy');
this.style.opacity = '0.4';
}, 1500);
});
});
tbody.appendChild(row);
});
// Store credentials for download/copy
window._currentCredentials = credentials;
// Show modal
const modalEl = document.getElementById('user-credentials-modal');
const modal = new bootstrap.Modal(modalEl);
modal.show();
}
// HTML escape helper
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Format credentials for text output
function formatCredentialsText(credentials) {
let text = 'JupyterHub User Credentials\n';
text += '===========================\n\n';
credentials.forEach(cred => {
text += `Username: ${cred.username}\n`;
text += `Password: ${cred.password}\n\n`;
});
text += `Generated: ${new Date().toISOString()}\n`;
return text;
}
// Copy to clipboard handler
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('copy-credentials-btn')?.addEventListener('click', function() {
if (window._currentCredentials) {
const text = formatCredentialsText(window._currentCredentials);
navigator.clipboard.writeText(text).then(() => {
const btn = this;
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="fa fa-check me-1"></i>Copied!';
btn.classList.remove('btn-outline-secondary');
btn.classList.add('btn-success');
setTimeout(() => {
btn.innerHTML = originalText;
btn.classList.remove('btn-success');
btn.classList.add('btn-outline-secondary');
}, 2000);
}).catch(err => {
// console.error('[Admin] Failed to copy:', err);
alert('Failed to copy to clipboard');
});
}
});
// Download as TXT handler
document.getElementById('download-credentials-btn')?.addEventListener('click', function() {
if (window._currentCredentials) {
const text = formatCredentialsText(window._currentCredentials);
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `jupyterhub-credentials-${new Date().toISOString().slice(0,10)}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
});
});
})();
</script>
{% endblock script %}

View File

@@ -0,0 +1,49 @@
{% extends "page.html" %}
{% block main %}
<div class="container authorization-container">
<h1>Authorization area</h1>
<table class="table table-sm">
<thead>
<tr>
<th>Username</th>
{% if ask_email %}<th>Email</th>{% endif %}
<th>2FA</th>
<th>Authorized</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
{% if user.is_authorized %}
<tr class="success" id="{{ user.username }}">
<td>{{ user.username }}</td>
{% if ask_email %}<td>{{ user.email }}</td>{% endif %}
<td>{{ user.has_2fa }}</td>
<td>Yes</td>
<td class="actions-cell">
<a class="btn btn-default btn-sm" href="{{ base_url }}authorize/{{ user.username }}" role="button">Unauthorize</a>
<a class="btn btn-jupyter btn-sm" href="{{ base_url }}change-password/{{ user.username }}" role="button">Change password</a>
</td>
</tr>
{% else %}
<tr id="{{ user.username }}">
<td>{{ user.username }}</td>
{% if ask_email %}<td>{{ user.email }}</td>{% endif %}
<td>{{ user.has_2fa }}</td>
<td>No</td>
<td class="actions-cell">
<a class="btn btn-jupyter btn-sm" href="{{ base_url }}authorize/{{ user.username }}" role="button">Authorize</a>
<a class="btn btn-jupyter btn-sm" href="{{ base_url }}change-password/{{ user.username }}" role="button">Change password</a>
{% if user.username not in hub_usernames %}
<a class="btn btn-danger btn-sm" href="{{ base_url }}discard/{{ user.username }}" role="button">Discard</a>
{% endif %}
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,58 @@
{% extends "page.html" %}
{% block script %}
{{ super() }}
<script type="text/javascript">
document.addEventListener('DOMContentLoaded', function() {
let button = document.getElementById('eye');
button.addEventListener("click", function(e) {
let npwd = document.getElementById("new_password_input");
let cpwd = document.getElementById("new_password_confirmation_input");
if (npwd.getAttribute("type") === "password") {
npwd.setAttribute("type", "text");
cpwd.setAttribute("type", "text");
button.innerHTML = '<i class="fa fa-eye-slash"></i>';
} else {
npwd.setAttribute("type", "password");
cpwd.setAttribute("type", "password");
button.innerHTML = '<i class="fa fa-eye"></i>';
}
});
});
</script>
{% endblock script %}
{% block main %}
<div class="container">
<form action="{{post_url}}" method="post" role="form">
<h1>
Change password for {{user_name}}
</h1>
<p>Please enter the new password you want to set for {{user_name}}.</p>
<input type="hidden" name="_xsrf" value="{{ xsrf }}"/>
<div class="form-group">
<label for="new_password_input">New password:</label>
<div class="input-group col-xs-6">
<input id="new_password_input" type="password" name="new_password" val="" autocapitalize="off" autocorrect="off" class="form-control" />
<span class="input-group-addon">
<button id="eye" type="button" style="border:0;background:transparent;"><i class="fa fa-eye"></i></button>
</span>
</div>
<p></p>
<label for="new_password_confirmation_input">Confirm new password:</label>
<div class="input-group col-xs-6">
<input id="new_password_confirmation_input" type="password" name="new_password_confirmation" val="" autocapitalize="off" autocorrect="off" class="form-control" />
</div>
<p></p>
<input id="signup_submit" type="submit" value='Change Password' class='btn btn-jupyter' />
</div>
</form>
{% if result_message %}
<div class="alert {{alert}} col-xs-6" role="alert">{{result_message}}</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,68 @@
{% extends "page.html" %}
{% block script %}
{{ super() }}
<script type="text/javascript">
document.addEventListener('DOMContentLoaded', function() {
let button = document.getElementById('eye');
button.addEventListener("click", function(e) {
let opwd = document.getElementById("old_password_input");
let npwd = document.getElementById("new_password_input");
let cpwd = document.getElementById("new_password_confirmation_input");
if (opwd.getAttribute("type") === "password") {
opwd.setAttribute("type", "text");
npwd.setAttribute("type", "text");
cpwd.setAttribute("type", "text");
button.innerHTML = '<i class="fa fa-eye-slash"></i>';
} else {
opwd.setAttribute("type", "password");
npwd.setAttribute("type", "password");
cpwd.setAttribute("type", "password");
button.innerHTML = '<i class="fa fa-eye"></i>';
}
});
});
</script>
{% endblock script %}
{% block main %}
<div class="container">
<form action="{{post_url}}" method="post" role="form">
<h1>
Change password for {{user_name}}
</h1>
<p>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.</p>
<input type="hidden" name="_xsrf" value="{{ xsrf }}"/>
<div class="form-group">
<label for="old_password_input">Old password:</label>
<div class="input-group col-xs-6">
<input id="old_password_input" type="password" name="old_password" val="" autocapitalize="off" autocorrect="off" class="form-control" />
<span class="input-group-addon">
<button id="eye" type="button" style="border:0;background:transparent;"><i class="fa fa-eye"></i></button>
</span>
</div>
<p></p>
<label for="new_password_input">New password:</label>
<div class="input-group col-xs-6">
<input id="new_password_input" type="password" name="new_password" val="" autocapitalize="off" autocorrect="off" class="form-control" />
</div>
<p></p>
<label for="new_password_confirmation_input">Confirm new password:</label>
<div class="input-group col-xs-6">
<input id="new_password_confirmation_input" type="password" name="new_password_confirmation" val="" autocapitalize="off" autocorrect="off" class="form-control" />
<span></span>
</div>
<p></p>
<input id="signup_submit" type="submit" value='Change Password' class='btn btn-jupyter' />
</div>
</form>
{% if result_message %}
<div class="alert {{alert}} col-xs-6" role="alert">{{result_message}}</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,47 @@
{% extends "page.html" %}
{% block login_widget %}
{% endblock login_widget %}
{% block main %}
<div class="error">
{% block h1_error %}
<h1>{{ status_code }} : {{ status_message }}</h1>
{% endblock h1_error %}
{% block error_detail %}
{% if message_html %}
<p>{{ message_html | safe }}</p>
{% elif message %}
<p>{{ message }}</p>
{% endif %}
{% if extra_error_html %}<p>{{ extra_error_html | safe }}</p>{% endif %}
{% endblock error_detail %}
</div>
{% endblock main %}
{% block script %}
{{ super() }}
<script type="text/javascript">
function _remove_redirects_from_url() {
if (window.location.search.length <= 1) {
return;
}
var search_parameters = window.location.search.slice(1).split('&');
for (var i = 0; i < search_parameters.length; i++) {
if (search_parameters[i].split('=')[0] === 'redirects') {
// remote redirects from search parameters
search_parameters.splice(i, 1);
var new_search = '';
if (search_parameters.length) {
new_search = '?' + search_parameters.join('&');
}
var new_url = window.location.origin +
window.location.pathname +
new_search +
window.location.hash;
window.history.replaceState({}, "", new_url);
return;
}
}
}
_remove_redirects_from_url();
</script>
{% endblock script %}

View File

@@ -45,16 +45,6 @@
</div>
</div>
{# Admin-only: Notifications link #}
{% if user.admin %}
<div class="row mt-3">
<div class="text-center">
<a href="{{ base_url }}notifications" class="text-decoration-none">
Broadcast Notifications to Active Servers
</a>
</div>
</div>
{% endif %}
{% if allow_named_servers %}
<h2>Named Servers</h2>
@@ -208,9 +198,7 @@
<script type="text/javascript">
require(["jquery", "home"], function($) {
// Version banner in browser console
console.log('%c Stellars JupyterHub DS %c {{ stellars_version }} ',
'background: #0d6efd; color: white; font-weight: bold; padding: 4px 8px;',
'background: #198754; color: white; font-weight: bold; padding: 4px 8px;');
console.log('[Stellars JupyterHub DS] Version:', '{{ stellars_version }}');
$(document).ready(function() {
const username = "{{ user.name }}";

View File

@@ -0,0 +1,120 @@
{% extends "page.html" %}
{% if announcement_login is string %}
{% set announcement = announcement_login %}
{% endif %}
{% block login_widget %}
{% endblock login_widget %}
{% block main %}
{% block login %}
<div id="login-main" class="container">
{% block login_container %}
{% if custom_html %}
{{ custom_html | safe }}
{% elif login_service %}
<div class="service-login">
<p id='insecure-login-warning' class='hidden alert alert-warning'>
Warning: JupyterHub seems to be served over an unsecured HTTP connection.
We strongly recommend enabling HTTPS for JupyterHub.
</p>
<a role="button"
class='btn btn-jupyter btn-lg'
href='{{ authenticator_login_url | safe }}'>Sign in with {{ login_service }}</a>
</div>
{% else %}
<form action="{{ authenticator_login_url | safe }}"
method="post"
role="form">
<div class="auth-form-header">
<h1>Sign in</h1>
</div>
<div class='auth-form-body m-auto'>
<p id='insecure-login-warning' class='hidden alert alert-warning'>
Warning: JupyterHub seems to be served over an unsecured HTTP connection.
We strongly recommend enabling HTTPS for JupyterHub.
</p>
{% if login_error %}<p class="login_error">{{ login_error }}</p>{% endif %}
<input type="hidden" name="_xsrf" value="{{ xsrf }}" />
{# Allow full override of the "label" and "input" elements of the username and password fields. #}
{% block username_input %}
<label for="username_input">Username:</label>
<input id="username_input"
{% block username_input_attrs %}
type="text"
autocapitalize="off"
autocorrect="off"
autocomplete="username"
class="form-control"
name="username"
value="{{ username }}"
autofocus="autofocus"
{% endblock username_input_attrs %} />
{% endblock username_input %}
{% block password_input %}
<label for='password_input'>Password:</label>
<input id="password_input"
{% block password_input_attrs %}
type="password"
class="form-control"
autocomplete="current-password"
name="password"
{% endblock password_input_attrs %} />
{% endblock password_input %}
{% if authenticator.request_otp %}
{% block otp_input %}
<label for="otp_input">{{ authenticator.otp_prompt }}</label>
<input id="otp_input"
{% block otp_input_attrs %}
class="form-control"
autocomplete="one-time-password"
name="otp"
{% endblock otp_input_attrs %} />
{% endblock otp_input %}
{% endif %}
<div class="feedback-container">
<input id="login_submit"
type="submit"
class='btn btn-jupyter form-control'
value='Sign in'
tabindex="3" />
<div class="feedback-widget hidden">
<i class="fa fa-spinner"></i>
</div>
</div>
{% block login_terms %}
{% if login_term_url %}
<div id="login_terms" class="login_terms">
<input type="checkbox"
id="login_terms_checkbox"
name="login_terms_checkbox"
required />
{% block login_terms_text %}
{# allow overriding the text #}
By logging into the platform you accept the <a href="{{ login_term_url }}">terms and conditions</a>.
{% endblock login_terms_text %}
</div>
{% endif %}
{% endblock login_terms %}
</div>
</form>
{% endif %}
{% endblock login_container %}
</div>
{% endblock login %}
{% endblock main %}
{% block script %}
{{ super() }}
<script>
if (!window.isSecureContext) {
// unhide http warning
var warning = document.getElementById('insecure-login-warning');
warning.className = warning.className.replace(/\bhidden\b/, '');
}
// setup onSubmit feedback
$('form').submit((e) => {
var form = $(e.target);
form.find('.feedback-container>input').attr('disabled', true);
form.find('.feedback-container>*').toggleClass('hidden');
form.find('.feedback-widget>*').toggleClass('fa-pulse');
});
</script>
{% endblock script %}

View File

@@ -0,0 +1,9 @@
{% extends "page.html" %}
{% if announcement_logout is string %}
{% set announcement = announcement_logout %}
{% endif %}
{% block main %}
<div id="logout-main" class="container">
<p>Successfully logged out.</p>
</div>
{% endblock main %}

View File

@@ -0,0 +1,11 @@
{% extends "page.html" %}
{% block main %}
<div class="container btn-jupyter">
<h2>{{message}}</h2>
</div>
<div class=container>
If you are authorized, you can try to <a href="{{ base_url }}login">login</a>.
</div>
{% endblock main %}

View File

@@ -0,0 +1,84 @@
{% extends "page.html" %}
{% if announcement_login %}
{% set announcement = announcement_login %}
{% endif %}
{% block script %}
{{ super() }}
<script type="text/javascript">
document.addEventListener('DOMContentLoaded', function() {
let button = document.getElementById('eye');
button.addEventListener("click", function(e) {
let pwd = document.getElementById("password_input");
if (pwd.getAttribute("type") === "password") {
pwd.setAttribute("type", "text");
button.innerHTML = '<i class="fa fa-eye-slash"></i>';
} else {
pwd.setAttribute("type", "password");
button.innerHTML = '<i class="fa fa-eye"></i>';
}
});
});
if (window.location.protocol === "http:") {
// unhide http warning
let warning = document.getElementById('insecure-login-warning');
warning.className = warning.className.replace(/\bhidden\b/, '');
}
</script>
{% endblock script %}
{% block main %}
{% block login %}
<div id="login-main" class="container">
<form action="{{login_url}}?next={{next}}" method="post" role="form">
<div class="auth-form-header">
Sign In
</div>
<div class='auth-form-body'>
<p id='insecure-login-warning' class='hidden'>
Warning: JupyterHub seems to be served over an unsecured HTTP connection.
We strongly recommend enabling HTTPS for JupyterHub.
</p>
{% if login_error %}
<p class="login_error">
{{login_error}}
</p>
{% endif %}
<input type="hidden" name="_xsrf" value="{{ xsrf }}"/>
<label for="username_input">Username:</label>
<input id="username_input" type="text" name="username" val="{{username}}" autocapitalize="off" autocorrect="off" class="form-control" autofocus="autofocus" required />
<p></p>
<label for='password_input'>Password:</label>
<div class="input-group">
<input id="password_input" type="password" name="password" val="" autocapitalize="off" autocorrect="off" class="form-control" />
<span class="input-group-addon">
<button id="eye" type="button" style="border:0;background:transparent;"><i class="fa fa-eye"></i></button>
</span>
</div>
<p></p>
{% if two_factor_auth %}
<label for="2fa_input">2FA code:</label>
<input id="2fa_input" type="text" autocapitalize="off" autocorrect="off" class="form-control" name="2fa" placeholder="If you don't have 2FA, leave empty" />
<p></p>
{% endif %}
<input type="submit" id="login_submit" class='btn btn-jupyter' value='Sign In' />
<p></p>
{% if enable_signup %}
<hr />
<p>
<a href="{{ base_url }}signup"> Sign up</a> to create a new user.
</p>
{% endif %}
</div>
</form>
</div>
{% endblock login %}
{% endblock main %}

View File

@@ -0,0 +1,75 @@
{% extends "page.html" %}
{% block main %}
<div class="container">
<div class="row">
<div class="text-center">
{% block heading %}
<h1>
{% if failed %}
Spawn failed
{% else %}
Server not running
{% endif %}
</h1>
{% endblock heading %}
{% block message %}
<p>
{% if failed %}
The latest attempt to start your server {{ server_name }} has failed.
{% if failed_html_message %}
</p>
<p>{{ failed_html_message | safe }}</p>
<p>{% elif failed_message %}</p>
<p>{{ failed_message }}</p>
<p>
{% 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 %}
</p>
{% endblock message %}
{% block start_button %}
<a id="start"
role="button"
class="btn btn-lg btn-primary"
href="{{ spawn_url }}">
{% if failed %}
Relaunch
{% else %}
Launch
{% endif %}
Server {{ server_name }}
</a>
{% endblock start_button %}
</div>
</div>
</div>
{% endblock main %}
{% block script %}
{{ super () }}
{% if implicit_spawn_seconds %}
{# djlint:off #}
<script type="text/javascript">
var spawn_url = "{{ spawn_url }}";
var implicit_spawn_seconds = {{ implicit_spawn_seconds }};
setTimeout(function() {
console.log("redirecting to spawn at", spawn_url);
window.location = spawn_url;
},
1000 * implicit_spawn_seconds
);
</script>
{# djlint:on #}
{% endif %}
<script type="text/javascript">
require(["not_running"]);
</script>
{% endblock script %}

View File

@@ -0,0 +1,52 @@
{% extends "page.html" %}
{% block login_widget %}
{% endblock login_widget %}
{% block main %}
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8">
<h1 class="text-center">Authorize access</h1>
<p class="lead">An application is requesting authorization to access data associated with your JupyterHub account</p>
<p>
{{ 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 %}
</p>
<form method="post" action="">
<div class="card">
<div class="card-header">
<p class="h5">This will grant the application permission to:</p>
</div>
<div class="card-body">
<input type="hidden" name="_xsrf" value="{{ xsrf }}" />
{# these are the 'real' inputs to the form -#}
{% for scope in allowed_scopes %}<input type="hidden" name="scopes" value="{{ scope }}" />{% endfor %}
{% for scope_info in scope_descriptions %}
<div class="checkbox input-group">
<label>
{# disabled because it's required #}
<input type="checkbox"
name="raw-scopes"
checked="true"
title="This authorization is required"
disabled="disabled" />
<span>
{{ scope_info['description'] }}
{% if scope_info['filter'] %}Applies to {{ scope_info['filter'] }}.{% endif %}
</span>
</label>
</div>
{% endfor %}
</div>
<div class="card-footer">
<button type="submit" class="form-control btn btn-jupyter mt-2">Authorize</button>
</div>
</div>
</form>
</div>
</div>
</div>
{% endblock main %}

View File

@@ -159,6 +159,9 @@
<li class="nav-item">
<a class="nav-link" href="{{ base_url }}notifications">Notifications</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ base_url }}settings">Settings</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link" href="{{ base_url }}change-password">Change Password</a>

View File

@@ -0,0 +1,54 @@
{% extends "page.html" %}
{% block main %}
<div class="container">
<h1>Platform Settings</h1>
<p class="text-muted">Read-only view of current environment variables and configuration</p>
<hr>
<div class="row">
<div class="col-12">
<table class="table table-striped table-sm">
<thead class="table-dark">
<tr>
<th style="width: 20%;">Category</th>
<th style="width: 30%;">Setting</th>
<th style="width: 25%;">Value</th>
<th style="width: 25%;">Description</th>
</tr>
</thead>
<tbody>
{% set current_category = namespace(value='') %}
{% for setting in settings %}
<tr>
<td>
{% if setting.category != current_category.value %}
<strong>{{ setting.category }}</strong>
{% set current_category.value = setting.category %}
{% endif %}
</td>
<td><code>{{ setting.name }}</code></td>
<td>
{% if setting.value|length > 50 %}
<span title="{{ setting.value }}">{{ setting.value[:47] }}...</span>
{% else %}
{{ setting.value }}
{% endif %}
</td>
<td class="text-muted">{{ setting.description }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<hr>
<p class="text-muted small">
These settings are configured via environment variables in <code>compose.yml</code> or <code>compose_override.yml</code>.
Changes require container restart to take effect.
</p>
</div>
{% endblock %}

View File

@@ -0,0 +1,130 @@
{% extends "page.html" %}
{% block script %}
{{ super() }}
<script>
document.addEventListener('DOMContentLoaded', function() {
let button = document.getElementById('eye');
button.addEventListener("click", function(e) {
let pwd = document.getElementById("password_input");
let pwdc = document.getElementById("password_confirmation_input");
if (pwd.getAttribute("type") === "password") {
pwd.setAttribute("type", "text");
pwdc.setAttribute("type", "text");
button.innerHTML = '<i class="fa fa-eye-slash"></i>';
} else {
pwd.setAttribute("type", "password");
pwdc.setAttribute("type", "password");
button.innerHTML = '<i class="fa fa-eye"></i>';
}
});
});
{% if tos %}
document.addEventListener('DOMContentLoaded', function() {
let checkbox = document.getElementById('tos_check');
checkbox.addEventListener("click", function(e) {
let submit_button = document.getElementById("signup_submit");
submit_button.disabled = ! checkbox.checked;
});
});
{% endif %}
if (window.location.protocol === "http:") {
// unhide http warning
let warning = document.getElementById('insecure-login-warning');
warning.className = warning.className.replace(/\bhidden\b/, '');
}
</script>
{% if recaptcha_key %}
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
{% endif %}
{% endblock script %}
{% block main %}
{% block login %}
<div id="login-main" class="container">
<form action="{{post_url}}" method="post" role="form">
<div class="auth-form-header">
Sign Up
</div>
<div class='auth-form-body'>
<p id='insecure-login-warning' class='hidden'>
Warning: JupyterHub seems to be served over an unsecured HTTP connection.
We strongly recommend enabling HTTPS for JupyterHub.
</p>
{% if alert %}
<div class="alert {{alert}}" role="alert">
<p>
{{result_message}}
</p>
{% if two_factor_auth_user %}
<p>
<strong>Attention!</strong> You have configured two factor authentication.
Your two factor authentication code is <strong>{{ two_factor_auth_value }}</strong>.
</p>
{% endif %}
</div>
{% endif %}
<input type="hidden" name="_xsrf" value="{{ xsrf }}"/>
<label for="username_input">Username:</label>
<input id="username_input" type="text" name="username" val="{{username}}" autocapitalize="off" autocorrect="off" class="form-control" autofocus="autofocus" required />
<p></p>
{% if ask_email %}
<label for="email_input">Email:</label>
<input id="email_input" type="email" name="email" val="{{email}}" class="form-control" required />
<p></p>
{% endif %}
<label for="password_input">Password:</label>
<div class="input-group">
<input id="password_input" type="password" name="signup_password" val="" autocapitalize="off" autocorrect="off" class="form-control" />
<span class="input-group-addon">
<button id="eye" type="button" style="border:0;background:transparent;"><i class="fa fa-eye"></i></button>
</span>
</div>
<p></p>
<label for="password_confirm">Confirm password:</label>
<div class="input-group">
<input id="password_confirmation_input" type="password" name="signup_password_confirmation" val="" autocapitalize="off" autocorrect="off" class="form-control" />
</div>
<p></p>
{% if two_factor_auth %}
<input type="checkbox" id="2fa" name="2fa" value="2fa">
Setup two factor authentication
</input>
<p></p>
{% endif %}
{% if tos %}
<input type="checkbox" id="tos_check">
{{tos|safe}}
</input>
<p></p>
{% endif %}
{% if recaptcha_key %}
<div class="g-recaptcha" data-sitekey="{{ recaptcha_key }}"></div>
<p></p>
{% endif %}
<input type="submit" id="signup_submit" class='btn btn-jupyter' value='Create User' {% if tos %}disabled{% endif %} />
<p></p>
<hr />
<p>
<a href="{{ base_url }}login">Login</a> with an existing user.
</p>
</div>
</form>
</div>
{% endblock login %}
{% endblock main %}

View File

@@ -0,0 +1,51 @@
{% extends "page.html" %}
{% if announcement_spawn is string %}
{% set announcement = announcement_spawn %}
{% endif %}
{% block main %}
<div class="container">
{% block heading %}
<div class="row text-center">
<h1>Server Options</h1>
</div>
{% endblock heading %}
<div class="row justify-content-center">
<div class="col-md-8">
{% if for_user and user.name != for_user.name -%}
<p>Spawning server for {{ for_user.name }}</p>
{% endif -%}
{% if error_message %}
<p class="spawn-error-msg alert alert-danger">Error: {{ error_message }}</p>
{% elif error_html_message %}
<p class="spawn-error-msg alert alert-danger">{{ error_html_message | safe }}</p>
{% endif %}
<form enctype="multipart/form-data"
id="spawn_form"
action="{{ url | safe }}"
method="post"
role="form">
{{ spawner_options_form | safe }}
<br>
<div class="feedback-container">
<button type="submit" class="btn btn-jupyter form-control">Start</button>
<div class="feedback-widget hidden">
<i class="fa fa-spinner"></i>
</div>
</div>
</form>
</div>
</div>
</div>
{% endblock main %}
{% block script %}
{{ super() }}
<script>
// setup onSubmit feedback
$('form').submit((e) => {
var form = $(e.target);
form.find('.feedback-container>input').attr('disabled', true);
form.find('.feedback-container>*').toggleClass('hidden');
form.find('.feedback-widget>*').toggleClass('fa-pulse');
});
</script>
{% endblock script %}

View File

@@ -0,0 +1,95 @@
{% extends "page.html" %}
{% block main %}
<div class="container">
<div class="row">
<div class="text-center">
{% block message %}
<p>Your server is starting up.</p>
<p>You will be redirected automatically when it's ready for you.</p>
{% endblock message %}
<div class="progress">
<div id="progress-bar"
class="progress-bar"
role="progressbar"
aria-valuenow="0"
aria-valuemin="0"
aria-valuemax="100">
<span class="visually-hidden"><span id="sr-progress">0%</span> Complete</span>
</div>
</div>
<p id="progress-message"></p>
</div>
</div>
<div class="row justify-content-center">
<div class="col-md-8">
<details id="progress-details">
<summary>Event log</summary>
<div id="progress-log"></div>
</details>
</div>
</div>
</div>
{% endblock main %}
{% block script %}
{{ super() }}
<script type="text/javascript">
require(["jquery"], function($) {
$("#refresh").click(function() {
window.location.reload();
});
// hook up event-stream for progress
var evtSource = new EventSource("{{ progress_url }}");
var progressMessage = $("#progress-message");
var progressBar = $("#progress-bar");
var srProgress = $("#sr-progress");
var progressLog = $("#progress-log");
evtSource.onmessage = function(e) {
var evt = JSON.parse(e.data);
console.log(evt);
if (evt.progress !== undefined) {
// update progress
var progText = evt.progress.toString();
progressBar.attr('aria-valuenow', progText);
srProgress.text(progText + '%');
progressBar.css('width', progText + '%');
}
// update message
var html_message;
if (evt.html_message !== undefined) {
progressMessage.html(evt.html_message);
html_message = evt.html_message;
} else if (evt.message !== undefined) {
progressMessage.text(evt.message);
html_message = progressMessage.html();
}
if (html_message) {
progressLog.append(
$("<div>")
.addClass('progress-log-event')
.html(html_message)
);
}
if (evt.ready) {
evtSource.close();
// reload the current page
// which should result in a redirect to the running server
window.location.reload();
}
if (evt.failed) {
evtSource.close();
// turn progress bar red
progressBar.addClass('progress-bar-danger');
// open event log for debugging
$('#progress-details').prop('open', true);
}
};
// signal that page has finished loading (mostly for tests)
window._jupyterhub_page_loaded = true;
});
</script>
{% endblock script %}

View File

@@ -0,0 +1,30 @@
{% extends "page.html" %}
{% block main %}
<div class="container">
<div class="row">
<div class="text-center">
{% block message %}
<p>Your server is stopping.</p>
<p>You will be able to start it again once it has finished stopping.</p>
{% endblock message %}
<p>
<i class="fa fa-spinner fa-pulse fa-fw fa-3x" aria-hidden="true"></i>
</p>
<a role="button" id="refresh" class="btn btn-lg btn-primary" href="#">refresh</a>
</div>
</div>
</div>
{% endblock main %}
{% block script %}
{{ super() }}
<script type="text/javascript">
require(["jquery"], function($) {
$("#refresh").click(function() {
window.location.reload();
});
setTimeout(function() {
window.location.reload();
}, 5000);
});
</script>
{% endblock script %}

View File

@@ -0,0 +1,178 @@
{% extends "page.html" %}
{% block main %}
<div class="container">
<h1 class="visually-hidden">Manage JupyterHub Tokens</h1>
<div class="row justify-content-center">
<form id="request-token-form" class="col-lg-6">
<div class="form-group">
<label for="token-note" class="form-label">Note</label>
<input id="token-note"
class="form-control"
placeholder="note to identify your new token">
<small id="note-note" class="form-text">This note will help you keep track of what your tokens are for.</small>
<br />
<label for="token-expiration-seconds" class="form-label">Token expires in</label>
{% block expiration_options %}
<select id="token-expiration-seconds" class="form-select">{{ token_expires_in_options_html | safe }}</select>
{% endblock expiration_options %}
<small id="note-expires-at" class="form-text">You can configure when your token will expire.</small>
<br />
<label for="token-scopes" class="form-label">Permissions</label>
<input id="token-scopes"
class="form-control"
placeholder="list of scopes for the token to have, separated by space">
<small id="note-token-scopes" class="form-text">
You can limit the permissions of the token so it can only do what you want it to.
If none are specified, the token will have permission to do everything you can do.
See the <a href="https://jupyterhub.readthedocs.io/en/stable/rbac/scopes.html#available-scopes">JupyterHub documentation for a list of available scopes</a>.
</small>
</div>
<div class="text-center m-4">
<button type="submit" class="btn btn-lg btn-jupyter">Request new API token</button>
</div>
</form>
</div>
<div class="row justify-content-center">
<div id="token-area" class="col-lg-6" style="display: none;">
<div class="card">
<div class="card-header">Your new API Token</div>
<div class="card-body">
<p class="card-title text-center">
<span id="token-result"></span>
</p>
<p class="card-text">
Copy this token. You won't be able to see it again,
but you can always come back here to get a new one.
</p>
</div>
</div>
</div>
</div>
{% if api_tokens %}
<div class="row" id="api-tokens-section">
<div class="col">
<h2>API Tokens</h2>
<p>
These are tokens with access to the JupyterHub API.
Permissions for each token may be viewed via the JupyterHub tokens API.
Revoking the API token for a running server will require restarting that server.
</p>
<table class="table table-striped" id="api-tokens-table">
<thead>
<tr>
<th>Note</th>
<th>Permissions</th>
<th>Last used</th>
<th>Created</th>
<th>Expires</th>
</tr>
</thead>
<tbody>
{% for token in api_tokens %}
<tr class="token-row container" data-token-id="{{ token.api_id }}">
{% block token_row scoped %}
<td class="note-col col">{{ token.note }}</td>
<td class="scope-col col">
<details>
<summary>scopes</summary>
{% for scope in token.scopes %}<pre class="token-scope">{{ scope }}</pre>{% endfor %}
</details>
</td>
<td class="time-col col">
{%- if token.last_activity -%}
{{ token.last_activity.isoformat() + 'Z' }}
{%- else -%}
Never
{%- endif -%}
</td>
<td class="time-col col">
{%- if token.created -%}
{{ token.created.isoformat() + 'Z' }}
{%- else -%}
N/A
{%- endif -%}
</td>
<td class="time-col col">
{%- if token.expires_at -%}
{{ token.expires_at.isoformat() + 'Z' }}
{%- else -%}
Never
{%- endif -%}
</td>
<td class="col text-center">
<button class="revoke-token-btn btn btn-xs btn-danger">revoke</button>
</td>
{% endblock token_row %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if oauth_clients %}
<div class="row" id="oauth-clients-section">
<h2>Authorized Applications</h2>
<p>
These are applications that use OAuth with JupyterHub
to identify users (mostly notebook servers).
OAuth tokens can generally only be used to identify you,
not take actions on your behalf.
</p>
<table class="table table-striped" id="oauth-tokens-table">
<thead>
<tr>
<th>Application</th>
<th>Permissions</th>
<th>Last used</th>
<th>First authorized</th>
</tr>
</thead>
<tbody>
{% for client in oauth_clients %}
<tr class="token-row" data-token-id="{{ client['token_id'] }}">
{% block client_row scoped %}
<td class="note-col col-sm-4">{{ client['description'] }}</td>
<td class="scope-col col-sm-1">
<details>
<summary>scopes</summary>
{# create set of scopes on all tokens -#}
{# sum concatenates all token.scopes into a single list -#}
{# then filter to unique set and sort -#}
{% for scope in client.tokens | sum(attribute="scopes", start=[]) | unique | sort %}
<pre class="token-scope">{{ scope }}</pre>
{% endfor %}
</details>
</td>
<td class="time-col col-sm-3">
{%- if client['last_activity'] -%}
{{ client['last_activity'].isoformat() + 'Z' }}
{%- else -%}
Never
{%- endif -%}
</td>
<td class="time-col col-sm-3">
{%- if client['created'] -%}
{{ client['created'].isoformat() + 'Z' }}
{%- else -%}
N/A
{%- endif -%}
</td>
<td class="col-sm-1 text-center">
<button class="revoke-token-btn btn btn-xs btn-danger">revoke</button>
</td>
{% endblock client_row %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
{% endblock main %}
{% block script %}
{{ super() }}
<script type="text/javascript">
require(["token"]);
</script>
{% endblock script %}

View File

@@ -45,16 +45,6 @@
</div>
</div>
{# Admin-only: Notifications link #}
{% if user.admin %}
<div class="row mt-3">
<div class="text-center">
<a href="{{ base_url }}notifications" class="text-decoration-none">
Broadcast Notifications to Active Servers
</a>
</div>
</div>
{% endif %}
{% if allow_named_servers %}
<h2>Named Servers</h2>

View File

@@ -159,6 +159,9 @@
<li class="nav-item">
<a class="nav-link" href="{{ base_url }}notifications">Notifications</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ base_url }}settings">Settings</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link" href="{{ base_url }}change-password">Change Password</a>

View File

@@ -0,0 +1,54 @@
{% extends "page.html" %}
{% block main %}
<div class="container">
<h1>Platform Settings</h1>
<p class="text-muted">Read-only view of current environment variables and configuration</p>
<hr>
<div class="row">
<div class="col-12">
<table class="table table-striped table-sm">
<thead class="table-dark">
<tr>
<th style="width: 20%;">Category</th>
<th style="width: 30%;">Setting</th>
<th style="width: 25%;">Value</th>
<th style="width: 25%;">Description</th>
</tr>
</thead>
<tbody>
{% set current_category = namespace(value='') %}
{% for setting in settings %}
<tr>
<td>
{% if setting.category != current_category.value %}
<strong>{{ setting.category }}</strong>
{% set current_category.value = setting.category %}
{% endif %}
</td>
<td><code>{{ setting.name }}</code></td>
<td>
{% if setting.value|length > 50 %}
<span title="{{ setting.value }}">{{ setting.value[:47] }}...</span>
{% else %}
{{ setting.value }}
{% endif %}
</td>
<td class="text-muted">{{ setting.description }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<hr>
<p class="text-muted small">
These settings are configured via environment variables in <code>compose.yml</code> or <code>compose_override.yml</code>.
Changes require container restart to take effect.
</p>
</div>
{% endblock %}