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';