diff --git a/.claude/JOURNAL.md b/.claude/JOURNAL.md index 0fd3a22..8633219 100644 --- a/.claude/JOURNAL.md +++ b/.claude/JOURNAL.md @@ -243,3 +243,6 @@ This journal tracks substantive work on documents, diagrams, and documentation c 80. **Task - Activity monitor half-life to 48h**: Changed decay half-life default from 24h to 48h
**Result**: Updated JUPYTERHUB_ACTIVITYMON_HALF_LIFE default across Dockerfile, custom_handlers.py (DEFAULT_HALF_LIFE constant), activity_sampler.py (code and docstring), settings_dictionary.yml, and README.md. With 48h half-life, a sample from 48 hours ago has 50% weight, providing smoother decay for activity scoring + +81. **Task - Volume size tooltip with breakdown**: Added hover tooltip showing per-volume sizes
+ **Result**: Modified `_fetch_volume_sizes()` to return `{encoded_username: {"total": float, "volumes": {suffix: float}}}` structure with per-volume breakdown (home, workspace, cache). Updated `ActivityDataHandler` to pass `volume_breakdown` dict to frontend. Enhanced `formatVolumeSize()` in activity.html to display tooltip on hover showing individual volume sizes (e.g., "cache: 500 MB\nhome: 1.2 GB\nworkspace: 3.5 GB"). Volume size displays dotted underline to indicate tooltip availability diff --git a/Makefile b/Makefile index d87a283..7a3bea9 100644 --- a/Makefile +++ b/Makefile @@ -48,11 +48,11 @@ increment_version: { print }' project.env > project.env.tmp && mv project.env.tmp project.env ## build docker containers (BUILD_OPTS='--no-version-increment --no-cache') -build: clean maybe_increment_version +build: maybe_increment_version @cd ./scripts && ./build.sh $(DOCKER_BUILD_OPTS) ## build with verbose output (BUILD_OPTS='--no-version-increment --no-cache') -build_verbose: clean maybe_increment_version +build_verbose: maybe_increment_version @cd ./scripts && ./build_verbose.sh $(DOCKER_BUILD_OPTS) ## pull docker image from dockerhub diff --git a/project.env b/project.env index bf18ef0..cbde294 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.7.14_cuda-13.0.2_jh-5.4.2" +VERSION="3.7.15_cuda-13.0.2_jh-5.4.2" VERSION_COMMENT="Activity Monitor: admin page with 3-state status, activity scoring, reset functionality" RELEASE_TAG="RELEASE_3.2.11" RELEASE_DATE="2025-11-09" diff --git a/services/jupyterhub/conf/bin/custom_handlers.py b/services/jupyterhub/conf/bin/custom_handlers.py index 1cda88c..bdf0d8c 100755 --- a/services/jupyterhub/conf/bin/custom_handlers.py +++ b/services/jupyterhub/conf/bin/custom_handlers.py @@ -641,7 +641,7 @@ def _get_volumes_update_interval(): def _fetch_volume_sizes(): """ Fetch sizes of all user volumes (blocking). - Returns dict: {encoded_username: total_size_mb} + Returns dict: {encoded_username: {"total": float, "volumes": {suffix: float}}} Uses 'docker system df -v' equivalent via Docker SDK. """ try: @@ -652,28 +652,33 @@ def _fetch_volume_sizes(): df_data = docker_client.df() volumes_data = df_data.get('Volumes', []) or [] - # Build dict of volume sizes by encoded username - user_sizes = {} + # Build dict of volume sizes by encoded username with per-volume breakdown + user_data = {} for vol in volumes_data: name = vol.get('Name', '') # Match pattern: jupyterlab-{encoded_username}_{suffix} if name.startswith('jupyterlab-') and '_' in name: - # Extract encoded username (between 'jupyterlab-' and last '_') + # Extract encoded username and suffix (between 'jupyterlab-' and last '_') parts = name[len('jupyterlab-'):].rsplit('_', 1) if len(parts) == 2: - encoded_username = parts[0] + encoded_username, suffix = parts # UsageData.Size contains actual bytes used usage_data = vol.get('UsageData', {}) or {} size_bytes = usage_data.get('Size', 0) or 0 + size_mb = round(size_bytes / (1024 * 1024), 1) - if encoded_username not in user_sizes: - user_sizes[encoded_username] = 0 - user_sizes[encoded_username] += size_bytes + if encoded_username not in user_data: + user_data[encoded_username] = {"total": 0, "volumes": {}} + user_data[encoded_username]["total"] += size_mb + user_data[encoded_username]["volumes"][suffix] = size_mb - # Convert to MB - result = {user: round(size / (1024 * 1024), 1) for user, size in user_sizes.items()} - log.info(f"[Volume Sizes] Refreshed: {len(result)} users, total {sum(result.values()):.1f} MB") - return result + # Round totals + for user in user_data: + user_data[user]["total"] = round(user_data[user]["total"], 1) + + total_size = sum(u["total"] for u in user_data.values()) + log.info(f"[Volume Sizes] Refreshed: {len(user_data)} users, total {total_size:.1f} MB") + return user_data finally: docker_client.close() except Exception as e: @@ -1694,7 +1699,9 @@ class ActivityDataHandler(BaseHandler): # Get volume size for this user (using encoded username) encoded_name = encode_username_for_docker(user.name) - user_volume_size = volume_sizes.get(encoded_name, 0) + user_volume_data = volume_sizes.get(encoded_name, {"total": 0, "volumes": {}}) + user_volume_size = user_volume_data.get("total", 0) + user_volume_breakdown = user_volume_data.get("volumes", {}) user_data = { "username": user.name, @@ -1707,7 +1714,8 @@ class ActivityDataHandler(BaseHandler): "activity_score": None, "sample_count": 0, "last_activity": None, - "volume_size_mb": user_volume_size + "volume_size_mb": user_volume_size, + "volume_breakdown": user_volume_breakdown } # Get activity score diff --git a/services/jupyterhub/html_templates_enhanced/activity.html b/services/jupyterhub/html_templates_enhanced/activity.html index 91623b1..7a2961b 100644 --- a/services/jupyterhub/html_templates_enhanced/activity.html +++ b/services/jupyterhub/html_templates_enhanced/activity.html @@ -252,7 +252,7 @@ require(["jquery"], function($) { '' + formatStatus(user.server_active, user.recently_active) + '' + '' + formatCpu(user.cpu_percent) + '' + '' + formatMemory(user.memory_mb, user.memory_percent) + '' + - '' + formatVolumeSize(user.volume_size_mb) + '' + + '' + formatVolumeSize(user.volume_size_mb, user.volume_breakdown) + '' + '' + formatTimeRemaining(user.time_remaining_seconds) + '' + '' + formatLastActive(user.last_activity) + '' + '' + renderActivityBar(user.activity_score) + '' + @@ -291,14 +291,36 @@ require(["jquery"], function($) { return memoryMb.toFixed(0) + ' MB'; } - function formatVolumeSize(sizeMb) { + function formatVolumeSize(sizeMb, breakdown) { if (sizeMb === null || sizeMb === undefined || sizeMb === 0) { return '--'; } + // Format total size + var displaySize; if (sizeMb >= 1024) { - return (sizeMb / 1024).toFixed(1) + ' GB'; + displaySize = (sizeMb / 1024).toFixed(1) + ' GB'; + } else { + displaySize = Math.round(sizeMb) + ' MB'; } - return Math.round(sizeMb) + ' MB'; + // Build tooltip with breakdown + if (breakdown && Object.keys(breakdown).length > 0) { + var tooltipLines = []; + // Sort volumes by name for consistent display + var sortedKeys = Object.keys(breakdown).sort(); + sortedKeys.forEach(function(suffix) { + var volSize = breakdown[suffix]; + var volDisplay; + if (volSize >= 1024) { + volDisplay = (volSize / 1024).toFixed(1) + ' GB'; + } else { + volDisplay = Math.round(volSize) + ' MB'; + } + tooltipLines.push(suffix + ': ' + volDisplay); + }); + var tooltip = tooltipLines.join(' '); + return '' + displaySize + ''; + } + return displaySize; } function formatTimeRemaining(seconds) {