From e0ceed80040a67f0cf75eeba6937796d27f7cb43 Mon Sep 17 00:00:00 2001 From: stellarshenson Date: Sun, 18 Jan 2026 20:28:33 +0100 Subject: [PATCH] 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 --- .claude/CLAUDE.md | 4 +- .claude/JOURNAL.md | 6 + README.md | 15 +- compose.yml | 10 +- config/jupyterhub_config.py | 29 ++- project.env | 2 +- services/jupyterhub/Dockerfile.jupyterhub | 25 +- .../jupyterhub/conf/bin/custom_handlers.py | 194 +++++++++++++++ .../jupyterhub/conf/settings_dictionary.yml | 11 +- .../html_templates_enhanced/home.html | 228 ++++++++++++++++++ 10 files changed, 497 insertions(+), 27 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 9b42aee..d648a13 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -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` diff --git a/.claude/JOURNAL.md b/.claude/JOURNAL.md index 93bd32d..715bf29 100644 --- a/.claude/JOURNAL.md +++ b/.claude/JOURNAL.md @@ -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
**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
+ **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
+ **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 diff --git a/README.md b/README.md index eeeb63d..aca8647 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ graph LR CHECK -->|1| ENABLED[GPU Enabled] CHECK -->|2| DETECT[Auto-detect] - DETECT --> SPAWN[Spawn test container
nvidia/cuda:12.9.1-base] + DETECT --> SPAWN[Spawn test container
nvidia/cuda:13.0.2-base] SPAWN --> RUN[Execute nvidia-smi
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 diff --git a/compose.yml b/compose.yml index d024638..9176804 100644 --- a/compose.yml +++ b/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" diff --git a/config/jupyterhub_config.py b/config/jupyterhub_config.py index e0ffc58..bbf4519 100644 --- a/config/jupyterhub_config.py +++ b/config/jupyterhub_config.py @@ -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), diff --git a/project.env b/project.env index eb834a6..fd4cf1b 100644 --- a/project.env +++ b/project.env @@ -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" diff --git a/services/jupyterhub/Dockerfile.jupyterhub b/services/jupyterhub/Dockerfile.jupyterhub index 94ef846..51fa4ff 100644 --- a/services/jupyterhub/Dockerfile.jupyterhub +++ b/services/jupyterhub/Dockerfile.jupyterhub @@ -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 diff --git a/services/jupyterhub/conf/bin/custom_handlers.py b/services/jupyterhub/conf/bin/custom_handlers.py index 515c6d8..a69b440 100755 --- a/services/jupyterhub/conf/bin/custom_handlers.py +++ b/services/jupyterhub/conf/bin/custom_handlers.py @@ -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)""" diff --git a/services/jupyterhub/conf/settings_dictionary.yml b/services/jupyterhub/conf/settings_dictionary.yml index 1024c7a..83b78ca 100644 --- a/services/jupyterhub/conf/settings_dictionary.yml +++ b/services/jupyterhub/conf/settings_dictionary.yml @@ -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 diff --git a/services/jupyterhub/html_templates_enhanced/home.html b/services/jupyterhub/html_templates_enhanced/home.html index 2db6fc0..11eca52 100644 --- a/services/jupyterhub/html_templates_enhanced/home.html +++ b/services/jupyterhub/html_templates_enhanced/home.html @@ -45,6 +45,53 @@ + {# Session Extension UI - only visible when culler enabled and server active #} + {% if idle_culler_enabled and default_server.active %} +
+
+
+
+
+ + Session Status +
+
+ + Loading session info... +
+
+
+ Session expires in: + -- +
+
+ + + +
+
+ + You can extend up to -- more hour(s) + +
+
+
+ +
+
+
+
+
+ {% endif %} + {% if allow_named_servers %}

Named Servers

@@ -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('Maximum extension limit reached'); + } 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(' 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(' 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(' 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); + } }); });