mirror of
https://github.com/stellarshenson/stellars-jupyterhub-ds.git
synced 2026-03-07 21:50:28 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user