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})