Files
stellars-jupyterhub-ds/services/jupyterhub/html_templates_enhanced/home.html

662 lines
30 KiB
HTML

{% extends "page.html" %}
{% if announcement_home is string %}
{% set announcement = announcement_home %}
{% endif %}
{% block main %}
<div class="container">
<h1 class="visually-hidden">JupyterHub home page</h1>
<div class="row">
<div class="text-center">
{% if default_server.active %}
<a id="stop" role="button" class="btn btn-lg btn-danger">
<i class="fa fa-stop" aria-hidden="true"></i>
Stop My Server
</a>
{% endif %}
<a id="start"
role="button"
class="btn btn-lg btn-primary"
href="{{ url }}">
<i class="fa fa-play" aria-hidden="true"></i>
{% if not default_server.active %}Start{% endif %}
My Server
</a>
{# Custom buttons: Restart Server when active, Manage Volumes when stopped #}
{% if default_server.active %}
<button id="restart-server-btn"
class="btn btn-lg btn-warning"
data-username="{{ user.name }}"
data-bs-toggle="modal"
data-bs-target="#restart-server-modal">
<i class="fa fa-rotate" aria-hidden="true"></i>
Restart Server
</button>
{% else %}
<button id="manage-volumes-btn"
class="btn btn-lg btn-secondary"
data-username="{{ user.name }}"
data-bs-toggle="modal"
data-bs-target="#manage-volumes-modal">
<i class="fa fa-database" aria-hidden="true"></i>
Manage Volumes
</button>
{% endif %}
</div>
</div>
{# Session Extension UI - only visible when culler enabled and server active #}
{% if idle_culler_enabled and default_server.active %}
<div class="row mt-4">
<div class="col-md-6 offset-md-3">
<div class="card" id="session-status-card">
<div class="card-body">
<h6 class="card-title mb-3">
<i class="fa fa-clock" aria-hidden="true"></i>
Idle Session Timeout
</h6>
<div id="session-loading" class="text-center py-2">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Loading session info...
</div>
<div id="session-info" class="d-none">
<p class="text-muted small mb-2">
Your server will be stopped after a period of inactivity to free up resources.
</p>
<div class="mb-3">
<span class="text-muted">Session expires in:</span>
<strong id="time-remaining" class="ms-2">--</strong>
</div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<label for="extend-hours" class="text-muted mb-0">Extend by:</label>
<input type="number" id="extend-hours" class="form-control form-control-sm"
style="width: 70px;" min="1" max="24" value="1">
<span class="text-muted">hour(s)</span>
<button id="extend-session-btn" class="btn btn-sm btn-outline-primary">
<i class="fa fa-plus" aria-hidden="true"></i>
Extend
</button>
</div>
<div class="mt-2">
<small class="text-muted" id="extension-allowance">
You can extend up to <span id="extensions-available">--</span> more hour(s)
</small>
</div>
</div>
<div id="session-error" class="d-none alert alert-warning mb-0 mt-2">
<small id="session-error-msg"></small>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% if allow_named_servers %}
<h2>Named Servers</h2>
<p>
In addition to your default server,
you may have additional
{% if named_server_limit_per_user > 0 %}{{ named_server_limit_per_user }}{% endif %}
server(s) with names.
This allows you to have more than one server running at the same time.
</p>
{% set named_spawners = user.all_spawners(include_default=False)|list %}
<table class="server-table table table-striped">
<thead>
<tr>
<th>Server name</th>
<th>URL</th>
<th>Last activity</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr class="home-server-row add-server-row">
<td colspan="4">
<div class="input-group">
<input class="new-server-name form-control"
aria-label="server name"
placeholder="name-your-server">
<button role="button"
type="button"
class="new-server-btn btn btn-xs btn-primary">Add New Server</button>
</div>
</td>
</tr>
{% for spawner in named_spawners %}
<tr class="home-server-row" data-server-name="{{ spawner.name }}">
{# name #}
<td>{{ spawner.name }}</td>
{# url #}
<td>
<a class="server-link {% if not spawner.ready %}hidden{% endif %}"
href="{{ user.server_url(spawner.name) }}">{{ user.server_url(spawner.name) }}</a>
</td>
{# activity #}
<td class='time-col'>
{% if spawner.last_activity %}
{{ spawner.last_activity.isoformat() + 'Z' }}
{% else %}
Never
{% endif %}
</td>
{# actions #}
<td>
<a role="button"
class="stop-server btn btn-xs btn-danger{% if not spawner.active %} hidden{% endif %}"
id="stop-{{ spawner.name }}">stop</a>
<a role="button"
class="start-server btn btn-xs btn-primary {% if spawner.active %}hidden{% endif %}"
id="start-{{ spawner.name }}"
href="{{ base_url }}spawn/{{ user.name }}/{{ spawner.name }}">start</a>
<button role="button"
class="delete-server btn btn-xs btn-danger{% if spawner.active %} hidden{% endif %}"
id="delete-{{ spawner.name }}">delete</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
<!-- Manage Volumes Modal -->
<div class="modal fade" id="manage-volumes-modal" tabindex="-1" role="dialog" aria-labelledby="manageVolumesModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="manageVolumesModalLabel">Manage Volumes</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-danger">
<strong>Warning:</strong> This action cannot be undone!
</div>
<p>Select which volumes to reset. All files in selected volumes will be permanently deleted:</p>
{% for volume_suffix in user_volume_suffixes %}
<div class="form-check mb-2">
<input class="form-check-input volume-checkbox" type="checkbox" id="volume-{{ volume_suffix }}" value="{{ volume_suffix }}">
<label class="form-check-label" for="volume-{{ volume_suffix }}">
<strong>{{ volume_suffix }}</strong>
<br>
<code class="text-muted">jupyterlab-{{ user.name }}_{{ volume_suffix }}</code>
{% if volume_descriptions and volume_suffix in volume_descriptions %}
<br>
<small class="text-muted">{{ volume_descriptions[volume_suffix] }}</small>
{% endif %}
</label>
</div>
{% endfor %}
<div id="volume-warning" class="alert alert-warning d-none">
<strong>Notice:</strong> <span id="volume-count">0</span> volume(s) selected for deletion.
</div>
<p class="mt-3"><strong>Are you sure you want to continue?</strong></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirm-reset-btn" disabled>
Reset Selected Volumes
</button>
</div>
</div>
</div>
</div>
<!-- Restart Server Modal -->
<div class="modal fade" id="restart-server-modal" tabindex="-1" role="dialog" aria-labelledby="restartServerModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="restartServerModalLabel">Restart Server</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<strong>Notice:</strong> Your server will be temporarily unavailable during restart.
</div>
<p>This will restart your JupyterLab container using Docker's native restart:</p>
<ul>
<li>Gracefully stops the container</li>
<li>Restarts the same container (does not recreate)</li>
<li>Preserves all volumes and configuration</li>
</ul>
<p class="mt-3"><strong>Any unsaved work in notebooks will be lost.</strong></p>
<p class="mt-2">Your files on disk are safe and will remain intact.</p>
<p><strong>Are you sure you want to restart?</strong></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-warning" id="confirm-restart-btn">
Yes, Restart Server
</button>
</div>
</div>
</div>
</div>
{% endblock main %}
{% block footer %}
<div class="mt-5 pt-5"></div>
<div class="py-2 px-4 bg-body-tertiary small version_footer">Stellars JupyterHub DS {{ stellars_version.split('_')[0] }} | JupyterHub {{ server_version }}</div>
{% endblock footer %}
{% block script %}
{{ super() }}
<script type="text/javascript">
require(["jquery", "home"], function($) {
// Version banner in browser console
console.log('[Stellars JupyterHub DS] Version:', '{{ stellars_version }}');
$(document).ready(function() {
const username = "{{ user.name }}";
console.log('[Custom Handlers] Page loaded, username:', username);
console.log('[Custom Handlers] Base URL:', '{{ base_url }}');
// Helper function to get cookie value
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
// Log when modals are triggered
$('#manage-volumes-btn').on('click', function() {
console.log('[Manage Volumes] Modal button clicked, opening confirmation dialog');
});
$('#restart-server-btn').on('click', function() {
console.log('[Restart Server] Modal button clicked, opening confirmation dialog');
});
// Get modal instances
const manageVolumesModalEl = document.getElementById('manage-volumes-modal');
const restartServerModalEl = document.getElementById('restart-server-modal');
// Log when modals are shown/hidden
if (manageVolumesModalEl) {
manageVolumesModalEl.addEventListener('show.bs.modal', function() {
console.log('[Manage Volumes] Modal dialog opened');
});
manageVolumesModalEl.addEventListener('hide.bs.modal', function() {
console.log('[Manage Volumes] Modal dialog closed');
});
}
// Handle volume checkbox changes
$('.volume-checkbox').on('change', function() {
const checkedCount = $('.volume-checkbox:checked').length;
const $confirmBtn = $('#confirm-reset-btn');
const $warning = $('#volume-warning');
const $count = $('#volume-count');
console.log('[Manage Volumes] Checkbox changed, selected count:', checkedCount);
// Enable/disable confirm button
$confirmBtn.prop('disabled', checkedCount === 0);
// Show/hide warning and update count
if (checkedCount > 0) {
$warning.removeClass('d-none');
$count.text(checkedCount);
} else {
$warning.addClass('d-none');
}
});
if (restartServerModalEl) {
restartServerModalEl.addEventListener('show.bs.modal', function() {
console.log('[Restart Server] Modal dialog opened');
});
restartServerModalEl.addEventListener('hide.bs.modal', function() {
console.log('[Restart Server] Modal dialog closed');
});
}
// Manage Volumes handler
$('#confirm-reset-btn').on('click', function() {
const apiUrl = `{{ base_url }}api/users/${username}/manage-volumes`;
const $btn = $(this);
// Collect selected volumes
const selectedVolumes = [];
$('.volume-checkbox:checked').each(function() {
selectedVolumes.push($(this).val());
});
console.log('[Manage Volumes] Confirm button clicked');
console.log('[Manage Volumes] Selected volumes:', selectedVolumes);
console.log('[Manage Volumes] API URL:', apiUrl);
console.log('[Manage Volumes] CSRF Token:', getCookie('_xsrf') ? 'present' : 'missing');
if (selectedVolumes.length === 0) {
console.warn('[Manage Volumes] No volumes selected');
return;
}
// Disable button and show loading state
$btn.prop('disabled', true).text('Resetting...');
console.log('[Manage Volumes] Sending DELETE request...');
$.ajax({
url: apiUrl,
type: 'DELETE',
headers: {
'X-XSRFToken': getCookie('_xsrf')
},
data: JSON.stringify({ volumes: selectedVolumes }),
contentType: 'application/json',
success: function(response) {
console.log('[Manage Volumes] API call successful');
console.log('[Manage Volumes] Response:', response);
const modal = bootstrap.Modal.getInstance(manageVolumesModalEl);
if (modal) modal.hide();
const resetCount = response.reset_volumes ? response.reset_volumes.length : selectedVolumes.length;
alert(`Successfully reset ${resetCount} volume(s). Selected volumes will be recreated on next server start.`);
console.log('[Manage Volumes] Reloading page...');
location.reload();
},
error: function(xhr) {
console.error('[Manage Volumes] API call failed');
console.error('[Manage Volumes] Status:', xhr.status);
console.error('[Manage Volumes] Response:', xhr.responseText);
const modal = bootstrap.Modal.getInstance(manageVolumesModalEl);
if (modal) modal.hide();
const errorMsg = xhr.responseJSON?.message || xhr.statusText || 'Failed to reset volumes';
alert(`Error: ${errorMsg}`);
$btn.prop('disabled', false).text('Reset Selected Volumes');
}
});
});
// Restart Server handler
$('#confirm-restart-btn').on('click', function() {
const apiUrl = `{{ base_url }}api/users/${username}/restart-server`;
const $btn = $(this);
console.log('[Restart Server] Confirm button clicked');
console.log('[Restart Server] API URL:', apiUrl);
console.log('[Restart Server] CSRF Token:', getCookie('_xsrf') ? 'present' : 'missing');
// Disable button and show loading state
$btn.prop('disabled', true).text('Restarting...');
console.log('[Restart Server] Sending POST request...');
$.ajax({
url: apiUrl,
type: 'POST',
headers: {
'X-XSRFToken': getCookie('_xsrf')
},
success: function(response) {
console.log('[Restart Server] API call successful');
console.log('[Restart Server] Response:', response);
const modal = bootstrap.Modal.getInstance(restartServerModalEl);
if (modal) modal.hide();
alert('Server successfully restarted. Page will refresh.');
console.log('[Restart Server] Refreshing page...');
location.reload();
},
error: function(xhr) {
console.error('[Restart Server] API call failed');
console.error('[Restart Server] Status:', xhr.status);
console.error('[Restart Server] Response:', xhr.responseText);
const modal = bootstrap.Modal.getInstance(restartServerModalEl);
if (modal) modal.hide();
const errorMsg = xhr.responseJSON?.message || xhr.statusText || 'Failed to restart server';
alert(`Error: ${errorMsg}`);
$btn.prop('disabled', false).text('Yes, Restart Server');
}
});
});
// Add page refresh after Stop Server completes
// Watch for when JupyterHub's home.js modifies the DOM after stop
console.log('[Stop Server] Setting up DOM observer');
const targetNode = document.querySelector('.text-center');
if (targetNode) {
const observer = new MutationObserver(function(mutations) {
// Check if stop button was hidden (JupyterHub uses .hide() which sets display:none)
const stopButton = document.getElementById('stop');
const startButton = document.getElementById('start');
// Check visibility using offsetParent (null = hidden)
const stopVisible = stopButton && stopButton.offsetParent !== null;
const startVisible = startButton && startButton.offsetParent !== null;
console.log('[Stop Server] Mutation detected - stop visible:', stopVisible, 'start visible:', startVisible);
if (!stopVisible && startVisible) {
// Check if start button text was changed by JupyterHub (indicates stop completed)
const startText = startButton.textContent.trim();
console.log('[Stop Server] Start button text:', startText);
if (startText === 'Start My Server') {
console.log('[Stop Server] JupyterHub updated DOM after stop, updating UI and refreshing...');
observer.disconnect();
// Re-inject the play icon that JupyterHub removed
const iconHtml = '<i class="fa fa-play" aria-hidden="true"></i> Start My Server';
$(startButton).html(iconHtml);
// Hide Restart Server button and show Manage Volumes button
$('#restart-server-btn').hide();
$('#manage-volumes-btn').show();
console.log('[Stop Server] Updated button visibility - showing Manage Volumes');
setTimeout(function() {
console.log('[Stop Server] Refreshing now');
location.reload();
}, 1000); // Give user a moment to see the corrected button state
}
}
});
observer.observe(targetNode, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class'],
characterData: true
});
console.log('[Stop Server] Observer attached to button container');
}
// Debug: Check if buttons exist
console.log('[Custom Handlers] Restart button exists:', $('#restart-server-btn').length > 0);
console.log('[Custom Handlers] Manage volumes button exists:', $('#manage-volumes-btn').length > 0);
console.log('[Custom Handlers] Confirm restart button exists:', $('#confirm-restart-btn').length > 0);
console.log('[Custom Handlers] Confirm reset button exists:', $('#confirm-reset-btn').length > 0);
console.log('[Custom Handlers] Stop button exists:', $('#stop').length > 0);
// ============================================================
// Session Extension Feature
// ============================================================
const $sessionCard = $('#session-status-card');
const $sessionLoading = $('#session-loading');
const $sessionInfo = $('#session-info');
const $sessionError = $('#session-error');
const $sessionErrorMsg = $('#session-error-msg');
const $timeRemaining = $('#time-remaining');
const $extensionsAvailable = $('#extensions-available');
const $extendBtn = $('#extend-session-btn');
const $extendHours = $('#extend-hours');
// Only initialize if session card exists (culler enabled + server active)
if ($sessionCard.length > 0) {
console.log('[Session Extension] Initializing session extension feature');
let sessionUpdateInterval = null;
let currentSessionInfo = null;
// Format seconds to human readable string
function formatTimeRemaining(seconds) {
if (seconds <= 0) {
return 'Session may be culled soon';
}
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
// Update the UI with session info
function updateSessionUI(info) {
currentSessionInfo = info;
if (!info.culler_enabled || !info.server_active) {
$sessionCard.hide();
return;
}
$sessionLoading.addClass('d-none');
$sessionInfo.removeClass('d-none');
// Update time remaining
const timeRemainingSeconds = info.time_remaining_seconds || 0;
$timeRemaining.text(formatTimeRemaining(timeRemainingSeconds));
// Style based on time remaining
if (timeRemainingSeconds < 1800) { // Less than 30 minutes
$timeRemaining.removeClass('text-warning').addClass('text-danger');
} else if (timeRemainingSeconds < 3600) { // Less than 1 hour
$timeRemaining.removeClass('text-danger').addClass('text-warning');
} else {
$timeRemaining.removeClass('text-danger text-warning');
}
// Update extensions available
const available = info.extensions_available_hours || 0;
$extensionsAvailable.text(available);
// No static warning when max reached - backend shows disappearing error on click
console.log('[Session Extension] UI updated - remaining:', timeRemainingSeconds, 'available ext:', available);
}
// Fetch session info from API
function fetchSessionInfo() {
const apiUrl = `{{ base_url }}api/users/${username}/session-info`;
console.log('[Session Extension] Fetching session info from:', apiUrl);
$.ajax({
url: apiUrl,
type: 'GET',
headers: {
'X-XSRFToken': getCookie('_xsrf')
},
success: function(response) {
console.log('[Session Extension] Session info received:', response);
$sessionError.addClass('d-none');
updateSessionUI(response);
},
error: function(xhr) {
console.error('[Session Extension] Failed to fetch session info:', xhr.status, xhr.responseText);
$sessionLoading.addClass('d-none');
$sessionError.removeClass('d-none');
$sessionErrorMsg.text('Unable to load session info');
}
});
}
// Handle extend session button click
$extendBtn.on('click', function() {
const hours = parseInt($extendHours.val()) || 0;
const apiUrl = `{{ base_url }}api/users/${username}/extend-session`;
// Validate input
if (hours < 1) {
$sessionError.removeClass('d-none');
$sessionErrorMsg.text('Please enter at least 1 hour');
return;
}
console.log('[Session Extension] Extending session by', hours, 'hour(s)');
// Disable button during request
$extendBtn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm" role="status"></span> Extending...');
$.ajax({
url: apiUrl,
type: 'POST',
headers: {
'X-XSRFToken': getCookie('_xsrf')
},
data: JSON.stringify({ hours: hours }),
contentType: 'application/json',
success: function(response) {
console.log('[Session Extension] Extension successful:', response);
$extendBtn.prop('disabled', false).html('<i class="fa fa-plus" aria-hidden="true"></i> Extend');
if (response.success) {
// Update UI with new session info
if (response.session_info) {
updateSessionUI({
culler_enabled: true,
server_active: true,
time_remaining_seconds: response.session_info.time_remaining_seconds,
extensions_used_hours: response.session_info.extensions_used_hours,
extensions_available_hours: response.session_info.extensions_available_hours
});
}
// Show feedback - warning if truncated, success otherwise
const alertClass = response.truncated ? 'alert-warning' : 'alert-success';
$sessionError.removeClass('d-none alert-warning alert-success').addClass(alertClass);
$sessionErrorMsg.text(response.message);
setTimeout(function() {
$sessionError.addClass('d-none').removeClass('alert-success alert-warning');
fetchSessionInfo(); // Refresh to get accurate time
}, response.truncated ? 4000 : 2000); // Show warning longer
}
},
error: function(xhr) {
console.error('[Session Extension] Extension failed:', xhr.status, xhr.responseText);
$extendBtn.prop('disabled', false).html('<i class="fa fa-plus" aria-hidden="true"></i> Extend');
const errorMsg = xhr.responseJSON?.error || 'Failed to extend session';
// Show as warning alert that disappears after 4 seconds
$sessionError.removeClass('d-none alert-success').addClass('alert-warning');
$sessionErrorMsg.text(errorMsg);
setTimeout(function() {
$sessionError.addClass('d-none');
}, 4000);
}
});
});
// Initial fetch
fetchSessionInfo();
// Update every 60 seconds
sessionUpdateInterval = setInterval(function() {
// Decrement local counter between fetches
if (currentSessionInfo && currentSessionInfo.time_remaining_seconds > 0) {
currentSessionInfo.time_remaining_seconds = Math.max(0, currentSessionInfo.time_remaining_seconds - 60);
$timeRemaining.text(formatTimeRemaining(currentSessionInfo.time_remaining_seconds));
// Update styling
if (currentSessionInfo.time_remaining_seconds < 1800) {
$timeRemaining.removeClass('text-warning').addClass('text-danger');
} else if (currentSessionInfo.time_remaining_seconds < 3600) {
$timeRemaining.removeClass('text-danger').addClass('text-warning');
}
}
}, 60000);
// Refresh from server every 5 minutes
setInterval(fetchSessionInfo, 300000);
}
});
});
</script>
{% endblock script %}