mirror of
https://github.com/stellarshenson/stellars-jupyterhub-ds.git
synced 2026-03-07 13:40:28 +00:00
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:
@@ -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
|
||||
|
||||
4
Makefile
4
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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(' ');
|
||||
return '<span title="' + tooltip + '" style="cursor: help; border-bottom: 1px dotted currentColor;">' + displaySize + '</span>';
|
||||
}
|
||||
return displaySize;
|
||||
}
|
||||
|
||||
function formatTimeRemaining(seconds) {
|
||||
|
||||
Reference in New Issue
Block a user