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:
stellarshenson
2026-01-18 20:28:33 +01:00
parent 1db8ed1129
commit e0ceed8004
10 changed files with 497 additions and 27 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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