mirror of
https://github.com/stellarshenson/stellars-jupyterhub-ds.git
synced 2026-03-07 21:50:28 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
48
README.md
48
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<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
|
||||
|
||||
|
||||
31
compose.yml
31
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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
4
services/jupyterhub/templates/404.html
Normal file
4
services/jupyterhub/templates/404.html
Normal 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 %}
|
||||
52
services/jupyterhub/templates/accept-share.html
Normal file
52
services/jupyterhub/templates/accept-share.html
Normal 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 %}
|
||||
315
services/jupyterhub/templates/admin.html
Normal file
315
services/jupyterhub/templates/admin.html
Normal 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 %}
|
||||
49
services/jupyterhub/templates/authorization-area.html
Normal file
49
services/jupyterhub/templates/authorization-area.html
Normal 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 %}
|
||||
58
services/jupyterhub/templates/change-password-admin.html
Normal file
58
services/jupyterhub/templates/change-password-admin.html
Normal 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 %}
|
||||
68
services/jupyterhub/templates/change-password.html
Normal file
68
services/jupyterhub/templates/change-password.html
Normal 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 %}
|
||||
47
services/jupyterhub/templates/error.html
Normal file
47
services/jupyterhub/templates/error.html
Normal 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 %}
|
||||
@@ -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 }}";
|
||||
|
||||
120
services/jupyterhub/templates/login.html
Normal file
120
services/jupyterhub/templates/login.html
Normal 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 %}
|
||||
9
services/jupyterhub/templates/logout.html
Normal file
9
services/jupyterhub/templates/logout.html
Normal 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 %}
|
||||
11
services/jupyterhub/templates/my_message.html
Normal file
11
services/jupyterhub/templates/my_message.html
Normal 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 %}
|
||||
84
services/jupyterhub/templates/native-login.html
Normal file
84
services/jupyterhub/templates/native-login.html
Normal 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 %}
|
||||
75
services/jupyterhub/templates/not_running.html
Normal file
75
services/jupyterhub/templates/not_running.html
Normal 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 %}
|
||||
52
services/jupyterhub/templates/oauth.html
Normal file
52
services/jupyterhub/templates/oauth.html
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
54
services/jupyterhub/templates/settings.html
Normal file
54
services/jupyterhub/templates/settings.html
Normal 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 %}
|
||||
130
services/jupyterhub/templates/signup.html
Normal file
130
services/jupyterhub/templates/signup.html
Normal 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 %}
|
||||
51
services/jupyterhub/templates/spawn.html
Normal file
51
services/jupyterhub/templates/spawn.html
Normal 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 %}
|
||||
95
services/jupyterhub/templates/spawn_pending.html
Normal file
95
services/jupyterhub/templates/spawn_pending.html
Normal 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 %}
|
||||
30
services/jupyterhub/templates/stop_pending.html
Normal file
30
services/jupyterhub/templates/stop_pending.html
Normal 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 %}
|
||||
178
services/jupyterhub/templates/token.html
Normal file
178
services/jupyterhub/templates/token.html
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
54
services/jupyterhub/templates_enhanced/settings.html
Normal file
54
services/jupyterhub/templates_enhanced/settings.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user