diff --git a/.claude/JOURNAL.md b/.claude/JOURNAL.md index 87d17d3..50e885b 100644 --- a/.claude/JOURNAL.md +++ b/.claude/JOURNAL.md @@ -192,3 +192,6 @@ This journal tracks substantive work on documents, diagrams, and documentation c 63. **Task - Fix SQLite database locking**: Resolved database lock errors preventing login
**Result**: ActivityMonitor was using JupyterHub's main SQLite database (`/data/jupyterhub.sqlite`) via separate SQLAlchemy connection, causing `sqlite3.OperationalError: database is locked` when both tried to write simultaneously (login + activity sampling). Fixed by moving ActivityMonitor to separate database file `/data/activity_samples.sqlite`, completely avoiding lock contention. Added "Last Active" column to activity table showing relative time since user's last activity (e.g., "5min", "2h"), left-aligned. "Measured X ago" text now updates dynamically every second (shows "5s ago", "3min ago", etc.) using setInterval timer with stored timestamp + +64. **Task - Activity monitor sampling refactor**: Removed on-demand sampling and fixed timer display
+ **Result**: Removed `JUPYTERHUB_ACTIVITYMON_SAMPLE_INTERVAL` env var entirely - sampling will be controlled dynamically by caller. Removed on-demand sample recording from ActivityDataHandler (viewing activity page no longer triggers samples). Simplified `record_sample()` to always insert new sample without interval checking. Fixed formatTimeAgo to show nothing when time ≤0s (handles server/client clock skew). Removed interval config from Dockerfile and settings_dictionary.yml diff --git a/project.env b/project.env index 0aa7ad6..fb3c4b8 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.5_cuda-13.0.2_jh-5.4.2" +VERSION="3.7.7_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/Dockerfile.jupyterhub b/services/jupyterhub/Dockerfile.jupyterhub index 9981260..3366a72 100644 --- a/services/jupyterhub/Dockerfile.jupyterhub +++ b/services/jupyterhub/Dockerfile.jupyterhub @@ -87,7 +87,6 @@ ENV JUPYTERHUB_IDLE_CULLER_INTERVAL=600 ENV JUPYTERHUB_IDLE_CULLER_MAX_AGE=0 ENV JUPYTERHUB_IDLE_CULLER_MAX_EXTENSION=24 # Activity monitor -ENV JUPYTERHUB_ACTIVITYMON_SAMPLE_INTERVAL=600 ENV JUPYTERHUB_ACTIVITYMON_RETENTION_DAYS=7 ENV JUPYTERHUB_ACTIVITYMON_HALF_LIFE=24 ENV JUPYTERHUB_ACTIVITYMON_INACTIVE_AFTER=60 diff --git a/services/jupyterhub/conf/bin/custom_handlers.py b/services/jupyterhub/conf/bin/custom_handlers.py index 78e5481..1b3989f 100755 --- a/services/jupyterhub/conf/bin/custom_handlers.py +++ b/services/jupyterhub/conf/bin/custom_handlers.py @@ -89,7 +89,6 @@ class ActivityMonitor: _lock = threading.Lock() # Default configuration - DEFAULT_SAMPLE_INTERVAL = 600 # 10 minutes DEFAULT_RETENTION_DAYS = 7 # 7 days DEFAULT_HALF_LIFE = 24 # 24 hours DEFAULT_INACTIVE_AFTER = 60 # 60 minutes @@ -100,7 +99,6 @@ class ActivityMonitor: self._initialized = False # Load configuration from environment - self.sample_interval = self._get_env_int("JUPYTERHUB_ACTIVITYMON_SAMPLE_INTERVAL", self.DEFAULT_SAMPLE_INTERVAL, 60, 86400) self.retention_days = self._get_env_int("JUPYTERHUB_ACTIVITYMON_RETENTION_DAYS", self.DEFAULT_RETENTION_DAYS, 1, 365) self.half_life_hours = self._get_env_int("JUPYTERHUB_ACTIVITYMON_HALF_LIFE", self.DEFAULT_HALF_LIFE, 1, 168) self.inactive_after_minutes = self._get_env_int("JUPYTERHUB_ACTIVITYMON_INACTIVE_AFTER", self.DEFAULT_INACTIVE_AFTER, 1, 1440) @@ -108,7 +106,7 @@ class ActivityMonitor: # Calculate decay constant self.decay_lambda = math.log(2) / self.half_life_hours - print(f"[ActivityMonitor] Config: sample_interval={self.sample_interval}s, retention={self.retention_days}d, half_life={self.half_life_hours}h, inactive_after={self.inactive_after_minutes}m") + print(f"[ActivityMonitor] Config: retention={self.retention_days}d, half_life={self.half_life_hours}h, inactive_after={self.inactive_after_minutes}m") @classmethod def get_instance(cls): @@ -158,11 +156,7 @@ class ActivityMonitor: def record_sample(self, username, last_activity): """Record an activity sample for a user. - Sampling behavior: - - Always UPDATE the last sample (never insert on refresh) - - Only INSERT a new sample when sample_interval has passed since last sample - - This prevents sample flooding from frequent page refreshes - - New samples are collected at sample_interval rate (default 10 min) + Always inserts a new sample - caller controls sampling frequency. """ db = self._get_db() if db is None: @@ -178,31 +172,11 @@ class ActivityMonitor: age_seconds = (now - last_activity_utc).total_seconds() active = age_seconds <= (self.inactive_after_minutes * 60) - # Get the most recent sample for this user - last_sample = db.query(ActivitySample).filter( - ActivitySample.username == username - ).order_by(ActivitySample.timestamp.desc()).first() + # Insert new sample + db.add(ActivitySample(username=username, timestamp=now, last_activity=last_activity, active=active)) + db.commit() - if last_sample: - last_sample_ts = last_sample.timestamp.replace(tzinfo=timezone.utc) if last_sample.timestamp.tzinfo is None else last_sample.timestamp - sample_age = (now - last_sample_ts).total_seconds() - - if sample_age < self.sample_interval: - # Within sample interval - UPDATE last sample - last_sample.timestamp = now - last_sample.last_activity = last_activity - last_sample.active = active - db.commit() - else: - # Sample interval passed - INSERT new sample - db.add(ActivitySample(username=username, timestamp=now, last_activity=last_activity, active=active)) - db.commit() - else: - # No samples yet - INSERT first sample - db.add(ActivitySample(username=username, timestamp=now, last_activity=last_activity, active=active)) - db.commit() - - # Always prune old samples (regardless of update vs insert) + # Prune old samples cutoff = now - timedelta(days=self.retention_days) deleted = db.query(ActivitySample).filter( ActivitySample.username == username, @@ -1343,9 +1317,6 @@ class ActivityDataHandler(BaseHandler): time_remaining_seconds = max(0, effective_timeout - elapsed_seconds) user_data["time_remaining_seconds"] = int(time_remaining_seconds) - # Record a sample for this user (on-demand sampling when page is viewed) - record_activity_sample(user.name, last_activity) - # Only include users with active servers or recent activity samples if server_active or sample_count > 0: users_data.append(user_data) diff --git a/services/jupyterhub/conf/settings_dictionary.yml b/services/jupyterhub/conf/settings_dictionary.yml index 792cddc..99b6018 100644 --- a/services/jupyterhub/conf/settings_dictionary.yml +++ b/services/jupyterhub/conf/settings_dictionary.yml @@ -81,10 +81,6 @@ Idle Culler: default: "24" Activity Monitor: - - name: JUPYTERHUB_ACTIVITYMON_SAMPLE_INTERVAL - description: Sampling interval in seconds (60-86400) - default: "600" - - name: JUPYTERHUB_ACTIVITYMON_RETENTION_DAYS description: Sample retention period in days (1-365) default: "7" diff --git a/services/jupyterhub/html_templates_enhanced/activity.html b/services/jupyterhub/html_templates_enhanced/activity.html index 87504cc..66fe521 100644 --- a/services/jupyterhub/html_templates_enhanced/activity.html +++ b/services/jupyterhub/html_templates_enhanced/activity.html @@ -306,7 +306,9 @@ require(["jquery"], function($) { var diffMin = Math.floor(diffSec / 60); var diffHour = Math.floor(diffMin / 60); - if (diffSec < 60) { + if (diffSec <= 0) { + return ''; // Display nothing for 0 or negative + } else if (diffSec < 60) { return diffSec + 's ago'; } else if (diffMin < 60) { return diffMin + 'min ago';