mirror of
https://github.com/stellarshenson/stellars-jupyterhub-ds.git
synced 2026-03-07 13:40:28 +00:00
feat: add idle culler session extension and harmonize env settings
Session Extension Feature: - Add JUPYTERHUB_IDLE_CULLER_MAX_EXTENSION env var (default 24h) - Add SessionInfoHandler and ExtendSessionHandler API endpoints - Add Session Status card to home page with countdown timer - Extension tracking in spawner state (resets on server restart) - Color-coded warnings (yellow <1h, red <30min) Environment Settings Harmonization: - Add all ENV defaults to Dockerfile - Add idle culler settings to compose.yml - Standardize logo as JUPYTERHUB_LOGO_URI with file:// prefix - Update NVIDIA_IMAGE to nvidia/cuda:13.0.2-base-ubuntu24.04
This commit is contained in:
@@ -72,7 +72,7 @@ This Python configuration file controls all JupyterHub behavior:
|
||||
- `JUPYTERHUB_SERVICE_MLFLOW`: Enable MLflow tracking (`0`/`1`)
|
||||
- `JUPYTERHUB_SERVICE_RESOURCES_MONITOR`: 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`)
|
||||
- `JUPYTERHUB_NVIDIA_IMAGE`: Image for GPU detection (default: `nvidia/cuda:13.0.2-base-ubuntu24.04`)
|
||||
|
||||
**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`.
|
||||
|
||||
@@ -299,7 +299,7 @@ User renames via JupyterHub admin panel automatically sync to NativeAuthenticato
|
||||
## Troubleshooting
|
||||
|
||||
**GPU not detected**:
|
||||
- Verify NVIDIA Docker runtime: `docker run --rm --gpus all nvidia/cuda:12.9.1-base-ubuntu24.04 nvidia-smi`
|
||||
- Verify NVIDIA Docker runtime: `docker run --rm --gpus all nvidia/cuda:13.0.2-base-ubuntu24.04 nvidia-smi`
|
||||
- Check `JUPYTERHUB_NVIDIA_IMAGE` matches your CUDA version
|
||||
- Manually enable with `JUPYTERHUB_GPU_ENABLED=1`
|
||||
|
||||
|
||||
@@ -168,3 +168,9 @@ This journal tracks substantive work on documents, diagrams, and documentation c
|
||||
|
||||
55. **Task - Rename GLANCES to RESOURCES_MONITOR**: Renamed environment variable for clarity<br>
|
||||
**Result**: Renamed JUPYTERHUB_SERVICE_GLANCES to JUPYTERHUB_SERVICE_RESOURCES_MONITOR across compose.yml, jupyterhub_config.py, settings_dictionary.yml, README.md, and CLAUDE.md
|
||||
|
||||
56. **Task - Idle culler session extension feature**: Implemented user session extension capability for idle culler<br>
|
||||
**Result**: Added JUPYTERHUB_IDLE_CULLER_MAX_EXTENSION env var (default 24 hours) to jupyterhub_config.py, created SessionInfoHandler (GET /api/users/{username}/session-info) and ExtendSessionHandler (POST /api/users/{username}/extend-session) in custom_handlers.py, added Session Status card to home.html with countdown timer, extension dropdown (1h/2h/4h/8h), and extension allowance display, extension tracking stored in spawner.orm_spawner.state (resets on server restart), timer updates locally every 60s with server refresh every 5 minutes, color-coded warnings (yellow < 1h, red < 30min), extension button disabled when limit reached, added all idle culler settings to compose.yml (ENABLED, TIMEOUT, INTERVAL, MAX_AGE, MAX_EXTENSION), added setting to settings_dictionary.yml, updated README.md Idle Server Culler section with MAX_EXTENSION documentation, passed idle_culler_enabled/timeout/max_extension to templates via template_vars
|
||||
|
||||
57. **Task - Harmonize env settings across files**: Ensured all env settings are consistent in compose.yml, Dockerfile, and config<br>
|
||||
**Result**: Added all missing ENV defaults to Dockerfile (ADMIN, BASE_URL, SSL_ENABLED, NOTEBOOK_IMAGE, NETWORK_NAME, GPU_ENABLED, NVIDIA_IMAGE, SERVICE_MLFLOW/RESOURCES_MONITOR/TENSORBOARD, all IDLE_CULLER settings, TF_CPP_MIN_LOG_LEVEL), added AUTOGENERATED_PASSWORD_WORDS/DELIMITER to compose.yml, standardized logo setting as JUPYTERHUB_LOGO_URI with file:// prefix (supports external http/https URIs), updated NVIDIA_IMAGE to nvidia/cuda:13.0.2-base-ubuntu24.04 across all files (compose.yml, jupyterhub_config.py, Dockerfile, settings_dictionary.yml, README.md, CLAUDE.md), config now strips file:// prefix for local files while allowing external URI support in templates
|
||||
|
||||
15
README.md
15
README.md
@@ -175,7 +175,7 @@ graph LR
|
||||
CHECK -->|1| ENABLED[GPU Enabled]
|
||||
CHECK -->|2| DETECT[Auto-detect]
|
||||
|
||||
DETECT --> SPAWN[Spawn test container<br/>nvidia/cuda:12.9.1-base]
|
||||
DETECT --> SPAWN[Spawn test container<br/>nvidia/cuda:13.0.2-base]
|
||||
SPAWN --> RUN[Execute nvidia-smi<br/>with runtime=nvidia]
|
||||
|
||||
RUN --> SUCCESS{Success?}
|
||||
@@ -373,31 +373,28 @@ services:
|
||||
- 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)
|
||||
- JUPYTERHUB_IDLE_CULLER_MAX_EXTENSION=24 # 24 hours (default) - max hours users can extend their session
|
||||
```
|
||||
|
||||
**Behavior**:
|
||||
- `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
|
||||
- `JUPYTERHUB_IDLE_CULLER_MAX_EXTENSION`: Maximum total hours a user can extend their session. Users see a "Session Status" card on the home page showing time remaining and can request extensions up to this limit. Extension allowance resets when server restarts
|
||||
|
||||
#### Custom Branding
|
||||
|
||||
Replace the default JupyterHub logo with a custom logo. Mount your logo file and reference it via environment variable:
|
||||
Replace the default JupyterHub logo with a custom logo. Mount your logo file and set the path:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
jupyterhub:
|
||||
environment:
|
||||
- JUPYTERHUB_CUSTOM_LOGO_URI=file:///srv/jupyterhub/logo.svg
|
||||
- JUPYTERHUB_LOGO_URI=file:///srv/jupyterhub/logo.svg
|
||||
volumes:
|
||||
- ./logo.svg:/srv/jupyterhub/logo.svg:ro
|
||||
```
|
||||
|
||||
Supported formats: SVG, PNG, JPG. Alternative options:
|
||||
- **URL**: `https://example.com/logo.png`
|
||||
- **Data URI**: `data:image/png;base64,iVBORw0KGgo...`
|
||||
- **Shared storage**: `file:///mnt/shared/logo.png`
|
||||
|
||||
Leave empty (default) to use the stock JupyterHub logo.
|
||||
Supported formats: SVG, PNG, JPG. The default path `/srv/jupyterhub/logo.svg` is used if file exists.
|
||||
|
||||
#### Enable shared CIFS mount
|
||||
|
||||
|
||||
10
compose.yml
10
compose.yml
@@ -64,6 +64,8 @@ services:
|
||||
- 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
|
||||
- JUPYTERHUB_AUTOGENERATED_PASSWORD_WORDS=4 # words in auto-generated passwords
|
||||
- JUPYTERHUB_AUTOGENERATED_PASSWORD_DELIMITER=- # delimiter for auto-generated passwords
|
||||
# Docker spawner
|
||||
- JUPYTERHUB_NOTEBOOK_IMAGE=stellars/stellars-jupyterlab-ds:latest # user container image
|
||||
- JUPYTERHUB_NETWORK_NAME=jupyterhub_network # container network
|
||||
@@ -74,10 +76,16 @@ services:
|
||||
- JUPYTERHUB_SERVICE_MLFLOW=1 # MLflow experiment tracking
|
||||
- JUPYTERHUB_SERVICE_RESOURCES_MONITOR=1 # system resources monitor
|
||||
- JUPYTERHUB_SERVICE_TENSORBOARD=1 # TensorFlow training tracker
|
||||
# Idle culler (disabled by default)
|
||||
- JUPYTERHUB_IDLE_CULLER_ENABLED=0 # 0=disabled, 1=enabled
|
||||
- JUPYTERHUB_IDLE_CULLER_TIMEOUT=86400 # seconds of inactivity before culling (default 24h)
|
||||
- JUPYTERHUB_IDLE_CULLER_INTERVAL=600 # check interval in seconds (default 10min)
|
||||
- JUPYTERHUB_IDLE_CULLER_MAX_AGE=0 # max server age in seconds (0=unlimited)
|
||||
- JUPYTERHUB_IDLE_CULLER_MAX_EXTENSION=24 # max hours users can extend session
|
||||
# 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)
|
||||
- JUPYTERHUB_LOGO_URI=file:///srv/jupyterhub/logo.svg # custom logo URI
|
||||
labels:
|
||||
# Enable proxy support from Traefik
|
||||
- "traefik.enable=true"
|
||||
|
||||
@@ -157,7 +157,7 @@ def remove_nativeauth_on_user_delete(mapper, connection, target):
|
||||
|
||||
|
||||
# NVIDIA GPU auto-detection
|
||||
def detect_nvidia(nvidia_autodetect_image='nvidia/cuda:12.9.1-base-ubuntu24.04'):
|
||||
def detect_nvidia(nvidia_autodetect_image='nvidia/cuda:13.0.2-base-ubuntu24.04'):
|
||||
""" function to run docker image with nvidia driver, and execute `nvidia-smi` utility
|
||||
to verify if nvidia GPU is present and in functional state """
|
||||
client = docker.DockerClient('unix://var/run/docker.sock')
|
||||
@@ -195,12 +195,13 @@ JUPYTERHUB_IDLE_CULLER_ENABLED = int(os.environ.get("JUPYTERHUB_IDLE_CULLER_ENAB
|
||||
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))
|
||||
JUPYTERHUB_IDLE_CULLER_MAX_EXTENSION = int(os.environ.get("JUPYTERHUB_IDLE_CULLER_MAX_EXTENSION", 24)) # hours
|
||||
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")
|
||||
JUPYTERHUB_NVIDIA_IMAGE = os.environ.get("JUPYTERHUB_NVIDIA_IMAGE", "nvidia/cuda:13.0.2-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 = ''
|
||||
@@ -307,18 +308,24 @@ if c is not None:
|
||||
# Set volumes from constant
|
||||
c.DockerSpawner.volumes = DOCKER_SPAWNER_VOLUMES
|
||||
|
||||
# Custom logo - mount/copy logo file to /srv/jupyterhub/logo.svg (or set path via env)
|
||||
# JupyterHub serves this at {{ base_url }}logo automatically
|
||||
logo_file = os.environ.get('JUPYTERHUB_LOGO_FILE', '/srv/jupyterhub/logo.svg')
|
||||
if os.path.exists(logo_file):
|
||||
c.JupyterHub.logo_file = logo_file
|
||||
# Custom logo URI - supports file:// for local files, or http(s):// for external resources
|
||||
# JupyterHub serves local logos at {{ base_url }}logo automatically
|
||||
# External URIs (http/https) are passed to templates for custom rendering
|
||||
logo_uri = os.environ.get('JUPYTERHUB_LOGO_URI', 'file:///srv/jupyterhub/logo.svg')
|
||||
if logo_uri.startswith('file://'):
|
||||
logo_file = logo_uri[7:] # Strip file:// prefix to get local path
|
||||
if os.path.exists(logo_file):
|
||||
c.JupyterHub.logo_file = logo_file
|
||||
|
||||
# Make volume suffixes, descriptions, and version available to templates
|
||||
# Make volume suffixes, descriptions, version, and idle culler config available to templates
|
||||
c.JupyterHub.template_vars = {
|
||||
'user_volume_suffixes': USER_VOLUME_SUFFIXES,
|
||||
'volume_descriptions': VOLUME_DESCRIPTIONS,
|
||||
'stellars_version': os.environ.get('STELLARS_JUPYTERHUB_VERSION', 'dev'),
|
||||
'server_version': jupyterhub.__version__,
|
||||
'idle_culler_enabled': JUPYTERHUB_IDLE_CULLER_ENABLED,
|
||||
'idle_culler_timeout': JUPYTERHUB_IDLE_CULLER_TIMEOUT,
|
||||
'idle_culler_max_extension': JUPYTERHUB_IDLE_CULLER_MAX_EXTENSION,
|
||||
}
|
||||
|
||||
# Built-in groups that cannot be deleted (auto-recreated if missing)
|
||||
@@ -420,12 +427,16 @@ if c is not None:
|
||||
ActiveServersHandler,
|
||||
BroadcastNotificationHandler,
|
||||
GetUserCredentialsHandler,
|
||||
SettingsPageHandler
|
||||
SettingsPageHandler,
|
||||
SessionInfoHandler,
|
||||
ExtendSessionHandler
|
||||
)
|
||||
|
||||
c.JupyterHub.extra_handlers = [
|
||||
(r'/api/users/([^/]+)/manage-volumes', ManageVolumesHandler),
|
||||
(r'/api/users/([^/]+)/restart-server', RestartServerHandler),
|
||||
(r'/api/users/([^/]+)/session-info', SessionInfoHandler),
|
||||
(r'/api/users/([^/]+)/extend-session', ExtendSessionHandler),
|
||||
(r'/api/notifications/active-servers', ActiveServersHandler),
|
||||
(r'/api/notifications/broadcast', BroadcastNotificationHandler),
|
||||
(r'/api/admin/credentials', GetUserCredentialsHandler),
|
||||
|
||||
@@ -3,7 +3,7 @@ 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.11_cuda-13.0.2_jh-5.4.2"
|
||||
VERSION="3.6.14_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"
|
||||
|
||||
@@ -63,11 +63,34 @@ RUN <<-EOF
|
||||
EOF
|
||||
|
||||
## default environment variables
|
||||
# Core settings
|
||||
ENV JUPYTERHUB_ADMIN=admin
|
||||
ENV JUPYTERHUB_BASE_URL=/jupyterhub
|
||||
ENV JUPYTERHUB_SIGNUP_ENABLED=1
|
||||
ENV JUPYTERHUB_SSL_ENABLED=1
|
||||
ENV JUPYTERHUB_AUTOGENERATED_PASSWORD_WORDS=4
|
||||
ENV JUPYTERHUB_AUTOGENERATED_PASSWORD_DELIMITER="-"
|
||||
# Docker spawner
|
||||
ENV JUPYTERHUB_NOTEBOOK_IMAGE=stellars/stellars-jupyterlab-ds:latest
|
||||
ENV JUPYTERHUB_NETWORK_NAME=jupyterhub_network
|
||||
# GPU support
|
||||
ENV JUPYTERHUB_GPU_ENABLED=2
|
||||
ENV JUPYTERHUB_NVIDIA_IMAGE=nvidia/cuda:13.0.2-base-ubuntu24.04
|
||||
# User environment services
|
||||
ENV JUPYTERHUB_SERVICE_MLFLOW=1
|
||||
ENV JUPYTERHUB_SERVICE_RESOURCES_MONITOR=1
|
||||
ENV JUPYTERHUB_SERVICE_TENSORBOARD=1
|
||||
# Idle culler (disabled by default)
|
||||
ENV JUPYTERHUB_IDLE_CULLER_ENABLED=0
|
||||
ENV JUPYTERHUB_IDLE_CULLER_TIMEOUT=86400
|
||||
ENV JUPYTERHUB_IDLE_CULLER_INTERVAL=600
|
||||
ENV JUPYTERHUB_IDLE_CULLER_MAX_AGE=0
|
||||
ENV JUPYTERHUB_IDLE_CULLER_MAX_EXTENSION=24
|
||||
# Misc
|
||||
ENV TF_CPP_MIN_LOG_LEVEL=3
|
||||
ENV STELLARS_JUPYTERHUB_VERSION=${VERSION}
|
||||
ENV JUPYTERHUB_CUSTOM_LOGO_URI=""
|
||||
# Branding
|
||||
ENV JUPYTERHUB_LOGO_URI=file:///srv/jupyterhub/logo.svg
|
||||
|
||||
## expose ports
|
||||
EXPOSE 8000
|
||||
|
||||
@@ -563,6 +563,200 @@ class GetUserCredentialsHandler(BaseHandler):
|
||||
self.finish({"credentials": credentials})
|
||||
|
||||
|
||||
class SessionInfoHandler(BaseHandler):
|
||||
"""Handler for getting session info including idle culler status and extension tracking"""
|
||||
|
||||
@web.authenticated
|
||||
async def get(self, username):
|
||||
"""
|
||||
Get session info for a user's server
|
||||
|
||||
GET /hub/api/users/{username}/session-info
|
||||
Returns: {
|
||||
"culler_enabled": true,
|
||||
"server_active": true,
|
||||
"last_activity": "2024-01-18T10:30:00Z",
|
||||
"timeout_seconds": 86400,
|
||||
"time_remaining_seconds": 7200,
|
||||
"extensions_used_hours": 4,
|
||||
"extensions_available_hours": 20
|
||||
}
|
||||
"""
|
||||
self.log.info(f"[Session Info] API endpoint called for user: {username}")
|
||||
|
||||
# Check permissions: user must be admin or requesting their own info
|
||||
current_user = self.current_user
|
||||
if current_user is None:
|
||||
raise web.HTTPError(403, "Not authenticated")
|
||||
|
||||
if not (current_user.admin or current_user.name == username):
|
||||
raise web.HTTPError(403, "Permission denied")
|
||||
|
||||
# Get idle culler config from environment
|
||||
import os
|
||||
culler_enabled = int(os.environ.get("JUPYTERHUB_IDLE_CULLER_ENABLED", 0)) == 1
|
||||
timeout_seconds = int(os.environ.get("JUPYTERHUB_IDLE_CULLER_TIMEOUT", 86400))
|
||||
max_extension_hours = int(os.environ.get("JUPYTERHUB_IDLE_CULLER_MAX_EXTENSION", 24))
|
||||
|
||||
# Get user and spawner
|
||||
user = self.find_user(username)
|
||||
if not user:
|
||||
raise web.HTTPError(404, "User not found")
|
||||
|
||||
spawner = user.spawner
|
||||
server_active = spawner.active if spawner else False
|
||||
|
||||
response = {
|
||||
"culler_enabled": culler_enabled,
|
||||
"server_active": server_active,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
"max_extension_hours": max_extension_hours,
|
||||
}
|
||||
|
||||
if server_active and culler_enabled:
|
||||
# Get last activity timestamp
|
||||
last_activity = user.last_activity
|
||||
if last_activity:
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(timezone.utc)
|
||||
last_activity_utc = last_activity.replace(tzinfo=timezone.utc) if last_activity.tzinfo is None else last_activity
|
||||
elapsed_seconds = (now - last_activity_utc).total_seconds()
|
||||
time_remaining_seconds = max(0, timeout_seconds - elapsed_seconds)
|
||||
|
||||
response["last_activity"] = last_activity_utc.isoformat()
|
||||
response["time_remaining_seconds"] = int(time_remaining_seconds)
|
||||
else:
|
||||
response["last_activity"] = None
|
||||
response["time_remaining_seconds"] = timeout_seconds
|
||||
|
||||
# Get extension tracking from spawner state
|
||||
extensions_used_hours = spawner.orm_spawner.state.get('extension_hours_used', 0) if spawner.orm_spawner.state else 0
|
||||
response["extensions_used_hours"] = extensions_used_hours
|
||||
response["extensions_available_hours"] = max(0, max_extension_hours - extensions_used_hours)
|
||||
else:
|
||||
response["last_activity"] = None
|
||||
response["time_remaining_seconds"] = None
|
||||
response["extensions_used_hours"] = 0
|
||||
response["extensions_available_hours"] = max_extension_hours
|
||||
|
||||
self.log.info(f"[Session Info] Response for {username}: culler_enabled={culler_enabled}, active={server_active}")
|
||||
self.finish(response)
|
||||
|
||||
|
||||
class ExtendSessionHandler(BaseHandler):
|
||||
"""Handler for extending user session by resetting last_activity"""
|
||||
|
||||
@web.authenticated
|
||||
async def post(self, username):
|
||||
"""
|
||||
Extend a user's session by updating last_activity timestamp
|
||||
|
||||
POST /hub/api/users/{username}/extend-session
|
||||
Body: {"hours": 2}
|
||||
Returns: {
|
||||
"success": true,
|
||||
"message": "Session extended by 2 hours",
|
||||
"session_info": {
|
||||
"time_remaining_seconds": 7200,
|
||||
"extensions_used_hours": 6,
|
||||
"extensions_available_hours": 18
|
||||
}
|
||||
}
|
||||
"""
|
||||
self.log.info(f"[Extend Session] API endpoint called for user: {username}")
|
||||
|
||||
# Check permissions: user must be admin or requesting their own extension
|
||||
current_user = self.current_user
|
||||
if current_user is None:
|
||||
raise web.HTTPError(403, "Not authenticated")
|
||||
|
||||
if not (current_user.admin or current_user.name == username):
|
||||
raise web.HTTPError(403, "Permission denied")
|
||||
|
||||
# Parse request body
|
||||
try:
|
||||
body = self.request.body.decode('utf-8')
|
||||
data = json.loads(body) if body else {}
|
||||
hours = data.get('hours', 1)
|
||||
if not isinstance(hours, (int, float)) or hours <= 0:
|
||||
raise ValueError("Invalid hours value")
|
||||
hours = int(hours)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
self.log.error(f"[Extend Session] Invalid request: {e}")
|
||||
self.set_status(400)
|
||||
return self.finish({"success": False, "error": "Invalid request. Hours must be a positive number."})
|
||||
|
||||
# Get idle culler config
|
||||
import os
|
||||
culler_enabled = int(os.environ.get("JUPYTERHUB_IDLE_CULLER_ENABLED", 0)) == 1
|
||||
timeout_seconds = int(os.environ.get("JUPYTERHUB_IDLE_CULLER_TIMEOUT", 86400))
|
||||
max_extension_hours = int(os.environ.get("JUPYTERHUB_IDLE_CULLER_MAX_EXTENSION", 24))
|
||||
|
||||
if not culler_enabled:
|
||||
self.set_status(400)
|
||||
return self.finish({"success": False, "error": "Idle culler is not enabled"})
|
||||
|
||||
# Get user and spawner
|
||||
user = self.find_user(username)
|
||||
if not user:
|
||||
raise web.HTTPError(404, "User not found")
|
||||
|
||||
spawner = user.spawner
|
||||
if not spawner or not spawner.active:
|
||||
self.set_status(400)
|
||||
return self.finish({"success": False, "error": "Server is not running"})
|
||||
|
||||
# Get current extension usage from spawner state
|
||||
current_state = spawner.orm_spawner.state or {}
|
||||
extensions_used_hours = current_state.get('extension_hours_used', 0)
|
||||
|
||||
# Check if extension would exceed maximum
|
||||
if extensions_used_hours + hours > max_extension_hours:
|
||||
available = max_extension_hours - extensions_used_hours
|
||||
self.set_status(400)
|
||||
return self.finish({
|
||||
"success": False,
|
||||
"error": f"Extension would exceed maximum allowed ({max_extension_hours} hours). You have {available} hour(s) remaining."
|
||||
})
|
||||
|
||||
# Update last_activity to reset the idle timer
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(timezone.utc)
|
||||
user.last_activity = now
|
||||
self.db.commit()
|
||||
|
||||
# Update extension tracking in spawner state
|
||||
new_extensions_used = extensions_used_hours + hours
|
||||
new_state = dict(current_state)
|
||||
new_state['extension_hours_used'] = new_extensions_used
|
||||
|
||||
# Track extension history (optional)
|
||||
extension_history = new_state.get('extension_history', [])
|
||||
extension_history.append({
|
||||
'timestamp': now.isoformat(),
|
||||
'hours': hours
|
||||
})
|
||||
new_state['extension_history'] = extension_history
|
||||
|
||||
# Save updated state
|
||||
spawner.orm_spawner.state = new_state
|
||||
self.db.commit()
|
||||
|
||||
self.log.info(f"[Extend Session] User {username} extended session by {hours} hour(s). Total used: {new_extensions_used}/{max_extension_hours}")
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
"message": f"Session extended by {hours} hour(s)",
|
||||
"session_info": {
|
||||
"time_remaining_seconds": timeout_seconds,
|
||||
"extensions_used_hours": new_extensions_used,
|
||||
"extensions_available_hours": max_extension_hours - new_extensions_used
|
||||
}
|
||||
}
|
||||
|
||||
self.finish(response)
|
||||
|
||||
|
||||
class SettingsPageHandler(BaseHandler):
|
||||
"""Handler for rendering the settings page (admin only, read-only)"""
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ GPU:
|
||||
|
||||
- name: JUPYTERHUB_NVIDIA_IMAGE
|
||||
description: CUDA image for GPU detection
|
||||
default: nvidia/cuda:12.9.1-base-ubuntu24.04
|
||||
default: nvidia/cuda:13.0.2-base-ubuntu24.04
|
||||
|
||||
Services:
|
||||
- name: JUPYTERHUB_SERVICE_MLFLOW
|
||||
@@ -76,11 +76,14 @@ Idle Culler:
|
||||
description: Max server age (0=unlimited)
|
||||
default: "0"
|
||||
|
||||
- name: JUPYTERHUB_IDLE_CULLER_MAX_EXTENSION
|
||||
description: Max session extension hours per session
|
||||
default: "24"
|
||||
|
||||
Branding:
|
||||
- name: JUPYTERHUB_CUSTOM_LOGO_URI
|
||||
- name: JUPYTERHUB_LOGO_URI
|
||||
description: Custom logo URI
|
||||
default: ""
|
||||
empty_display: "(default)"
|
||||
default: file:///srv/jupyterhub/logo.svg
|
||||
|
||||
- name: STELLARS_JUPYTERHUB_VERSION
|
||||
description: Platform version
|
||||
|
||||
@@ -45,6 +45,53 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Session Extension UI - only visible when culler enabled and server active #}
|
||||
{% if idle_culler_enabled and default_server.active %}
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-6 offset-md-3">
|
||||
<div class="card" id="session-status-card">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title mb-3">
|
||||
<i class="fa fa-clock" aria-hidden="true"></i>
|
||||
Session Status
|
||||
</h6>
|
||||
<div id="session-loading" class="text-center py-2">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
Loading session info...
|
||||
</div>
|
||||
<div id="session-info" class="d-none">
|
||||
<div class="mb-3">
|
||||
<span class="text-muted">Session expires in:</span>
|
||||
<strong id="time-remaining" class="ms-2">--</strong>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<label for="extend-hours" class="text-muted mb-0">Extend by:</label>
|
||||
<select id="extend-hours" class="form-select form-select-sm" style="width: auto;">
|
||||
<option value="1">1 hour</option>
|
||||
<option value="2">2 hours</option>
|
||||
<option value="4">4 hours</option>
|
||||
<option value="8">8 hours</option>
|
||||
</select>
|
||||
<button id="extend-session-btn" class="btn btn-sm btn-outline-primary">
|
||||
<i class="fa fa-plus" aria-hidden="true"></i>
|
||||
Extend Session
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<small class="text-muted" id="extension-allowance">
|
||||
You can extend up to <span id="extensions-available">--</span> more hour(s)
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div id="session-error" class="d-none alert alert-warning mb-0 mt-2">
|
||||
<small id="session-error-msg"></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if allow_named_servers %}
|
||||
<h2>Named Servers</h2>
|
||||
@@ -427,6 +474,187 @@
|
||||
console.log('[Custom Handlers] Confirm restart button exists:', $('#confirm-restart-btn').length > 0);
|
||||
console.log('[Custom Handlers] Confirm reset button exists:', $('#confirm-reset-btn').length > 0);
|
||||
console.log('[Custom Handlers] Stop button exists:', $('#stop').length > 0);
|
||||
|
||||
// ============================================================
|
||||
// Session Extension Feature
|
||||
// ============================================================
|
||||
|
||||
const $sessionCard = $('#session-status-card');
|
||||
const $sessionLoading = $('#session-loading');
|
||||
const $sessionInfo = $('#session-info');
|
||||
const $sessionError = $('#session-error');
|
||||
const $sessionErrorMsg = $('#session-error-msg');
|
||||
const $timeRemaining = $('#time-remaining');
|
||||
const $extensionsAvailable = $('#extensions-available');
|
||||
const $extendBtn = $('#extend-session-btn');
|
||||
const $extendHours = $('#extend-hours');
|
||||
|
||||
// Only initialize if session card exists (culler enabled + server active)
|
||||
if ($sessionCard.length > 0) {
|
||||
console.log('[Session Extension] Initializing session extension feature');
|
||||
|
||||
let sessionUpdateInterval = null;
|
||||
let currentSessionInfo = null;
|
||||
|
||||
// Format seconds to human readable string
|
||||
function formatTimeRemaining(seconds) {
|
||||
if (seconds <= 0) {
|
||||
return 'Session may be culled soon';
|
||||
}
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
// Update the UI with session info
|
||||
function updateSessionUI(info) {
|
||||
currentSessionInfo = info;
|
||||
|
||||
if (!info.culler_enabled || !info.server_active) {
|
||||
$sessionCard.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
$sessionLoading.addClass('d-none');
|
||||
$sessionInfo.removeClass('d-none');
|
||||
|
||||
// Update time remaining
|
||||
const timeRemainingSeconds = info.time_remaining_seconds || 0;
|
||||
$timeRemaining.text(formatTimeRemaining(timeRemainingSeconds));
|
||||
|
||||
// Style based on time remaining
|
||||
if (timeRemainingSeconds < 1800) { // Less than 30 minutes
|
||||
$timeRemaining.removeClass('text-warning').addClass('text-danger');
|
||||
} else if (timeRemainingSeconds < 3600) { // Less than 1 hour
|
||||
$timeRemaining.removeClass('text-danger').addClass('text-warning');
|
||||
} else {
|
||||
$timeRemaining.removeClass('text-danger text-warning');
|
||||
}
|
||||
|
||||
// Update extensions available
|
||||
const available = info.extensions_available_hours || 0;
|
||||
$extensionsAvailable.text(available);
|
||||
|
||||
// Disable extend button if no extensions available
|
||||
if (available <= 0) {
|
||||
$extendBtn.prop('disabled', true).addClass('btn-secondary').removeClass('btn-outline-primary');
|
||||
$('#extension-allowance').html('<small class="text-warning">Maximum extension limit reached</small>');
|
||||
} else {
|
||||
$extendBtn.prop('disabled', false).removeClass('btn-secondary').addClass('btn-outline-primary');
|
||||
}
|
||||
|
||||
// Filter dropdown options to only show values <= available
|
||||
$extendHours.find('option').each(function() {
|
||||
const val = parseInt($(this).val());
|
||||
$(this).prop('disabled', val > available);
|
||||
});
|
||||
|
||||
console.log('[Session Extension] UI updated - remaining:', timeRemainingSeconds, 'available ext:', available);
|
||||
}
|
||||
|
||||
// Fetch session info from API
|
||||
function fetchSessionInfo() {
|
||||
const apiUrl = `{{ base_url }}api/users/${username}/session-info`;
|
||||
console.log('[Session Extension] Fetching session info from:', apiUrl);
|
||||
|
||||
$.ajax({
|
||||
url: apiUrl,
|
||||
type: 'GET',
|
||||
headers: {
|
||||
'X-XSRFToken': getCookie('_xsrf')
|
||||
},
|
||||
success: function(response) {
|
||||
console.log('[Session Extension] Session info received:', response);
|
||||
$sessionError.addClass('d-none');
|
||||
updateSessionUI(response);
|
||||
},
|
||||
error: function(xhr) {
|
||||
console.error('[Session Extension] Failed to fetch session info:', xhr.status, xhr.responseText);
|
||||
$sessionLoading.addClass('d-none');
|
||||
$sessionError.removeClass('d-none');
|
||||
$sessionErrorMsg.text('Unable to load session info');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle extend session button click
|
||||
$extendBtn.on('click', function() {
|
||||
const hours = parseInt($extendHours.val());
|
||||
const apiUrl = `{{ base_url }}api/users/${username}/extend-session`;
|
||||
|
||||
console.log('[Session Extension] Extending session by', hours, 'hour(s)');
|
||||
|
||||
// Disable button during request
|
||||
$extendBtn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm" role="status"></span> Extending...');
|
||||
|
||||
$.ajax({
|
||||
url: apiUrl,
|
||||
type: 'POST',
|
||||
headers: {
|
||||
'X-XSRFToken': getCookie('_xsrf')
|
||||
},
|
||||
data: JSON.stringify({ hours: hours }),
|
||||
contentType: 'application/json',
|
||||
success: function(response) {
|
||||
console.log('[Session Extension] Extension successful:', response);
|
||||
$extendBtn.html('<i class="fa fa-plus" aria-hidden="true"></i> Extend Session');
|
||||
|
||||
if (response.success) {
|
||||
// Update UI with new session info
|
||||
if (response.session_info) {
|
||||
updateSessionUI({
|
||||
culler_enabled: true,
|
||||
server_active: true,
|
||||
time_remaining_seconds: response.session_info.time_remaining_seconds,
|
||||
extensions_used_hours: response.session_info.extensions_used_hours,
|
||||
extensions_available_hours: response.session_info.extensions_available_hours
|
||||
});
|
||||
}
|
||||
// Show success feedback briefly
|
||||
$sessionError.removeClass('d-none alert-warning').addClass('alert-success');
|
||||
$sessionErrorMsg.text(response.message);
|
||||
setTimeout(function() {
|
||||
$sessionError.addClass('d-none').removeClass('alert-success').addClass('alert-warning');
|
||||
fetchSessionInfo(); // Refresh to get accurate time
|
||||
}, 2000);
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
console.error('[Session Extension] Extension failed:', xhr.status, xhr.responseText);
|
||||
$extendBtn.prop('disabled', false).html('<i class="fa fa-plus" aria-hidden="true"></i> Extend Session');
|
||||
|
||||
const errorMsg = xhr.responseJSON?.error || 'Failed to extend session';
|
||||
$sessionError.removeClass('d-none');
|
||||
$sessionErrorMsg.text(errorMsg);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Initial fetch
|
||||
fetchSessionInfo();
|
||||
|
||||
// Update every 60 seconds
|
||||
sessionUpdateInterval = setInterval(function() {
|
||||
// Decrement local counter between fetches
|
||||
if (currentSessionInfo && currentSessionInfo.time_remaining_seconds > 0) {
|
||||
currentSessionInfo.time_remaining_seconds = Math.max(0, currentSessionInfo.time_remaining_seconds - 60);
|
||||
$timeRemaining.text(formatTimeRemaining(currentSessionInfo.time_remaining_seconds));
|
||||
|
||||
// Update styling
|
||||
if (currentSessionInfo.time_remaining_seconds < 1800) {
|
||||
$timeRemaining.removeClass('text-warning').addClass('text-danger');
|
||||
} else if (currentSessionInfo.time_remaining_seconds < 3600) {
|
||||
$timeRemaining.removeClass('text-danger').addClass('text-warning');
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
// Refresh from server every 5 minutes
|
||||
setInterval(fetchSessionInfo, 300000);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user