Files
stellars-jupyterhub-ds/services/jupyterhub/html_templates_enhanced/activity.html
stellarshenson 8980e552cf refactor: simplify Activity column header
Removed "(7 days)" suffix from Activity column header in activity.html.
The retention period is a configuration detail, not needed in the UI.
2026-01-25 11:48:49 +01:00

427 lines
14 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">User <span class="sort-icon"></span></th>
<th class="col-status text-center sortable" data-sort="status">Status <span class="sort-icon"></span></th>
<th class="col-cpu text-center sortable" data-sort="cpu_percent">CPU <span class="sort-icon"></span></th>
<th class="col-memory text-center sortable" data-sort="memory_mb">Memory <span class="sort-icon"></span></th>
<th class="col-volumes text-center sortable" data-sort="volume_size_mb">Volumes <span class="sort-icon"></span></th>
<th class="col-timeleft text-center sortable" data-sort="time_remaining_seconds">Time Left <span class="sort-icon"></span></th>
<th class="col-lastactive sortable" data-sort="last_activity">Last Active <span class="sort-icon"></span></th>
<th class="col-activity sortable" data-sort="activity_score">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";
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
}
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">' + 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) + '</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 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(score) {
if (score === null || score === undefined) {
return '<span class="text-muted small">--</span>';
}
// Calculate number of lit segments (0-5) based on score (0-100)
var litSegments = Math.ceil(score / 20); // 0-20=1, 21-40=2, 41-60=3, 61-80=4, 81-100=5
if (score === 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)
}
// 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="Activity: ' + score + '%">';
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 %}