mirror of
https://github.com/stellarshenson/stellars-jupyterhub-ds.git
synced 2026-03-09 22:50:29 +00:00
- 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%
470 lines
17 KiB
HTML
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(' ');
|
|
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 = {
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": '''
|
|
};
|
|
return String(text).replace(/[&<>"']/g, function(m) { return map[m]; });
|
|
}
|
|
});
|
|
</script>
|
|
{% endblock %}
|