diff --git a/.claude/JOURNAL.md b/.claude/JOURNAL.md
index 337f028..a626737 100644
--- a/.claude/JOURNAL.md
+++ b/.claude/JOURNAL.md
@@ -216,3 +216,6 @@ This journal tracks substantive work on documents, diagrams, and documentation c
71. **Task - Volume sizes background caching**: Move volume size fetching to background to avoid blocking page load
**Result**: **Problem**: `docker system df` (used for volume sizes) is slow and was blocking Activity page load. **Solution**: Implemented background caching with configurable refresh interval. **Cache Structure**: `_volume_sizes_cache` dict with `data` (sizes), `timestamp` (last refresh), `refreshing` (lock flag). **Functions**: `_fetch_volume_sizes()` blocking fetch, `_refresh_volume_sizes_sync()` updates cache, `_refresh_volume_sizes_background()` non-blocking via thread pool, `get_cached_volume_sizes()` returns cache immediately, `get_volume_sizes_with_refresh()` returns cache and triggers background refresh if stale. **New Env Var**: `JUPYTERHUB_ACTIVITYMON_VOLUMES_UPDATE_INTERVAL` (default 3600 = 1 hour). Activity page now loads instantly - volume sizes served from cache, refreshed hourly in background. First load shows empty volumes until first background refresh completes
+
+72. **Task - Activity sampling for all users including offline**: Record activity samples for ALL users to show progress bars for everyone
+ **Result**: **Problem**: Activity sampling only tracked active servers, so offline users had no activity history and no progress bars. **Solution**: Added `record_samples_for_all_users(db, find_user_func)` function that iterates ALL users in JupyterHub database and records samples. Active users with recent activity → marked active. Active users idle → marked inactive. Offline users → marked inactive (uses last_activity from their last session). **New API Endpoint**: `POST /hub/api/activity/sample` (admin only) - triggers sampling for all users, returns `{"success": true, "total": 7, "active": 3, "inactive": 1, "offline": 3}`. Can be called by cron job or scheduler. **Handler**: Added `ActivitySampleHandler` to custom_handlers.py and registered in jupyterhub_config.py. Calls `log_activity_tick()` to log statistics after sampling
diff --git a/config/jupyterhub_config.py b/config/jupyterhub_config.py
index fce6e5e..7659d1a 100644
--- a/config/jupyterhub_config.py
+++ b/config/jupyterhub_config.py
@@ -449,7 +449,8 @@ if c is not None:
ExtendSessionHandler,
ActivityPageHandler,
ActivityDataHandler,
- ActivityResetHandler
+ ActivityResetHandler,
+ ActivitySampleHandler
)
c.JupyterHub.extra_handlers = [
@@ -462,6 +463,7 @@ if c is not None:
(r'/api/admin/credentials', GetUserCredentialsHandler),
(r'/api/activity', ActivityDataHandler),
(r'/api/activity/reset', ActivityResetHandler),
+ (r'/api/activity/sample', ActivitySampleHandler),
(r'/notifications', NotificationsPageHandler),
(r'/settings', SettingsPageHandler),
(r'/activity', ActivityPageHandler),
diff --git a/project.env b/project.env
index c96abf6..2d78f9d 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.12_cuda-13.0.2_jh-5.4.2"
+VERSION="3.7.13_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 8abf3e7..a68866e 100755
--- a/services/jupyterhub/conf/bin/custom_handlers.py
+++ b/services/jupyterhub/conf/bin/custom_handlers.py
@@ -384,6 +384,74 @@ def reset_all_activity_data():
return ActivityMonitor.get_instance().reset_all()
+def record_samples_for_all_users(db, find_user_func):
+ """
+ Record activity samples for ALL users (active and offline).
+
+ Call this from a scheduled task or background process.
+ - Active users with recent activity → sample marked as active
+ - Active users without recent activity (idle) → sample marked as inactive
+ - Offline users → sample marked as inactive (last_activity from last session)
+
+ Args:
+ db: JupyterHub database session (handler.db)
+ find_user_func: Function to find user by name (handler.find_user)
+
+ Returns:
+ dict with counts: {'total': N, 'active': N, 'inactive': N, 'offline': N}
+ """
+ from jupyterhub import orm
+ from datetime import datetime, timezone
+
+ monitor = ActivityMonitor.get_instance()
+ inactive_threshold = monitor.inactive_after_minutes * 60
+ now = datetime.now(timezone.utc)
+
+ counts = {'total': 0, 'active': 0, 'inactive': 0, 'offline': 0}
+
+ for orm_user in db.query(orm.User).all():
+ user = find_user_func(orm_user.name)
+ if not user:
+ continue
+
+ spawner = user.spawner
+ server_active = spawner.active if spawner else False
+
+ # Get last_activity from spawner
+ last_activity = None
+ if spawner and spawner.orm_spawner:
+ last_activity = spawner.orm_spawner.last_activity
+ if last_activity and last_activity.tzinfo is None:
+ last_activity = last_activity.replace(tzinfo=timezone.utc)
+
+ # Record sample for this user
+ monitor.record_sample(user.name, last_activity)
+ counts['total'] += 1
+
+ # Count by status
+ if server_active:
+ if last_activity:
+ elapsed = (now - last_activity).total_seconds()
+ if elapsed <= inactive_threshold:
+ counts['active'] += 1
+ else:
+ counts['inactive'] += 1
+ else:
+ counts['inactive'] += 1
+ else:
+ counts['offline'] += 1
+
+ # Log the tick with statistics
+ monitor.log_activity_tick(
+ counts['total'],
+ counts['active'],
+ counts['inactive'],
+ counts['offline']
+ )
+
+ return counts
+
+
# Thread pool for blocking Docker operations (prevents event loop blocking)
from concurrent.futures import ThreadPoolExecutor
_docker_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="docker-stats")
@@ -1526,3 +1594,31 @@ class ActivityResetHandler(BaseHandler):
self.log.info(f"[Activity Reset] Deleted {deleted} samples")
self.finish({"success": True, "deleted": deleted})
+
+
+class ActivitySampleHandler(BaseHandler):
+ """Handler for triggering activity sampling (admin only)"""
+
+ @web.authenticated
+ async def post(self):
+ """
+ Record activity samples for ALL users (active and offline).
+
+ POST /hub/api/activity/sample
+ Returns: {"success": true, "total": 7, "active": 3, "inactive": 1, "offline": 3}
+
+ Can be called by cron job or scheduler to periodically record samples.
+ """
+ current_user = self.current_user
+
+ # Only admins can trigger activity sampling
+ if not current_user.admin:
+ raise web.HTTPError(403, "Only administrators can trigger activity sampling")
+
+ self.log.info(f"[Activity Sample] Admin {current_user.name} triggered activity sampling")
+
+ counts = record_samples_for_all_users(self.db, self.find_user)
+
+ self.log.info(f"[Activity Sample] Recorded {counts['total']} samples: "
+ f"{counts['active']} active, {counts['inactive']} inactive, {counts['offline']} offline")
+ self.finish({"success": True, **counts})