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 %}
+