refactor: remove sample interval, simplify activity sampling

- Remove JUPYTERHUB_ACTIVITYMON_SAMPLE_INTERVAL env var entirely
- Remove on-demand sampling from activity page view
- Simplify record_sample() to always insert (caller controls frequency)
- Fix formatTimeAgo to show nothing when time ≤0s
- Update Dockerfile, settings_dictionary.yml, custom_handlers.py
This commit is contained in:
stellarshenson
2026-01-20 19:29:26 +01:00
parent ff32729bd6
commit 04ef42ce41
6 changed files with 13 additions and 42 deletions

View File

@@ -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<br>
**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<br>
**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

View File

@@ -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"

View File

@@ -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

View File

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

View File

@@ -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"

View File

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