Files
stellars-jupyterhub-ds/services/jupyterhub/html_templates_enhanced/activity.html
stellarshenson 9162de622f fix: volume sizes logging and tooltip cleanup
- Add _get_logger() helper for JupyterHub application logger integration
- Module-level logger doesn't inherit JupyterHub handlers, causing
  invisible logs for VolumeSizeRefresher and Volume Sizes operations
- Update all volume-related functions to use _get_logger()
- Add cache validation to prevent empty results overwriting valid cache
- Remove "(>8h/day)" suffix from activity tooltip when score > 100%
2026-01-26 12:57:32 +01:00

470 lines
17 KiB
HTML

{% extends "page.html" %}
{% block main %}
<div class="container">
<h1>User Activity Monitor</h1>
<p class="text-muted">Real-time resource usage and engagement metrics</p>
<hr>
<!-- Loading indicator -->
<div id="loading-indicator" class="text-center py-4">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="ms-2">Loading activity data...</span>
</div>
<!-- No data message -->
<div id="no-data-message" style="display: none;">
<div class="alert alert-info">
<i class="fa fa-info-circle" aria-hidden="true"></i>
No active user servers found. Start a server to see activity data.
</div>
</div>
<!-- Activity table -->
<div id="activity-table-container" style="display: none;">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<span class="badge bg-dark" id="active-count"></span>
</div>
<div>
<button class="btn btn-outline-danger btn-sm me-2" id="reset-btn">
<i class="fa fa-trash" aria-hidden="true"></i> Reset
</button>
<button class="btn btn-outline-secondary btn-sm" id="refresh-btn">
<i class="fa fa-refresh" aria-hidden="true"></i> Refresh
</button>
</div>
</div>
<table class="table table-sm table-striped table-hover activity-table">
<thead class="table-dark">
<tr>
<th class="col-user sortable" data-sort="username" title="JupyterHub username">User <span class="sort-icon"></span></th>
<th class="col-auth text-center sortable" data-sort="is_authorized" title="Authorization status in NativeAuthenticator">Auth <span class="sort-icon"></span></th>
<th class="col-status text-center sortable" data-sort="status" title="Server status: green=active, yellow=idle, red=offline">Status <span class="sort-icon"></span></th>
<th class="col-cpu text-center sortable" data-sort="cpu_percent" title="Current CPU usage of user container">CPU <span class="sort-icon"></span></th>
<th class="col-memory text-center sortable" data-sort="memory_mb" title="Current memory usage of user container">Memory <span class="sort-icon"></span></th>
<th class="col-volumes text-center sortable" data-sort="volume_size_mb" title="Total size of user volumes (hover for breakdown)">Volumes <span class="sort-icon"></span></th>
<th class="col-timeleft text-center sortable" data-sort="time_remaining_seconds" title="Time until idle culler stops the server">Time Left <span class="sort-icon"></span></th>
<th class="col-lastactive sortable" data-sort="last_activity" title="Time since last JupyterLab activity">Last Active <span class="sort-icon"></span></th>
<th class="col-activity sortable" data-sort="activity_score" title="Activity score based on exponential decay (hover bar for %)">Activity <span class="sort-icon"></span></th>
</tr>
</thead>
<tbody id="activity-table-body">
<!-- Populated by JavaScript -->
</tbody>
</table>
</div>
</div>
<script>
require(["jquery"], function($) {
"use strict";
// Target hours for 100% activity score (from config)
var targetHours = {{ activitymon_target_hours | default(8) }};
var normalizationFactor = targetHours / 24; // e.g., 8/24 = 0.333
// Minimum samples required for 24h of data
var sampleInterval = {{ activitymon_sample_interval | default(600) }}; // seconds
var minSamplesFor24h = Math.ceil(86400 / sampleInterval); // e.g., 86400/600 = 144 samples
var autoRefreshInterval = null;
var currentUsersData = []; // Store data for re-sorting
var sortColumn = null; // Current sort column
var sortDirection = null; // 'asc', 'desc', or null
// Initial fetch
fetchActivityData();
// Sorting click handlers
$('th.sortable').on('click', function() {
var column = $(this).data('sort');
// Cycle through: none -> desc -> asc -> none
if (sortColumn !== column) {
sortColumn = column;
sortDirection = 'desc';
} else if (sortDirection === 'desc') {
sortDirection = 'asc';
} else if (sortDirection === 'asc') {
sortColumn = null;
sortDirection = null;
}
updateSortIcons();
renderTableRows();
});
function updateSortIcons() {
$('th.sortable .sort-icon').html('');
if (sortColumn && sortDirection) {
var icon = sortDirection === 'asc' ? '<i class="fa fa-sort-up"></i>' : '<i class="fa fa-sort-down"></i>';
$('th.sortable[data-sort="' + sortColumn + '"] .sort-icon').html(icon);
}
}
// Auto-refresh resources every 10 seconds (status, CPU, memory, timers)
autoRefreshInterval = setInterval(fetchActivityData, 10000);
// Manual refresh button
$('#refresh-btn').on('click', function() {
var btn = $(this);
btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>');
fetchActivityData(function() {
btn.prop('disabled', false).html('<i class="fa fa-refresh" aria-hidden="true"></i> Refresh');
});
});
// Reset button
$('#reset-btn').on('click', function() {
if (!confirm('Reset all activity data? This will clear all historical samples.')) {
return;
}
var btn = $(this);
btn.prop('disabled', true);
$.ajax({
url: '{{ base_url }}api/activity/reset',
method: 'POST',
headers: {
'X-XSRFToken': getCookie('_xsrf')
},
success: function(data) {
btn.prop('disabled', false);
fetchActivityData();
},
error: function(xhr) {
btn.prop('disabled', false);
alert('Failed to reset activity data');
}
});
});
function fetchActivityData(callback) {
$.ajax({
url: '{{ base_url }}api/activity',
method: 'GET',
headers: {
'X-XSRFToken': getCookie('_xsrf')
},
success: function(data) {
renderActivityTable(data);
if (callback) callback();
},
error: function(xhr) {
$('#loading-indicator').hide();
$('#activity-table-container').hide();
$('#no-data-message').show().find('.alert')
.removeClass('alert-info')
.addClass('alert-danger')
.html('<i class="fa fa-exclamation-circle"></i> Failed to load activity data');
if (callback) callback();
}
});
}
function renderActivityTable(data) {
var users = data.users || [];
$('#loading-indicator').hide();
if (users.length === 0) {
$('#activity-table-container').hide();
$('#no-data-message').show();
return;
}
$('#no-data-message').hide();
$('#activity-table-container').show();
// Update header info - count users by status
var activeCount = users.filter(function(u) { return u.server_active && u.recently_active; }).length;
var idleCount = users.filter(function(u) { return u.server_active && !u.recently_active; }).length;
var offlineCount = users.filter(function(u) { return !u.server_active; }).length;
var badgeText = users.length + ' user' + (users.length !== 1 ? 's' : '') +
' (' + activeCount + ' active, ' + idleCount + ' idle, ' + offlineCount + ' offline)';
$('#active-count').text(badgeText);
// Store data for sorting
currentUsersData = users;
renderTableRows();
}
function renderTableRows() {
if (currentUsersData.length === 0) return;
// Copy array for sorting
var users = currentUsersData.slice();
// Helper to get sortable value for a column
function getSortValue(user, column, direction) {
// Status sorting: active > inactive > offline (higher = better)
// Must be checked first since 'status' is not a real field
if (column === 'status') {
if (user.server_active && user.recently_active) return 2; // green - active
if (user.server_active) return 1; // amber - inactive
return 0; // red - offline
}
// Authorization sorting: authorized (1) > not authorized (0)
if (column === 'is_authorized') {
return user.is_authorized ? 1 : 0;
}
var val = user[column];
// Handle null/undefined - push to end regardless of direction
if (val === null || val === undefined) {
return direction === 'asc' ? Infinity : -Infinity;
}
// String comparison for username
if (column === 'username') {
return (val || '').toLowerCase();
}
// Date comparison for last_activity
if (column === 'last_activity') {
return val ? new Date(val).getTime() : (direction === 'asc' ? Infinity : -Infinity);
}
return val;
}
// Apply sorting with secondary sort by username
users.sort(function(a, b) {
var column = sortColumn || 'status';
var direction = sortDirection || 'desc';
var aVal = getSortValue(a, column, direction);
var bVal = getSortValue(b, column, direction);
var result = 0;
if (aVal < bVal) result = direction === 'asc' ? -1 : 1;
else if (aVal > bVal) result = direction === 'asc' ? 1 : -1;
// Secondary sort by username (always ascending) when primary values are equal
if (result === 0 && column !== 'username') {
var aName = (a.username || '').toLowerCase();
var bName = (b.username || '').toLowerCase();
if (aName < bName) result = -1;
else if (aName > bName) result = 1;
}
return result;
});
// Build table rows
var tbody = $('#activity-table-body');
tbody.empty();
users.forEach(function(user) {
var row = '<tr>' +
'<td><strong>' + escapeHtml(user.username) + '</strong></td>' +
'<td class="text-center">' + formatAuth(user.is_authorized) + '</td>' +
'<td class="text-center">' + formatStatus(user.server_active, user.recently_active) + '</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">' + formatVolumeSize(user.volume_size_mb, user.volume_breakdown) + '</td>' +
'<td class="text-center">' + formatTimeRemaining(user.time_remaining_seconds) + '</td>' +
'<td>' + formatLastActive(user.last_activity) + '</td>' +
'<td>' + renderActivityBar(user.activity_score, user.sample_count) + '</td>' +
'</tr>';
tbody.append(row);
});
}
function formatStatus(serverActive, recentlyActive) {
// Green: server running AND recently active
// Yellow: server running BUT inactive
// Red: server offline
if (serverActive && recentlyActive) {
return '<span title="Online (active)"><i class="fa fa-circle" style="color: #28a745;" aria-hidden="true"></i></span>';
} else if (serverActive) {
return '<span title="Online (inactive)"><i class="fa fa-circle text-warning" aria-hidden="true"></i></span>';
} else {
return '<span title="Offline"><i class="fa fa-circle text-danger" aria-hidden="true"></i></span>';
}
}
function formatAuth(isAuthorized) {
if (isAuthorized) {
return '<span title="Authorized"><i class="fa fa-check" style="color: #28a745;" aria-hidden="true"></i></span>';
} else {
return '<span title="Not authorized"><i class="fa fa-times text-danger" aria-hidden="true"></i></span>';
}
}
function formatCpu(cpuPercent) {
if (cpuPercent === null || cpuPercent === undefined) {
return '<span class="text-muted">--</span>';
}
return cpuPercent.toFixed(1) + '%';
}
function formatMemory(memoryMb, memoryPercent) {
if (memoryMb === null || memoryMb === undefined) {
return '<span class="text-muted">--</span>';
}
if (memoryMb >= 1024) {
return (memoryMb / 1024).toFixed(1) + ' GB';
}
return memoryMb.toFixed(0) + ' MB';
}
function formatVolumeSize(sizeMb, breakdown) {
if (sizeMb === null || sizeMb === undefined || sizeMb === 0) {
return '<span class="text-muted">--</span>';
}
// Format total size
var displaySize;
if (sizeMb >= 1024) {
displaySize = (sizeMb / 1024).toFixed(1) + ' GB';
} else {
displaySize = Math.round(sizeMb) + ' MB';
}
// Build tooltip with breakdown
if (breakdown && Object.keys(breakdown).length > 0) {
var tooltipLines = [];
// Sort volumes by name for consistent display
var sortedKeys = Object.keys(breakdown).sort();
sortedKeys.forEach(function(suffix) {
var volSize = breakdown[suffix];
var volDisplay;
if (volSize >= 1024) {
volDisplay = (volSize / 1024).toFixed(1) + ' GB';
} else {
volDisplay = Math.round(volSize) + ' MB';
}
tooltipLines.push(suffix + ': ' + volDisplay);
});
var tooltip = tooltipLines.join('&#10;');
return '<span title="' + tooltip + '" style="cursor: help; border-bottom: 1px dotted currentColor;">' + displaySize + '</span>';
}
return displaySize;
}
function formatTimeRemaining(seconds) {
if (seconds === null || seconds === undefined) {
return '<span class="text-muted">--</span>';
}
if (seconds <= 0) {
return '<span class="text-danger">Expiring...</span>';
}
var hours = Math.floor(seconds / 3600);
var minutes = Math.floor((seconds % 3600) / 60);
if (hours >= 24) {
var days = Math.floor(hours / 24);
hours = hours % 24;
return days + 'd ' + hours + 'h';
} else if (hours > 0) {
return hours + 'h ' + minutes + 'm';
} else {
return minutes + 'm';
}
}
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 diffDay = Math.floor(diffHour / 24);
var diffMonth = Math.floor(diffDay / 30);
if (diffMin < 1) {
return '<span class="text-success">now</span>';
} else if (diffMin < 60) {
return diffMin + (diffMin === 1 ? ' minute ago' : ' minutes ago');
} else if (diffHour < 24) {
return diffHour + (diffHour === 1 ? ' hour ago' : ' hours ago');
} else if (diffDay < 30) {
return diffDay + (diffDay === 1 ? ' day ago' : ' days ago');
} else {
return diffMonth + (diffMonth === 1 ? ' month ago' : ' months ago');
}
}
function renderActivityBar(rawScore, sampleCount) {
if (rawScore === null || rawScore === undefined) {
return '<span class="text-muted small">--</span>';
}
// Check if we have enough data (at least 24h of samples)
var hasEnoughData = sampleCount >= minSamplesFor24h;
// Normalize score based on target hours (e.g., 8h/day = 100%)
// Raw score is % of sampled time active (8h/24h ≈ 33% for 8h worker)
// Normalized score = raw / (target/24) -> 33% / 0.333 = 100%
var normalizedScore = Math.round(rawScore / normalizationFactor);
// Cap bar display at 100% (5 segments) but show real % in tooltip
var displayScore = Math.min(normalizedScore, 100);
// Calculate number of lit segments (0-5) based on capped score
var litSegments = Math.ceil(displayScore / 20); // 0-20=1, 21-40=2, 41-60=3, 61-80=4, 81-100=5
if (displayScore === 0) litSegments = 0;
// Color based on lit segments: 1=red, 2-3=yellow, 4-5=green
var segmentColor;
if (litSegments >= 4) {
segmentColor = '#28a745'; // Green - high activity (4-5)
} else if (litSegments >= 2) {
segmentColor = '#ffc107'; // Yellow - medium activity (2-3)
} else {
segmentColor = '#dc3545'; // Red - low activity (1)
}
// Tooltip: show % only if we have enough data, otherwise "not enough data"
var tooltipText;
if (hasEnoughData) {
tooltipText = 'Activity: ' + normalizedScore + '%';
} else {
var hoursCollected = Math.round((sampleCount * sampleInterval) / 3600);
tooltipText = 'Not enough data (' + hoursCollected + 'h of 24h collected)';
}
// Thin continuous bar with dividers
var html = '<div style="display: flex; width: 80px; height: 8px; border: 1px solid #6c757d; border-radius: 3px; overflow: hidden;" title="' + tooltipText + '">';
for (var i = 0; i < 5; i++) {
var isLit = i < litSegments;
var borderRight = i < 4 ? 'border-right: 1px solid #6c757d;' : '';
if (isLit) {
html += '<div style="flex: 1; background-color: ' + segmentColor + '; ' + borderRight + '"></div>';
} else {
html += '<div style="flex: 1; background-color: transparent; ' + borderRight + '"></div>';
}
}
html += '</div>';
return html;
}
function getCookie(name) {
var match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
if (match) return match[2];
}
function escapeHtml(text) {
if (text === null || text === undefined) return '';
var map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return String(text).replace(/[&<>"']/g, function(m) { return map[m]; });
}
});
</script>
{% endblock %}