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($) {
'