mirror of
https://github.com/stellarshenson/stellars-jupyterhub-ds.git
synced 2026-03-07 21:50:28 +00:00
feat: activity sampling for all users including offline
- Added record_samples_for_all_users() to sample ALL users - Offline users marked inactive (activity bars decay over time) - New API: POST /hub/api/activity/sample (admin, for cron jobs) - Returns counts: total, active, inactive, offline
This commit is contained in:
@@ -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<br>
|
||||
**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<br>
|
||||
**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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user