feat: add volume size tooltip with per-volume breakdown

Activity Monitor now shows hover tooltip on volume sizes displaying
individual volume sizes (home, workspace, cache) instead of just total.

Backend changes:
- _fetch_volume_sizes() returns {total, volumes: {suffix: size}}
- ActivityDataHandler passes volume_breakdown to frontend

Frontend changes:
- formatVolumeSize() displays tooltip with sorted volume list
- Dotted underline indicates tooltip availability

Also: removed clean dependency from build targets in Makefile
This commit is contained in:
stellarshenson
2026-01-22 22:39:54 +01:00
parent 4d9a97eb82
commit ea5e8b730a
5 changed files with 54 additions and 21 deletions

View File

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

View File

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

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

View File

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

View File

@@ -252,7 +252,7 @@ require(["jquery"], function($) {
'<td class="text-center">' + formatStatus(user.server_active, user.recently_active) + '</td>' +
'<td class="text-center">' + formatCpu(user.cpu_percent) + '</td>' +
'<td class="text-center">' + formatMemory(user.memory_mb, user.memory_percent) + '</td>' +
'<td class="text-center">' + formatVolumeSize(user.volume_size_mb) + '</td>' +
'<td class="text-center">' + formatVolumeSize(user.volume_size_mb, user.volume_breakdown) + '</td>' +
'<td class="text-center">' + formatTimeRemaining(user.time_remaining_seconds) + '</td>' +
'<td>' + formatLastActive(user.last_activity) + '</td>' +
'<td>' + renderActivityBar(user.activity_score) + '</td>' +
@@ -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 '<span class="text-muted">--</span>';
}
// 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('&#10;');
return '<span title="' + tooltip + '" style="cursor: help; border-bottom: 1px dotted currentColor;">' + displaySize + '</span>';
}
return displaySize;
}
function formatTimeRemaining(seconds) {