fix: use separate SQLite database for activity monitor

- ActivityMonitor now uses /data/activity_samples.sqlite instead of
  JupyterHub's main database to avoid SQLite locking conflicts
- Fixes "database is locked" errors that prevented login when both
  JupyterHub and ActivityMonitor wrote simultaneously
- Added "Last Active" column to activity table showing relative time
This commit is contained in:
stellarshenson
2026-01-20 19:04:39 +01:00
parent 66de0e7c5c
commit 4b2fc084bf
4 changed files with 37 additions and 3 deletions

View File

@@ -189,3 +189,6 @@ This journal tracks substantive work on documents, diagrams, and documentation c
62. **Task - Activity Monitor UI polish and sample cleanup fix**: Improved activity bar design and fixed sample retention enforcement<br> 62. **Task - Activity Monitor UI polish and sample cleanup fix**: Improved activity bar design and fixed sample retention enforcement<br>
**Result**: Fixed inconsistent timestamp text (initial "Last updated: --" vs loaded "Measured X ago" - removed initial text so only "Measured X ago" shows). Redesigned activity bar from separate 16x16 boxes to thin continuous bar (80px wide, 8px tall) with subtle 1px dividers between 5 segments - visually distinct from day-based layouts. Fixed critical sample cleanup bug - cleanup only ran when new sample was INSERT-ed, not when existing sample was UPDATE-d, causing old samples to persist indefinitely when refreshes kept updating the last sample. Moved cleanup to run on every record_sample() call regardless of update vs insert, ensuring samples older than RETENTION_DAYS are always pruned **Result**: Fixed inconsistent timestamp text (initial "Last updated: --" vs loaded "Measured X ago" - removed initial text so only "Measured X ago" shows). Redesigned activity bar from separate 16x16 boxes to thin continuous bar (80px wide, 8px tall) with subtle 1px dividers between 5 segments - visually distinct from day-based layouts. Fixed critical sample cleanup bug - cleanup only ran when new sample was INSERT-ed, not when existing sample was UPDATE-d, causing old samples to persist indefinitely when refreshes kept updating the last sample. Moved cleanup to run on every record_sample() call regardless of update vs insert, ensuring samples older than RETENTION_DAYS are always pruned
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 14min")

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" 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
VERSION="3.7.3_cuda-13.0.2_jh-5.4.2" VERSION="3.7.5_cuda-13.0.2_jh-5.4.2"
VERSION_COMMENT="Activity Monitor: admin page with 3-state status, activity scoring, reset functionality" VERSION_COMMENT="Activity Monitor: admin page with 3-state status, activity scoring, reset functionality"
RELEASE_TAG="RELEASE_3.2.11" RELEASE_TAG="RELEASE_3.2.11"
RELEASE_DATE="2025-11-09" RELEASE_DATE="2025-11-09"

View File

@@ -132,11 +132,16 @@ class ActivityMonitor:
return default return default
def _get_db(self): def _get_db(self):
"""Get or create database session""" """Get or create database session.
Uses a SEPARATE database file to avoid SQLite locking conflicts
with JupyterHub's main database.
"""
if self._db_session is not None: if self._db_session is not None:
return self._db_session return self._db_session
db_url = os.environ.get('JUPYTERHUB_DB_URL', 'sqlite:////data/jupyterhub.sqlite') # Use separate database file to avoid locking conflicts with JupyterHub
db_url = 'sqlite:////data/activity_samples.sqlite'
try: try:
self._engine = create_engine(db_url) self._engine = create_engine(db_url)

View File

@@ -46,6 +46,7 @@
<th class="text-center">CPU</th> <th class="text-center">CPU</th>
<th class="text-center">Memory</th> <th class="text-center">Memory</th>
<th class="text-center">Time Left</th> <th class="text-center">Time Left</th>
<th class="text-center">Last Active</th>
<th>Activity (7 days)</th> <th>Activity (7 days)</th>
</tr> </tr>
</thead> </thead>
@@ -166,6 +167,7 @@ require(["jquery"], function($) {
'<td class="text-center">' + formatCpu(user.cpu_percent) + '</td>' + '<td class="text-center">' + formatCpu(user.cpu_percent) + '</td>' +
'<td class="text-center">' + formatMemory(user.memory_mb, user.memory_percent) + '</td>' + '<td class="text-center">' + formatMemory(user.memory_mb, user.memory_percent) + '</td>' +
'<td class="text-center">' + formatTimeRemaining(user.time_remaining_seconds) + '</td>' + '<td class="text-center">' + formatTimeRemaining(user.time_remaining_seconds) + '</td>' +
'<td class="text-center">' + formatLastActive(user.last_activity) + '</td>' +
'<td>' + renderActivityBar(user.activity_score) + '</td>' + '<td>' + renderActivityBar(user.activity_score) + '</td>' +
'</tr>'; '</tr>';
tbody.append(row); tbody.append(row);
@@ -229,6 +231,30 @@ require(["jquery"], function($) {
} }
} }
function formatLastActive(isoString) {
if (!isoString) {
return '<span class="text-muted">--</span>';
}
var date = new Date(isoString);
var now = new Date();
var diffMs = now - date;
var diffSec = Math.floor(diffMs / 1000);
var diffMin = Math.floor(diffSec / 60);
var diffHour = Math.floor(diffMin / 60);
var remainingMin = diffMin % 60;
if (diffMin < 1) {
return '<span class="text-success">now</span>';
} else if (diffMin < 60) {
return diffMin + 'min';
} else if (diffHour < 24) {
return diffHour + 'h ' + remainingMin + 'min';
} else {
var diffDay = Math.floor(diffHour / 24);
return diffDay + 'd ' + (diffHour % 24) + 'h';
}
}
function renderActivityBar(score) { function renderActivityBar(score) {
if (score === null || score === undefined) { if (score === null || score === undefined) {
return '<span class="text-muted small">--</span>'; return '<span class="text-muted small">--</span>';