From 03f66995b023648ebea93ac154b12faaff1dfacc Mon Sep 17 00:00:00 2001 From: Tim Kreuzer <t.kreuzer@fz-juelich.de> Date: Mon, 3 Feb 2025 09:26:17 +0100 Subject: [PATCH] update frontend --- static/images/workshop/login_01.png | Bin 0 -> 47768 bytes static/images/workshop/partition_01.png | Bin 0 -> 63853 bytes static/images/workshop/project_01.png | Bin 0 -> 22663 bytes static/js/home/dropdown-options.js | 586 ----- static/js/home/handle-events.js | 451 ---- static/js/home/handle-servers.js | 505 ----- static/js/home/lab-configs.js | 383 ---- static/js/home/utils.js | 280 --- static/js/jhapi.js | 17 +- templates/footer.html | 64 +- templates/header.html | 3 + templates/home.html | 516 +---- templates/macros/home.jinja | 332 ++- templates/macros/svgs.jinja | 5 + templates/macros/table/config/home.jinja | 722 +++++++ templates/macros/table/config/workshop.jinja | 667 ++++++ .../table/config/workshop_manager.jinja | 775 +++++++ templates/macros/table/content.jinja | 362 ++++ templates/macros/table/elements.jinja | 836 ++++++++ templates/macros/table/elements_js.jinja | 646 ++++++ .../macros/table/helpers/options_js.jinja | 88 + .../macros/table/helpers/systems_js.jinja | 630 ++++++ templates/macros/table/table.jinja | 149 ++ templates/macros/table/table_js.jinja | 1884 +++++++++++++++++ templates/macros/table/variables.jinja | 5 + templates/page.html | 40 +- templates/spawn_pending.html | 312 +-- templates/spawn_pending_prev.html | 310 +++ templates/token.html | 1 + templates/workshop.html | 44 + templates/workshop_list.html | 239 +++ templates/workshop_manager.html | 38 + 32 files changed, 7685 insertions(+), 3205 deletions(-) create mode 100644 static/images/workshop/login_01.png create mode 100644 static/images/workshop/partition_01.png create mode 100644 static/images/workshop/project_01.png delete mode 100644 static/js/home/dropdown-options.js delete mode 100644 static/js/home/handle-events.js delete mode 100644 static/js/home/handle-servers.js delete mode 100644 static/js/home/lab-configs.js delete mode 100644 static/js/home/utils.js create mode 100644 templates/macros/table/config/home.jinja create mode 100644 templates/macros/table/config/workshop.jinja create mode 100644 templates/macros/table/config/workshop_manager.jinja create mode 100644 templates/macros/table/content.jinja create mode 100644 templates/macros/table/elements.jinja create mode 100644 templates/macros/table/elements_js.jinja create mode 100644 templates/macros/table/helpers/options_js.jinja create mode 100644 templates/macros/table/helpers/systems_js.jinja create mode 100644 templates/macros/table/table.jinja create mode 100644 templates/macros/table/table_js.jinja create mode 100644 templates/macros/table/variables.jinja create mode 100644 templates/spawn_pending_prev.html create mode 100644 templates/workshop.html create mode 100644 templates/workshop_list.html create mode 100644 templates/workshop_manager.html diff --git a/static/images/workshop/login_01.png b/static/images/workshop/login_01.png new file mode 100644 index 0000000000000000000000000000000000000000..77452370e8ce430fd2170911d4f59b32263ca695 GIT binary patch literal 47768 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%0Dxja#M}1y6hrjds~-E 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 XH<RcE^BWi#7#KWV{an^LB{Ts52u>T} literal 0 HcmV?d00001 diff --git a/static/images/workshop/partition_01.png b/static/images/workshop/partition_01.png new file mode 100644 index 0000000000000000000000000000000000000000..73385950db63ade4573a2bfc334462f28606fc95 GIT binary patch literal 63853 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 literal 0 HcmV?d00001 diff --git a/static/images/workshop/project_01.png b/static/images/workshop/project_01.png new file mode 100644 index 0000000000000000000000000000000000000000..1cb770ae5b6f90ca54024eb608f56c3d602fe69d GIT binary patch literal 22663 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 literal 0 HcmV?d00001 diff --git a/static/js/home/dropdown-options.js b/static/js/home/dropdown-options.js deleted file mode 100644 index eb50a40..0000000 --- a/static/js/home/dropdown-options.js +++ /dev/null @@ -1,586 +0,0 @@ -/* -* This module takes care of updating the user options, which are received from the backend and shown on the UI -*/ -define(["jquery", "home/utils"], function ( - $, - utils -) { - "use strict"; - - var updateServices = function (software, id, value=false) { - const serviceInfo = getServiceInfo(); - - let select = $(`select#${id}-version-select`); - const currentVal = select.val(); - resetInputElement(select); - - const options = (serviceInfo[software].options || {}); - for (const [serviceName, serviceInfo] of Object.entries(options)) { - var displayName = serviceInfo.name; - select.append(`<option value="${serviceName}">${displayName}</option>`); - } - if (!value) value = serviceInfo.JupyterLab.defaultOption; - updateLabConfigSelect(select, value, currentVal); - } - - var updateServicesPrev = function (id, value) { - const dropdownOptions = getDropdownOptions(); - const serviceInfo = getServiceInfo(); - - let select = $(`select#${id}-version-select`); - const currentVal = select.val(); - resetInputElement(select); - var valueName = (serviceInfo.JupyterLab.options[value] || {}).name || "new-jupyterlab"; - for (const service of Object.keys(dropdownOptions).sort().reverse()) { - var serviceName = (serviceInfo.JupyterLab.options[service] || {}).name || service; - if ( valueName.includes("deprecated") || ! serviceName.includes("deprecated")) { - select.append(`<option value="${service}">${serviceName}</option>`); - } - } - if (!value) value = serviceInfo.JupyterLab.defaultOption; - updateLabConfigSelect(select, value, currentVal); - } - - var updateSystems = function (id, service, value) { - const dropdownOptions = getDropdownOptions(); - const systemInfo = getSystemInfo(); - - let select = $(`select#${id}-system-select`); - const currentVal = select.val(); - resetInputElement(select); - - const systemsAllowed = dropdownOptions[service] || {}; - for (const system of Object.keys(systemInfo).sort((a, b) => (systemInfo[a]["weight"] || 99) < (systemInfo[b]["weight"] || 99) ? -1 : 1)) { - if (system in systemsAllowed) select.append(`<option value="${system}">${system}</option>`); - } - updateLabConfigSelect(select, value, currentVal); - } - - var updateFlavors = function (id, service, system, value) { - const systemInfo = getSystemInfo(); - const backendInfo = getBackendServiceInfo(); - - let select = $(`select#${id}-flavor-select`); - const currentVal = select.val(); - - resetInputElement(select); - if ($(`#${id}-na-info`).length && $(`#${id}-na-info`).html().includes("flavor")) { - $(`#${id}-na-btn`).hide(); - $(`#${id}-na-info`).empty().hide(); - if (!window.spawnActive[id]) - $(`#${id}-start-btn`).removeClass("disabled").show(); - } - - let systemFlavors = window.flavorInfo[system]; - if (!systemFlavors) { - // Check if system should have flavor info but doesn't first - let backend = (systemInfo[system] || {}).backendService; - if (backend && (backendInfo[backend].flavorsRequired || backendInfo[backend].userflavors)) { - // If so, we still want to create the flavor info to show the error message - utils.createFlavorInfo(id, system); - utils.setLabAsNA(id, "due to flavor"); - $(`#${id}-flavor-select-div, #${id}-flavor-legend-div, #${id}-flavor-info-div`).show(); - } - else { - // Otherwise, we can just skip showing the flavor info entirely - $(`#${id}-flavor-select-div, #${id}-flavor-legend-div, #${id}-flavor-info-div`).hide(); - } - updateLabConfigSelect(select, value, currentVal); - return; - }; - - // Sort systemFlavors by flavor weights - for (const [flavor, description] of Object.entries(systemFlavors).sort(([, a], [, b]) => (a["weight"] || 99) < (b["weight"] || 99) ? 1 : -1)) { - // Flavor not valid, so skip - if (description.max == 0 || description.current < 0 || description.max == null || description.current == null) continue; - if (description.max == -1 || description.current < description.max) - select.append(`<option value="${flavor}">${description.display_name}</option>`); - } - utils.createFlavorInfo(id, system); - enableTooltips(); // Defined in page.html - $.isEmptyObject(systemFlavors) ? $(`#${id}-flavor-select-div, #${id}-flavor-legend-div, #${id}-flavor-info-div`).hide() : $(`#${id}-flavor-select-div, #${id}-flavor-legend-div, #${id}-flavor-info-div`).show(); - - if (select.html() == "") { - if (window.spawnActive[id]) { - // Lab is active, so we should still append the current flavor to the select - const flavor = window.userOptions[id].flavor; - const description = (systemFlavors[system] || {})[flavor] || {}; - if (flavor) { - select.append(`<option disabled value="${flavor}">${description.display_name || flavor}</option>`); - } - } - else { - // Show info text and disable start - select.append(`<option disabled value="">No flavors currently available</option>`); - utils.setLabAsNA(id, "due to flavor limits"); - } - - select.addClass("disabled"); - select.prop("selectedIndex", 0); - } - updateLabConfigSelect(select, value, currentVal); - } - - var updateAccounts = function (id, service, system, value) { - const dropdownOptions = getDropdownOptions(); - - let select = $(`select#${id}-account-select`); - const currentVal = select.val(); - resetInputElement(select); - - const accountsAllowed = (dropdownOptions[service] || {})[system] || {}; - for (const account of Object.keys(accountsAllowed).sort()) { - select.append(`<option value="${account}">${account}</option>`); - } - $.isEmptyObject(accountsAllowed) ? $(`#${id}-account-select-div`).hide() : $(`#${id}-account-select-div`).show(); - updateLabConfigSelect(select, value, currentVal); - } - - var updateProjects = function (id, service, system, account, value) { - const dropdownOptions = getDropdownOptions(); - - let select = $(`select#${id}-project-select`); - const currentVal = select.val(); - resetInputElement(select); - - const projectsAllowed = ((dropdownOptions[service] || {})[system] || {})[account] || {}; - for (const project of Object.keys(projectsAllowed).sort()) { - select.append(`<option value="${project}">${project}</option>`); - } - $.isEmptyObject(projectsAllowed) ? $(`#${id}-project-select-div`).hide() : $(`#${id}-project-select-div`).show(); - updateLabConfigSelect(select, value, currentVal); - } - - var updatePartitions = function (id, service, system, account, project, value) { - const dropdownOptions = getDropdownOptions(); - const systemInfo = getSystemInfo(); - - let select = $(`select#${id}-partition-select`); - const currentVal = select.val(); - resetInputElement(select); - // Distinguish between login and compute nodes - var loginNodes = []; - var computeNodes = []; - const partitionsAllowed = (((dropdownOptions[service] || {})[system] || {})[account] || {})[project] || {}; - const interactivePartitions = (systemInfo[system] || {}).interactivePartitions || []; - for (const partition of Object.keys(partitionsAllowed).sort()) { - if (interactivePartitions.includes(partition)) loginNodes.push(partition); - else computeNodes.push(partition); - } - // Append options to select in groups - if (loginNodes.length > 0) { - select.append('<optgroup label="Login Nodes">'); - loginNodes.forEach((x) => select.append(`<option value="${x}">${x}</option>`)) - select.append('</optgroup>'); - } - if (computeNodes.length > 0) { - select.append('<optgroup label="Compute Nodes">'); - const systemUpper = system.replace('-', '').toUpperCase(); - if ((window.systemsHealth[systemUpper] || 0) >= (window.systemsHealth.threshold.compute || 40)) { - computeNodes.forEach((x) => select.append(`<option value="${x}" disabled>${x} (in maintenance)</option>`)); - } - else { - computeNodes.forEach((x) => select.append(`<option value="${x}">${x}</option>`)); - } - select.append('</optgroup>'); - } - $.isEmptyObject(partitionsAllowed) ? $(`#${id}-partition-select-div`).hide() : $(`#${id}-partition-select-div`).show(); - updateLabConfigSelect(select, value, currentVal); - } - - var updateReservations = function (id, service, system, account, project, partition, value) { - - function _toggle_show_reservation(show) { - if (show) { - $(`#${id}-reservation-select-div`).show(); - $(`#${id}-reservation-hr`).show(); - } - else { - $(`#${id}-reservation-select-div`).hide(); - $(`#${id}-reservation-hr`).hide(); - } - } - - const dropdownOptions = getDropdownOptions(); - const reservationInfo = getReservationInfo(); - const systemInfo = getSystemInfo(); - - let select = $(`select#${id}-reservation-select`); - const currentVal = select.val(); - resetInputElement(select, false); - - const interactivePartitions = (systemInfo[system] || {}).interactivePartitions || []; - if (interactivePartitions.includes(partition)){ - _toggle_show_reservation(false); - updateLabConfigSelect(select, value, currentVal); - return; - } - - const reservationsAllowed = ((((dropdownOptions[service] || {})[system] || {})[account] || {})[project] || {})[partition] || {}; - if (reservationsAllowed.length > 0 && JSON.stringify(reservationsAllowed) !== JSON.stringify(["None"])) { - for (const reservation of reservationsAllowed) { - if (reservation == "None") select.append(`<option value="${reservation}">${reservation}</option>`); - else { - const reservationName = reservation.ReservationName; - const systemReservationInfo = reservationInfo[system] || {}; - for (const reservationInfo of systemReservationInfo) { - if (reservationInfo.ReservationName == reservationName) { - if (reservationInfo.State == "ACTIVE") select.append(`<option value="${reservationName}">${reservationName}</option>`); - else select.append(`<option value="${reservationName}" disabled style="color: #6c757d;">${reservationName} [INACTIVE]</option>`); - } - } - } - } - select.attr("required", true); - _toggle_show_reservation(true); - } - else { - _toggle_show_reservation(false); - } - updateLabConfigSelect(select, value, currentVal); - } - - var updateResources = function (id, service, system, account, project, partition, nodes, gpus, runtime, xserver) { - const resourceInfo = getResourceInfo(); - let nodesInput = $(`input#${id}-nodes-input`); - let gpusInput = $(`input#${id}-gpus-input`); - let runtimeInput = $(`input#${id}-runtime-input`); - let xserverCheckboxInput = $(`input#${id}-xserver-cb-input`); - let xserverInput = $(`input#${id}-xserver-input`); - let tabWarning = $(`#${id}-resources-tab-warning`); - const currentNodeVal = nodesInput.val(); - const currentGpusVal = gpusInput.val(); - const currentRuntimeVal = runtimeInput.val(); - const currentXserverCbVal = xserverCheckboxInput[0].checked; - const currentXserverVal = xserverInput.val(); - [nodesInput, gpusInput, runtimeInput, xserverInput].forEach(input => resetInputElement(input, false)); - xserverCheckboxInput[0].checked = false; - - $(`#${id}-resources-tab`).show(); - const systemResources = (resourceInfo[service] || {})[system] || {}; - if ($.isEmptyObject(systemResources)) { - $(`#${id}-resources-tab`).addClass("disabled"); - $(`#${id}-resources-tab`).hide(); - tabWarning.addClass("invisible"); - } - else { - const partitionResources = systemResources[partition]; - if ($.isEmptyObject(partitionResources)) { - $(`#${id}-resources-tab`).addClass("disabled"); - $(`#${id}-resources-tab`).hide(); - tabWarning.addClass("invisible"); - } - else { - $(`#${id}-resources-tab`).removeClass("disabled"); - $(`#${id}-resources-tab`).show(); - if ("nodes" in partitionResources) { - let min = (partitionResources.nodes.minmax || [0, 1])[0]; - let max = (partitionResources.nodes.minmax || [0, 1])[1]; - $(`label[for*=${id}-nodes-input]`).text("Nodes [" + min + "," + max + "]"); - let defaultNodes = partitionResources.nodes.default || 0; - updateLabConfigInput(nodesInput, nodes, currentNodeVal, min, max, defaultNodes); - $(`#${id}-nodes-input-div`).show(); - if (!currentNodeVal) tabWarning.removeClass("invisible"); - } - else { - $(`#${id}-nodes-input-div`).hide(); - if (currentNodeVal) tabWarning.removeClass("invisible"); - } - - if ("gpus" in partitionResources) { - let min = (partitionResources.gpus.minmax || [0, 1])[0]; - let max = (partitionResources.gpus.minmax || [0, 1])[1]; - $(`label[for*=${id}-gpus-input]`).text("GPUs [" + min + "," + max + "]"); - let defaultGpus = partitionResources.gpus.default || 0; - updateLabConfigInput(gpusInput, gpus, currentGpusVal, min, max, defaultGpus); - $(`#${id}-gpus-input-div`).show(); - if (!currentGpusVal) tabWarning.removeClass("invisible"); - } - else { - $(`#${id}-gpus-input-div`).hide(); - if (currentGpusVal) tabWarning.removeClass("invisible"); - } - - if ("runtime" in partitionResources) { - let min = (partitionResources.runtime.minmax || [0, 1])[0]; - let max = (partitionResources.runtime.minmax || [0, 1])[1]; - $(`label[for*=${id}-runtime-input]`).text("Runtime (minutes) [" + min + "," + max + "]"); - let defaultRuntime = partitionResources.runtime.default || 0; - updateLabConfigInput(runtimeInput, runtime, currentRuntimeVal, min, max, defaultRuntime); - $(`#${id}-runtime-input-div`).show(); - if (!currentRuntimeVal) tabWarning.removeClass("invisible"); - } - else { - $(`#${id}-runtime-input-div`).hide(); - if (currentRuntimeVal) tabWarning.removeClass("invisible"); - } - - if ("xserver" in partitionResources) { - let cblabel = partitionResources.xserver.cblabel || "Activate XServer"; - $(`label[for*=${id}-xserver-cb-input]`).text(cblabel); - var min = (partitionResources.xserver.minmax || [0, 1])[0]; - var max = (partitionResources.xserver.minmax || [0, 1])[1]; - let label = partitionResources.xserver.label || "Use XServer GPU Index"; - $(`label[for*=${id}-xserver-input]`).text(label + " [" + min + "," + max + "]"); - - if (xserver) { xserverCheckboxInput[0].checked = true; } - else { - xserver = partitionResources.xserver.default || 0; - if (!currentXserverVal) tabWarning.removeClass("invisible"); - // Determine if XServer checkbox should be shown - if (partitionResources.xserver.checkbox || false) { - $(`#${id}-xserver-cb-input-div`).show(); - if (currentXserverCbVal) xserverCheckboxInput[0].checked = true; - else { - if (partitionResources.xserver.default_checkbox || false) - xserverCheckboxInput[0].checked = true; - else xserverCheckboxInput[0].checked = false; - } - if (!currentXserverCbVal && xserverCheckboxInput[0].checked) tabWarning.removeClass("invisible"); - } - else { - $(`#${id}-xserver-cb-input-div`).hide(); - xserverCheckboxInput[0].checked = true; - if (!currentXserverCbVal) tabWarning.removeClass("invisible"); - } - } - updateLabConfigInput(xserverInput, xserver, currentXserverVal, min, max, min, false); - if (xserverCheckboxInput[0].checked) $(`#${id}-xserver-input-div`).show(); - else $(`#${id}-xserver-input-div`).hide(); - } - else { - $(`#${id}-xserver-cb-input-div`).hide(); - $(`#${id}-xserver-input-div`).hide(); - if (currentXserverCbVal || currentXserverVal) tabWarning.removeClass("invisible"); - } - } - } - } - - var updateModules = function updateModules(id, service, system, account, project, partition, values) { - const moduleInfo = getModuleInfo(); - const systemInfo = getSystemInfo(); - const interactivePartitions = (systemInfo[system] || {}).interactivePartitions || []; - - var tabWarning = $(`#${id}-modules-tab-warning`); - var currentOptions = []; - $(`#${id}-modules-form`).find(`input[type=checkbox]`).each(function () { - currentOptions.push($(this).val()); - }) - - var defaultOptions = []; - var enableModulesTab = false; - - for (const [moduleSet, modules] of Object.entries(moduleInfo)) { - $(`#${id}-${moduleSet}-div`).hide(); - var insertIndex = -1; - for (const [module, moduleInfo] of Object.entries(modules)) { - if (moduleInfo.sets.includes(service)) { - if (moduleInfo.allowed_systems && !moduleInfo.allowed_systems.includes(system)) { - // Module not in allowed systems, so do nothing. - } - else { - if (moduleInfo.compute_only && interactivePartitions.includes(partition)) { - // Module is compute only, but partition is interactive, so do nothing. - } - else if (moduleInfo.interactive_only && !interactivePartitions.includes(partition)) { - // Module is interactive only, but partition is compute, so do nothing. - } - else { - $(`#${id}-${moduleSet}-div`).show(); - enableModulesTab = true; - defaultOptions.push(module); - // If checkbox already exists, do nothing - // Else create it and set the default value - if (!currentOptions.includes(module)) { - let parent = $(`#${id}-${moduleSet}-checkboxes-div`); - var checked = ""; - if (typeof moduleInfo.default == "boolean") { - var checked = moduleInfo.default ? "checked" : ""; - } else if ( typeof moduleInfo.default == "object" && service in moduleInfo.default ) { - var checked = ( moduleInfo.default[service] || false) ? "checked" : ""; - } else if ( typeof moduleInfo.default == "object" && "default" in moduleInfo.default ) { - var checked = moduleInfo.default.default ? "checked" : ""; - } else { - var checked = ""; - } - // let checked = moduleInfo.default ? "checked" : ""; - let module_cols = "col-sm-6 col-md-4 col-lg-3"; - let cbHtml = ` - <div id="${id}-${module}-cb-div" class="form-check ${module_cols}"> - <input type="checkbox" class="form-check-input" id="${id}-${module}-check" value="${module}" ${checked}> - <label class="form-check-label" for="${id}-${module}-check"> - <span class="align-middle">${moduleInfo.displayName}</span> - <a href="${moduleInfo.href}" target="_blank" class="module-info text-muted ms-3"> - <span>${getInfoSvg()}</span> - <div class="module-info-link-div d-inline-block"> - <span class="module-info-link" id="${module}-info-link"> - ${getLinkSvg()} - </span> - </div> - </a> - </label> - </input> - </div> - ` - // No checkboxes exist yet, so we can simply append to the parent div - if (parent.children().length == 0) { - parent.append(cbHtml); - } - // Otherwise, we need to determine where to insert the new checkbox - else { - // Get the current element at index - var target = parent.children().eq(insertIndex); - target.before(cbHtml); - } - // Show tab warning to indicate changes in checkbox options - tabWarning.removeClass("invisible"); - } - insertIndex++; - } - } - } - } - } - // Remove checkboxes which still exist but should not anymore - var shouldRemove = currentOptions.filter(x => !defaultOptions.includes(x)); - for (const module of shouldRemove) { - $(`#${id}-${module}-cb-div`).remove(); - // Show tab warning to indicate changes in checkbox options - tabWarning.removeClass("invisible"); - } - - // Set values according to previous values. - if (values) { - // Loop through all checkboxes and only check those in values. - $(`#${id}-modules`).find("input[type=checkbox]").each((i, cb) => { - if (values.includes(cb.value)) cb.checked = true; - else cb.checked = false; - }) - } - - if (enableModulesTab) { - $(`#${id}-modules-tab`).removeClass("disabled"); - $(`#${id}-modules-tab`).show(); - } - else { - $(`#${id}-modules-tab`).addClass("disabled"); - $(`#${id}-modules-tab`).hide(); - tabWarning.addClass("invisible"); - } - } - - /* - Util functions - */ - var resetInputElement = function (element, required = true) { - element.html(""); - element.val(null); - element.removeClass("text-muted disabled"); - if (required) { - if(! element.hasClass("optional")){ - element.attr("required", required); - } - } else { - element.removeAttr("required"); - } - } - - var updateLabConfigSelect = function (select, value, lastSelected) { - } - - var updateLabConfigSelect2 = function (select, value, lastSelected) { - // For some systems, e.g. cloud, some options are not available - if (select.html() == "") { - select.append("<option disabled>Not available</option>"); - select.addClass("text-muted").removeAttr("required"); - } - // If there is only one option, we disable the dropdown - const numberOfOptions = select.children().length - if (numberOfOptions == 1) { - select.addClass("disabled"); - } - if (value) select.val(value); - else { - // Check if the last value is contained in the new options, - // otherwise just select the first value. - var index = 0; - select.children().each(function (i, option) { - if ($(option).val() == lastSelected) { - index = i; - /* Although index should be used to set the value and - avoid the (index == 0) query, indices don't work directly - when there are optiongroups in the select. So we set - it via the .val() function regardless. */ - select.val(lastSelected); - return; - } - }) - if (index == 0) select.prop("selectedIndex", index); - } - select[0].dispatchEvent(new Event("change")); - } - - var updateLabConfigInput = function (input, value, lastSelected, min, max, defaultValue, required = true) { - input.attr({ "min": min, "max": max }); - if (required) input.attr("required", required); - else input.removeAttr("required"); - // Set message for invalid feedback - input.siblings(".invalid-feedback") - .text(`Please choose a number between ${min} and ${max}.`); - if (value) { - input.val(value); - } - // Check if we can keep the old value - else if (lastSelected != "" && lastSelected >= min && lastSelected <= max) { - input.val(lastSelected); - } - else { - input.val(defaultValue); - } - } - - var updateR2dType = function (id, r2dType) { - // const dropdownOptions = getDropdownOptions(); - const repos = getBinderRepos().repos || []; - - let select = $(`select#${id}-type-select`); - const currentVal = select.val(); - resetInputElement(select); - - repos.forEach((repo) => select.append(`<option value="${repo}">${repo}</option>`)); - - updateLabConfigSelect(select, r2dType, currentVal); - } - - var updateR2dNotebookTypes = function(id, r2dNotebookType){ - const notebookTypes = getBinderRepos().notebookTypes || []; - - let select = $(`select#${id}-notebook_type-select`); - const currentVal = select.val(); - resetInputElement(select); - - notebookTypes.forEach((nbType) => select.append(`<option value="${nbType}">${nbType}</option>`)) - updateLabConfigSelect(select, r2dNotebookType, currentVal); - } - - var updateDropdowns = { - updateServices: updateServices, - updateSystems: updateSystems, - updateFlavors: updateFlavors, - updateAccounts: updateAccounts, - updateProjects: updateProjects, - updatePartitions: updatePartitions, - updateReservations: updateReservations, - updateResources: updateResources, - updateModules: updateModules, - updateR2dType: updateR2dType, - updateR2dNotebookTypes: updateR2dNotebookTypes, - resetInputElement: resetInputElement, - updateLabConfigSelect: updateLabConfigSelect, - updateLabConfigInput: updateLabConfigInput, - } - - return updateDropdowns; - -}) diff --git a/static/js/home/handle-events.js b/static/js/home/handle-events.js deleted file mode 100644 index 5376b14..0000000 --- a/static/js/home/handle-events.js +++ /dev/null @@ -1,451 +0,0 @@ -/* -* Callbacks related to interacting with table rows -*/ -require(["jquery", "home/utils", "home/dropdown-options"], function ( - $, - utils, - dropdowns -) { - "use strict"; - - /* *************** */ - /* TABLE UI EVENTS */ - /* *************** */ - - // Toggle a labs corresponding collapsible table row - // when it's summary table row is clicked. - $(".summary-tr").on("click", function () { - let id = $(this).data("server-id"); - let accordionIcon = $(this).find(".accordion-icon"); - let collapse = $(`.collapse[id*=${id}]`); - let shown = collapse.hasClass("show"); - if (shown) accordionIcon.addClass("collapsed"); - else accordionIcon.removeClass("collapsed"); - new bootstrap.Collapse(collapse); - }); - - // ... but not when the action td button are clicked. - $(".actions-td button").on("click", function (event) { - event.preventDefault(); - event.stopPropagation(); - }); - - // We show warning icons when the content of tabs change. - // Hide those warning icons once the tab is activated. - $("button[role=tab]").on("click", function () { - let warning = $(this).find("[id$=tab-warning]"); - warning.addClass("invisible"); - }); - - // Toggle log tabs on log button or log info text click - $(".log-info-btn, .log-info-text").on("click", function (event) { - let id = $(this).parents("tr").data("server-id"); - let collapse = $(`.collapse[id*=${id}]`); - let shown = collapse.hasClass("show"); - // Prevent collapse from closing if it is - // already open, but not showing the logs tab. - if (shown && !$(`#${id}-logs-tab`).hasClass("active")) { - event.preventDefault(); - event.stopPropagation(); - } - // Change to the log tab. - var trigger = $(`#${id}-logs-tab`); - var tab = new bootstrap.Tab(trigger); - tab.show(); - }); - - // Show selected logs. - $("select[id*=log-select]").change(function () { - const id = utils.getId(this); - const val = $(this).val(); - var log = $(`#${id}-log`); - log.html(""); - for (const event of spawnEvents[id][val]) { - utils.appendToLog(log, event["html_message"]); - } - }); - - $("button[id*=view-password]").on("click", function (event) { - const id = utils.getId(this); - const passInput = $(`#${id}-image-private-pass-input`)[0] - const eye = $(`#${id}-password-eye`)[0] - if (passInput.type === 'password') { - passInput.type = 'text'; - eye.classList.remove('fa-eye'); - eye.classList.add('fa-eye-slash'); - } else { - passInput.type = 'password'; - eye.classList.add('fa-eye'); - eye.classList.remove('fa-eye-slash'); - } - }); - - /* *************** */ - /* LAB CONFIG */ - /* *************** */ - - function _toggle_show_element(id, key, type, showInput, pattern) { - let element = $(`#${id}-${key}-${type}`); - if (showInput) { - $(`#${id}-${key}-${type}-div`).show(); - if(element.hasClass("optional")){ - element.removeAttr("required"); - } - else element.attr("required", true); - if (pattern) element.attr("pattern", pattern); - } else { - $(`#${id}-${key}-${type}-div`).hide(); - element.removeAttr("required pattern"); - } - } - - function _toggle_show_repo2Docker(id){ - var repo2DockerInputs = ["repo", "gitref", "notebook"]; - var repo2DockerSelects = ["type", "notebook_type"] - - var values = utils.getLabConfigSelectValues(id); - const show = (values.service || "") == "repo2docker"; - - for(let key of repo2DockerInputs){ - let element = $(`#${id}-${key}-input`); - dropdowns.resetInputElement(element); - _toggle_show_element(id, key, "input", show); - } - repo2DockerSelects.forEach(key => _toggle_show_element(id, key, "select", show)); - - if (show) { - dropdowns.updateR2dType(id, null); - dropdowns.updateR2dNotebookTypes(id, null); - } - } - - function _toggle_show_customImage(id, userInputInfo){ - var customDockerInputs = ["image"] - var customDockerInputsMounts = ["image-mount"] - var customDockerInputsPrivate = ["image-private-url", "image-private-user", "image-private-pass"] - var registryAuthsInputDivs = $(`#${id}-image-private-url-input-div,#${id}-image-private-user-input-div, #${id}-image-private-pass-input-div`) - var allUserInputDivs = $(`#${id}-image-input-div, #${id}-image-private-cb-input-div, #${id}-image-mount-cb-input-div, #${id}-image-mount-input-div`) - - var values = utils.getLabConfigSelectValues(id); - const show = (values.service || "") == "custom"; - - for(let key of customDockerInputs){ - let element = $(`#${id}-${key}-input`); - dropdowns.resetInputElement(element, show); - _toggle_show_element(id, key, "input", show); - } - - // set default values for mount userdata - var mount_cb_checked = userInputInfo.defaultMountEnabled || true; - - if (mount_cb_checked) { - for(let key of customDockerInputsMounts){ - let element = $(`#${id}-${key}-input`); - dropdowns.resetInputElement(element, show && mount_cb_checked); - _toggle_show_element(id, key, "input", show && mount_cb_checked); - } - } - - for(let key of customDockerInputsPrivate){ - let element = $(`#${id}-${key}-input`); - dropdowns.resetInputElement(element, false); - _toggle_show_element(id, key, "input", false); - } - - if (show) { - $(`#${id}-image-mount-cb-input`)[0].checked = mount_cb_checked; - $(`#${id}-image-mount-input`).val(userInputInfo.defaultMountPath || "/mnt/userdata"); - allUserInputDivs.show(); - } else { - registryAuthsInputDivs.hide(); - allUserInputDivs.hide(); - } - } - - function _toggle_show_share_button(id, values, force_hide=false, force_show=false){ - const shareInfo = getShareInfo(); - const software = "JupyterLab"; - const service = values.service || ""; - const system = values.system || ""; - if ( force_show || ( (! force_hide) && ( shareInfo[software] ) && ( (shareInfo[software][service] || []).includes(system) ) ) ) { - $(`#${id}-share-btn`).show(); - } else { - $(`#${id}-share-btn`).hide(); - } - } - - $("select[id*=verfffsion]").change(function () { - const id = utils.getId(this); - const values = utils.getLabConfigSelectValues(id); - if (!$(this).hasClass("no-update")) { - try { - dropdowns.updateSystems(id, values.service); - } - catch (e) { - utils.setLabAsNA(id, "due to a JS error"); - console.log(e); - } - } - - const serviceInfo = getServiceInfo(); - const userInputInfo = (serviceInfo.JupyterLab.options[values.service] || {}).userInput || {}; - _toggle_show_customImage(id, userInputInfo); - _toggle_show_repo2Docker(id); - }); - - $("select[id*=type]").change(function () { - const id = utils.getId(this); - const values = utils.getLabConfigSelectValues(id); - if (!$(this).hasClass("no-update")) { - try { - dropdowns.updateSystems(id, values.service); - } - catch (e) { - utils.setLabAsNA(id, "due to a JS error"); - console.log(e); - } - } - - if ( ["GitHub"].includes(values.r2dtype) ){ - let label = $(`label[for="${id}-repo-input"]`); - let input = $(`#${id}-repo-input`); - label.text("GitHub repository name or URL"); - input.attr("placeholder", "GitHub repository name or URL"); - } - }); - - $("select[id*=notebook_type]").change(function () { - const id = utils.getId(this); - const values = utils.getLabConfigSelectValues(id); - if (!$(this).hasClass("no-update")) { - try { - dropdowns.updateSystems(id, values.service); - } - catch (e) { - utils.setLabAsNA(id, "due to a JS error"); - console.log(e); - } - } - - if ( ["GitHub"].includes(values.r2dtype) ){ - let label = $(`label[for="${id}-notebook-input"]`); - let input = $(`#${id}-notebook-input`); - if ( values.r2dnotebooktype == "File") { - label.text("Path to a notebook file (optional)"); - input.attr("placeholder", "Path to a notebook file (optional)"); - } else { - label.text("URL to open (optional)"); - input.attr("placeholder", "URL to open (optional)"); - } - } - }); - - $("input[id*=image-private-cb-input]").change(function () { - const id = utils.getId(this, -4); - const values = utils.getLabConfigSelectValues(id); - const showInput = this.checked; - _toggle_show_element(id, "image-private-url", "input", showInput); - _toggle_show_element(id, "image-private-user", "input", showInput); - _toggle_show_element(id, "image-private-pass", "input", showInput); - - // If private registry is used, we disable the share button - _toggle_show_share_button(id, values, showInput); - }); - - $("input[id*=image-mount-cb-input]").change(function () { - const id = utils.getId(this, -4); - const pattern_check = "^\\/[A-Za-z0-9\\-\\/]+"; - _toggle_show_element(id, "image-mount", "input", this.checked, pattern_check); - }); - - $("select[id*=system]").change(function () { - const id = utils.getId(this); - const values = utils.getLabConfigSelectValues(id); - if (!$(this).hasClass("no-update")) { - try { - dropdowns.updateFlavors(id, values.service, values.system); - dropdowns.updateAccounts(id, values.service, values.system); - } - catch (e) { - utils.setLabAsNA(id, "due to a JS error"); - console.log(e); - } - } - - // Check if the chosen version is deprecated for the system - const serviceInfo = getServiceInfo(); - const systemInfo = getSystemInfo(); - // First check for system specific default option, then for general one - var defaultOption = (((systemInfo[values.system] || {}).services || {}).JupyterLab || {}).defaultOption || serviceInfo.JupyterLab.defaultOption; - if (defaultOption && values.service != defaultOption) { - // Not using default/latest version, show a warning message - let reason = "<span style=\"color:darkorange;\">uses deprecated version</span>"; - $(`#${id}-spawner-info`).show().html(reason); - } - else { - $(`#${id}-spawner-info`).hide().html(""); - } - _toggle_show_share_button(id, values); - }); - - $("select[id*=account]").change(function () { - const id = utils.getId(this); - const values = utils.getLabConfigSelectValues(id); - if (!$(this).hasClass("no-update")) { - try { - dropdowns.updateProjects(id, values.service, values.system, values.account); - } - catch (e) { - utils.setLabAsNA(id, "due to a JS error"); - console.log(e); - } - } - }); - - $("select[id*=project]").change(function () { - const id = utils.getId(this); - const values = utils.getLabConfigSelectValues(id); - if (!$(this).hasClass("no-update")) { - try { - dropdowns.updatePartitions(id, values.service, values.system, values.account, values.project); - } - catch (e) { - utils.setLabAsNA(id, "due to a JS error"); - console.log(e); - } - } - }); - - $("select[id*=partition]").change(function () { - const id = utils.getId(this); - const values = utils.getLabConfigSelectValues(id); - if (!$(this).hasClass("no-update")) { - try { - dropdowns.updateReservations(id, values.service, values.system, values.account, values.project, values.partition); - dropdowns.updateResources(id, values.service, values.system, values.account, values.project, values.partition); - dropdowns.updateModules(id, values.service, values.system, values.account, values.project, values.partition); - } - catch (e) { - utils.setLabAsNA(id, "due to a JS error"); - console.log(e); - } - } - }); - - $("input[id*=xserver-cb-input]").change(function () { - const id = utils.getId(this, -3); - _toggle_show_element(id, "xserver", "input", this.checked); - }); - - $("select[id*=reservation]").change(function () { - const reservationInfo = getReservationInfo(); - - const id = utils.getId(this); - const value = $(this).val(); - if (value) { - if (value == "None") { - $(`#${id}-reservation-info-div`).hide(); - $(`#${id}-runtime-input`).trigger("change"); - // } - return; - } - const systemReservationInfo = reservationInfo[utils.getLabConfigSelectValues(id)["system"]] || []; - for (const reservationInfo of systemReservationInfo) { - if (reservationInfo.ReservationName == value) { - $(`#${id}-reservation-start`).html(`${reservationInfo.StartTime} (Europe/Berlin)`); - $(`#${id}-reservation-end`).html(`${reservationInfo.EndTime} (Europe/Berlin)`); - $(`#${id}-reservation-state`).html(reservationInfo.State); - $(`#${id}-reservation-details`).html( - JSON.stringify(reservationInfo, null, 2)); - } - } - $(`#${id}-reservation-info-div`).show(); - $(`#${id}-runtime-input`).trigger("change"); - } - else { - $(`#${id}-reservation-info-div`).hide(); - } - }); - - $("input[id*=runtime-input").change(function () { - - function _getTimeInMinutes(startTime, endTime){ - const elapsedTime = endTime - startTime; - const elapsedSeconds = elapsedTime / 1000; // Convert milliseconds to seconds - const elapsedMinutes = elapsedSeconds / 60; // Convert seconds to minutes - - return elapsedMinutes; - }; - - function _resetErrors(id, element) { - - var resourceInfo = getResourceInfo(); - const values = utils.getLabConfigSelectValues(id); - const partitionResources = ((resourceInfo[values.service] || {})[values.system] || {})[values.partition] || {}; - if (partitionResources.runtime != undefined) { - let min = (partitionResources.runtime.minmax || [0, 1])[0]; - let max = (partitionResources.runtime.minmax || [0, 1])[1]; - element.siblings(".invalid-feedback").text(`Please choose a number between ${min} and ${max}.`); - } - tabWarning.addClass("invisible"); - element.removeClass("is-invalid"); - } - - const id = utils.getId(this); - const reservationInfo = getReservationInfo(); - const systemReservationInfo = reservationInfo[utils.getLabConfigSelectValues(id)["system"]] || []; - var tabWarning = $(`#${id}-resources-tab-warning`); - - if (reservationInfo) { - const currentReservation = $(`#${id}-reservation-select`).val(); - - if (currentReservation == "None") { - _resetErrors(id, $(this)); - return; - } - for (const reservationInfo of systemReservationInfo) { - if (reservationInfo.ReservationName == currentReservation) { - const resStart = reservationInfo.StartTime; - const resEnd = reservationInfo.EndTime; - - const nowString = Date().toLocaleString("en-US", {timeZone: "Europe/Berlin"}); - const now = new Date(nowString).getTime(); - const reservStart = new Date(resStart).getTime(); - const startTime = (reservStart > now)? reservStart : now; - const endTime = new Date(resEnd).getTime(); - - var reservationTime = _getTimeInMinutes(startTime, endTime); - var currentRuntimeVal = $(this)[0].value; - - if(currentRuntimeVal > reservationTime){ - // a buffer of 10 minutes, which is used only for the error message to avoid users copy-pasting the maximum time and their job landing in the queue forever - let buffer = 10; - const timeLeft = Math.floor(reservationTime - buffer); - $(this).siblings(".invalid-feedback").text(`Your reservation ends on ${resEnd}. Do not set a runtime which exceeds this limit, e.g., ${timeLeft} minutes.`); - $(this).addClass("is-invalid"); - - tabWarning.removeClass("invisible"); - } - else { - _resetErrors(id, $(this)); - } - } - } - } - }); - - $("input.module-selector").click(function () { - const id = utils.getId(this, -3); - const allOrNone = $(this).attr("id").includes("select-all") ? "all" : "none"; - var checkboxes = $(`#${id}-modules-form`).find("input[type=checkbox]"); - if (allOrNone == "all") { - $(`#${id}-modules-select-none`)[0].checked = false; - checkboxes.each((i, cb) => { cb.checked = true; }); - } - else if (allOrNone == "none") { - $(`#${id}-modules-select-all`)[0].checked = false; - checkboxes.each((i, cb) => { cb.checked = false; }); - } - }); - -}) diff --git a/static/js/home/handle-servers.js b/static/js/home/handle-servers.js deleted file mode 100644 index 9d5055a..0000000 --- a/static/js/home/handle-servers.js +++ /dev/null @@ -1,505 +0,0 @@ -// Copyright (c) Jupyter Development Team. -// Distributed under the terms of the Modified BSD License. -/* -* This module is responsible for JupyterLab start/stop/cancel/delete etc. events. It also prepares the user options to be sent to the backend -*/ -require(["jquery", "jhapi", "utils", "home/utils", "home/lab-configs"], function ( - $, - JHAPI, - utils, - custom_utils, - lab -) { - "use strict"; - - var base_url = window.jhdata.base_url; - var user = window.jhdata.user; - var api = new JHAPI(base_url); - - function cancelServer() { - var [tr, id] = _getTrAndId(this); - _disableTrButtons(tr); - api.cancel_named_server(user, id, { - success: function () { - console.log("cancel success"); - custom_utils.setSpawnActive(id, false); - }, - error: function () { - console.log("cancel error"); - } - }); - } - - function stopServer() { - var [tr, id] = _getTrAndId(this); - _disableTrButtons(tr); - custom_utils.updateProgressState(id, "stopping"); - api.stop_named_server(user, id, { - success: function () { - console.log("stop success"); - let running = false; - _enableTrButtons(tr, running); - // Reset progress - custom_utils.updateProgressState(id, "reset"); - custom_utils.setSpawnActive(id, false); - }, - error: function (xhr) { - console.log("stop error"); - custom_utils.updateProgressState(id, "stop_failed"); - tr.find(".btn-open-lab, .btn-cancel-lab").removeClass("disabled"); - $(`#${id}-log`) - .append($('<div class="log-div">') - .html`Could not stop server. Error: ${xhr.responseText}`); - } - }); - } - - function deleteServer() { - var that = $(this); - var [collapsibleTr, id] = _getTrAndId(this); - _disableTrButtons(collapsibleTr); - api.delete_named_server(user, id, { - success: function () { - $(`tr[data-server-id=${id}]`).each(function () { - $(this).remove(); - }); - custom_utils.setSpawnActive(id, false); - }, - error: function (xhr) { - var alert = that.siblings(".alert"); - const displayName = _getDisplayName(collapsibleTr); - _showErrorAlert(alert, displayName, xhr.responseText); - } - }); - } - - function startServer() { - var [tr, id] = _getTrAndId(this); - var collapsibleTr = tr.siblings(`.collapsible-tr[data-server-id=${id}]`); - _disableTrButtons(tr); - - // Validate the form and start spawn only after validation - try { - $(`form[id*=${id}]`).submit(); - } - catch (e) { - let running = false; - _enableTrButtons(tr, running); - return; - } - custom_utils.updateProgressState(id, "reset"); - $(`#${id}-log`).html(""); - - var options = _createDataDict(collapsibleTr); - // Update the summary row according to the values set in the collapsibleTr - _updateTr(tr, id, options); - // Open a new tab for spawn_pending.html - // Need to create it here for JS context reasons - var newTab = window.open("about:blank"); - api.start_named_server(user, id, { - data: JSON.stringify(options), - success: function () { - // Save latest log to time stamp and empty it - custom_utils.updateSpawnEvents(window.spawnEvents, id); - window.userOptions[id] = options; - // Open the spawn url in the new tab - newTab.location.href = utils.url_path_join(base_url, "spawn", user, id); - // Hook up event-stream for progress - var evtSources = window.evtSources; - if (!(id in evtSources)) { - var progressUrl = utils.url_path_join(jhdata.base_url, "api/users", jhdata.user, "servers", id, "progress"); - progressUrl = progressUrl + "?_xsrf=" + window.jhdata.xsrf_token; - evtSources[id] = new EventSource(progressUrl); - evtSources[id].onmessage = function (e) { - onEvtMessage(e, id); - } - } - // Successfully sent request to start the lab, enable row again - let running = true; - custom_utils.setSpawnActive(id, "pending"); - _enableTrButtons(tr, running); - }, - error: function (xhr) { - newTab.close(); - // If cookie is not valid anymore, refresh the page. - // This should redirect the user to the login page. - if (xhr.status == 403) { - document.location.reload(); - return; - } - custom_utils.updateProgressState(id, "failed"); - // Update progress in log - let details = $("<details>") - .append($("<summary>") - .html(`Could not request spawn. Error: ${xhr.responseText}`)) - .append($("<pre>") - .html(custom_utils.parseJSON(xhr.responseText))); - $(`#${id}-log`).append( - $("<div>").addClass("log-div").html(details) - ); - // Spawn attempt finished, enable row again - let running = false; - _enableTrButtons(tr, running); - } - }); - } - - function startNewServer() { - function _uuidv4hex() { - return ([1e7, 1e3, 4e3, 8e3, 1e11].join('')).replace(/[018]/g, c => - (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)); - } - - function _uuidWithLetterStart() { - let uuid = _uuidv4hex(); - let char = Math.random().toString(36).match(/[a-zA-Z]/)[0]; - return char + uuid.substring(1); - } - - const uuid = _uuidWithLetterStart(); - // Start button is in collapsible tr for new labs - var [collapsibleTr, id] = _getTrAndId(this); - _disableTrButtons(collapsibleTr); - - // Validate the form and start spawn only after validation - try { - $(`form[id*=${id}]`).submit(); - } - catch (e) { - collapsibleTr.find("button").removeClass("disabled"); - return; - } - - var options = _createDataDict(collapsibleTr); - // Open a new tab for spawn_pending.html - // Need to create it here for JS context reasons - var newTab = window.open("about:blank"); - api.start_named_server(user, uuid, { - data: JSON.stringify(options), - success: function () { - var url = utils.url_path_join(base_url, "spawn", user, uuid); - newTab.location.href = url; - // Reload page to add spawner to table - location.reload(); - }, - error: function (xhr) { - newTab.close(); - // If cookie is not valid anymore, refresh the page. - // This should redirect the user to the login page. - if (xhr.status == 403) { - document.location.reload(); - return; - } - // Show information about why the start failed - let details = $("<details>") - .append($("<summary>") - .html(`Could not request spawn. Error: ${xhr.responseText}`)) - .append($("<pre>") - .html(custom_utils.parseJSON(xhr.responseText))); - $(`#${id}-log`).append( - $("<div>").addClass("log-div").html(details) - ); - collapsibleTr.find("button").removeClass("disabled"); - } - }); - } - - function shareLab() { - // Start button is in collapsible tr for new labs - var [collapsibleTr, id] = _getTrAndId(this); - // _disableTrButtons(collapsibleTr); - - var options = _createDataDict(collapsibleTr); - - function showShareDialogue(url) { - - $(`#${id}-copy-btn`).click(function() { - - const shareUrl = $(`#${id}-share-link .modal-body a`).attr('href'); - navigator.clipboard.writeText(shareUrl).then(function() { - - $(`#${id}-copy-btn`).tooltip('dispose').attr('title', 'Copied'); - $(`#${id}-copy-btn`).tooltip('show'); - }, function(err) { - console.error('Could not copy text: ', err); - }); - }); - - let shareableURL = url; - - $(`#${id}-share-link .modal-title`).text(`Share Lab ${options["name"]}`); - $(`#${id}-share-link .modal-body a`).text(`${shareableURL}`); - try { - shareableURL = new URL(url); - $(`#${id}-share-link .modal-body a`).attr('href', shareableURL); - } catch (error) {} - - $(`#${id}-share-link`).modal('show'); - } - //--------------------------------------------------- - - // Open a new tab for spawn_pending.html - // Need to create it here for JS context reasons - // var newTab = window.open("about:blank"); - // let protocol = window.location.protocol; - var urlStr = utils.url_path_join(window.origin, base_url, "share"); - api.share_server({ - data: JSON.stringify(options), - success: function (resp) { - urlStr = utils.url_path_join(window.origin, base_url, "share", "user_options", resp).replace("//", "/"); - showShareDialogue(urlStr); - }, - error: function (xhr) { - // newTab.close(); - // If cookie is not valid anymore, refresh the page. - // This should redirect the user to the login page. - if (xhr.status == 403) { - document.location.reload(); - return; - } - - let err = `Failed to create a share link for this lab. Error: ${xhr.responseText}`; - showShareDialogue(err); - } - }); - } - - $(".btn-start-lab").click(startServer); - $(".btn-start-new-lab").click(startNewServer); - $(".btn-cancel-lab").click(cancelServer); - $(".btn-stop-lab").click(stopServer); - $(".btn-delete-lab").click(deleteServer); - $(".btn-share-lab").click(shareLab); - - /* - Validate form before starting a new lab - */ - $("form").submit(function (event) { - event.preventDefault(); - event.stopPropagation(); - - if (!$(this)[0].checkValidity()) { - $(this).addClass('was-validated'); - // Show the tab where the error was thrown - var tab_id = $(this).attr("id").replace("-form", "-tab"); - var tab = new bootstrap.Tab($("#" + tab_id)); - tab.show(); - // Open the collapsibleTr if it was hidden - const id = custom_utils.getId(this); - var tr = $(`.summary-tr[data-server-id=${id}`); - if (!$(`${id}-collapse`).css("display") == "none") { - tr.trigger("click"); - } - throw { - name: "FormValidationError", - toString: function () { - return this.name; - } - }; - } else { - $(this).removeClass('was-validated'); - } - }); - - - /* - Save and revert changes to spawner - */ - function saveChanges() { - var [collapsibleTr, id] = _getTrAndId(this); - var tr = $(`.summary-tr[data-server-id=${id}]`); - var alert = $(this).siblings(".alert"); - - const displayName = _getDisplayName(collapsibleTr); - const options = _createDataDict(collapsibleTr); - api.update_named_server(user, id, { - data: JSON.stringify(options), - success: function () { - _updateTr(tr, id, options); - // Update global user options - window.userOptions[id] = options; - alert.children("span") - .text(`Successfully updated ${displayName}.`); - alert - .removeClass("alert-danger p-0") - .addClass("alert-success show p-1"); - }, - error: function (xhr) { - _showErrorAlert(alert, displayName, xhr.responseText); - } - }); - } - - function revertChanges() { - const id = custom_utils.getId(this); - var alert = $(this).siblings(".alert"); - - const options = window.userOptions[id]; - const name = options.name; - // Do not send start_id when updating lab config - delete options.start_id; - - api.update_named_server(user, id, { - data: JSON.stringify(options), - success: function () { - $(`#${id}-name-input`).val(name); - // Reset all user inputs to the values saved in the global user options - let available = lab.checkIfAvailable(id, options); - lab.setUserOptions(id, options, available); - // Remove all tab warnings since manual changes shouldn't cause warnings - $("[id$=tab-warning]").addClass("invisible"); - // Show first tab after resetting values - var trigger = $(`#${id}-collapse`).find(".nav-link").first(); - var tab = new bootstrap.Tab(trigger); - tab.show(); - alert.children("span") - .text(`Successfully reverted settings for ${name}.`); - alert - .removeClass("alert-danger p-0") - .addClass("alert-success show p-1"); - }, - error: function (xhr) { - _showErrorAlert(alert, name, xhr.responseText); - } - }); - } - - $(".btn-save-lab").click(saveChanges); - $(".btn-reset-lab").click(revertChanges); - - /* - Util functions - */ - function _getDisplayName(collapsibleTr) { - var displayName = collapsibleTr.find("input[id*=name]").val(); - if (displayName == "") displayName = "Unnamed JupyterLab"; - return displayName; - } - - function _getTrAndId(element) { - let tr = $(element).parents("tr"); - let id = tr.data("server-id"); - return [tr, id]; - } - - function _disableTrButtons(tr) { - // Disable buttons - tr.find(".btn").addClass("disabled"); - } - - function _enableTrButtons(tr, running) { - if (running) { - // Show open/cancel for starting labs - tr.find(".btn-na-lab, .btn-start-lab").addClass("disabled").hide(); - tr.find(".btn-open-lab, .btn-cancel-lab").show(); - // Disable until fitting event received from EventSource - tr.find(".btn-open-lab, .btn-cancel-lab").addClass("disabled"); - } - else { - // Show start or na for non-running labs - var na = tr.find(".na-status").text() || 0; - if (na != "0") { - tr.find(".btn-na-lab").removeClass("disabled").show(); - tr.find(".btn-start-lab").addClass("disabled").hide(); - } - else { - tr.find(".btn-na-lab").addClass("disabled").hide(); - tr.find(".btn-start-lab").removeClass("disabled").show(); - } - tr.find(".btn-open-lab, .btn-cancel-lab, .btn-stop-lab") - .addClass("disabled").hide(); - } - } - - function _showErrorAlert(alert, name, text) { - alert.children("span") - .text(`Could not update ${name}. Error: ${text}`); - alert - .removeClass("alert-success p-0") - .addClass("alert-danger show p-1"); - } - - - function _updateTr(tr, id, options) { - tr.find(".name-td").text(options.name); - function _updateTd(key) { - let configTdDiv = tr.find(`#${id}-config-td-div-${key}`); - if (options[key]) configTdDiv.show(); - else configTdDiv.hide(); - let configDiv = tr.find(`#${id}-config-td-${key}`); - configDiv.text(options[key]); - } - ["system", "flavor", "partition", "project", - "runtime", "nodes", "gpus"].forEach(key => _updateTd(key)); - } - - function _createDataDict(collapsibleTr) { - var options = {} - options.name = _getDisplayName(collapsibleTr); - - function _addSelectValue(param) { - var select = collapsibleTr.find(`select[id*=${param}]`); - var value = select.val(); - if (param == "version") { - param = "profile"; - value = "JupyterLab/" + value; - } - if (value) options[param] = value; - } - - function _addInputValue(param) { - var input = collapsibleTr.find(`input[id*=${param}]`).not(`[type=checkbox]`); - var value = input.val(); - if (param == "xserver") { - if (collapsibleTr.find(`input[id*=xserver-cb-input]`).length && collapsibleTr.find(`input[id*=xserver-cb-input]`)[0] && !collapsibleTr.find(`input[id*=xserver-cb-input]`)[0].checked) return; - } - else if (param == "image-private") { - if (collapsibleTr.find(`input[id*=image-private-cb-input]`).length && collapsibleTr.find(`input[id*=image-private-cb-input]`)[0] && !collapsibleTr.find(`input[id*=image-private-cb-input]`)[0].checked) return; - param = "dockerregistry"; - - let registry_url = ""; - const credentials = {} - - for(let i = 0; i < input.length; i++) { - let el = input[i] - if(el.id.indexOf("private-url") !== -1){ - registry_url = el.value; - } - if(el.id.indexOf("user") !== -1){ - credentials["username"] = el.value; - } - if(el.id.indexOf("pass") !== -1){ - credentials["password"] = el.value; - } - } - const auth_values = {} - auth_values[registry_url] = credentials - const auths = {"auths" : auth_values } - // Encode user credentials for the private docker repository in base64 - value = btoa(JSON.stringify(auths)); - } - else if (param == "image-mount") { - if (collapsibleTr.find(`input[id*=image-mount-cb-input]`).length && collapsibleTr.find(`input[id*=image-mount-cb-input]`)[0] && !collapsibleTr.find(`input[id*=image-mount-cb-input]`)[0].checked) return; - param = "userdata_path"; - } - if (value) options[param] = value; - } - - function _addCbValues(param) { - var checkboxes = collapsibleTr - .find('form[id*=modules-form]') - .find(`input[type=checkbox]`); - var values = []; - checkboxes.each(function () { - if (this.checked) values.push($(this).val()); - }); - options[param] = values; - } - - ["version", "system", "flavor", "account", - "project", "partition", "reservation", "type", "notebook_type"].forEach(key => _addSelectValue(key)); - ["image", "image-mount", "image-private", "nodes", "gpus", "runtime", "xserver", "repo", "gitref", "notebook"].forEach(key => _addInputValue(key)); - _addCbValues("userModules"); - return options; - } -}); diff --git a/static/js/home/lab-configs.js b/static/js/home/lab-configs.js deleted file mode 100644 index 6ac5161..0000000 --- a/static/js/home/lab-configs.js +++ /dev/null @@ -1,383 +0,0 @@ -define(["jquery", "home/utils", "home/dropdown-options"], function ( - $, - utils, - dropdowns -) { - "use strict"; - - var checkComputeMaintenance = function (system, partition) { - const systemInfo = getSystemInfo(); - const interactivePartitions = (systemInfo[system] || {}).interactivePartitions || []; - if (!interactivePartitions.includes(partition)) { - const systemUpper = system.replace('-', '').toUpperCase(); - if ((window.systemsHealth[systemUpper] || 0) >= (window.systemsHealth.threshold.compute || 40)) return true; - else return false; - } - } - - var checkIfAvailable = function (id, options) { - var reason = "due to "; - var reason_broken_lab = "This lab is broken.\nPlease delete and recreate."; - - // Check if system is not available due to incident - const systemUpper = options["system"].replace('-', '').toUpperCase(); - if ((window.systemsHealth[systemUpper] || 0) >= (window.systemsHealth.threshold.interactive || 50)) { - reason += "maintenance"; - utils.setLabAsNA(id, reason); - return false; - } - - // Check if system is not available due to groups - const dropdownOptions = getDropdownOptions(); - const service = getService(options); - const systemInfo = getSystemInfo(); - const system = options["system"]; - const flavor = options["flavor"]; - const account = options["account"]; - const project = options["project"]; - const partition = options["partition"]; - const reservation = options["reservation"]; - const nodes = options["nodes"]; - const runtime = options["runtime"]; - const gpus = options["gpus"]; - const xserver = options["xserver"]; - - if (service == undefined) { - utils.setLabAsNA(id, reason_broken_lab); - return false; - } - if (!(service in dropdownOptions)) { - reason += "service version"; - utils.setLabAsNA(id, reason); - return false; - } - if (system !== undefined) { - if (dropdownOptions[service] == undefined) { - utils.setLabAsNA(id, reason_broken_lab); - return false; - } - if (!(system in dropdownOptions[service])) { - reason += "system"; - utils.setLabAsNA(id, reason); - return false; - } - if (!(system in systemInfo)) { - reason += "system"; - utils.setLabAsNA(id, reason); - return false; - } - } - if (flavor !== undefined) { - let systemFlavors = window.flavorInfo[system] || {}; - if (!(flavor in systemFlavors)) { - reason += "flavor"; - utils.setLabAsNA(id, reason); - return false; - } - let flavorDescription = systemFlavors[flavor]; - let spawnerState = window.spawnActive[id]; - if (flavorDescription.max != -1 && (flavorDescription.current || 0) >= flavorDescription.max && !spawnerState) { - reason += "flavor limits"; - utils.setLabAsNA(id, reason); - return false; - } - } - if (account !== undefined) { - if (dropdownOptions[service][system] == undefined) { - utils.setLabAsNA(id, reason_broken_lab); - return false; - } - if (!(account in dropdownOptions[service][system])) { - reason += "account"; - utils.setLabAsNA(id, reason); - return false; - } - } - if (project !== undefined) { - if (dropdownOptions[service][system][account] == undefined) { - utils.setLabAsNA(id, reason_broken_lab); - return false; - } - if (!(project in dropdownOptions[service][system][account])) { - reason += "project"; - utils.setLabAsNA(id, reason); - return false; - } - } - if (partition !== undefined) { - if (dropdownOptions[service][system][account][project] == undefined) { - utils.setLabAsNA(id, reason_broken_lab); - return false; - } - if (!(partition in dropdownOptions[service][system][account][project])) { - reason += "partition"; - utils.setLabAsNA(id, reason); - return false; - } - // Only compute nodes are not available during rolling updates - if (checkComputeMaintenance(system, partition)) { - reason += "maintenance"; - utils.setLabAsNA(id, reason); - return false; - } - } - if (reservation !== undefined && reservation != "None") { - if (dropdownOptions[service][system][account][project][partition] == undefined) { - utils.setLabAsNA(id, reason_broken_lab); - return false; - } - let setFalse = true; - for (const reservation_dict of dropdownOptions[service][system][account][project][partition]) { - if (reservation == reservation_dict.ReservationName) { - if (reservation_dict.State == "ACTIVE") { - setFalse = false; - break; - } - } - } - if (setFalse) { - reason += "reservation"; - utils.setLabAsNA(id, reason); - return false; - } - } - // Resources - const resourceInfo = getResourceInfo(); - const partitionResources = ((resourceInfo[service] || {})[system] || {})[partition] || {}; - if (nodes !== undefined) { - if (partitionResources.nodes == undefined) { - utils.setLabAsNA(id, reason_broken_lab); - return false; - } - let min = (partitionResources.nodes.minmax || [0, 1])[0]; - let max = (partitionResources.nodes.minmax || [0, 1])[1]; - if (nodes < min || nodes > max) { - reason += "number of nodes"; - utils.setLabAsNA(id, reason); - return false; - } - } - if (gpus !== undefined) { - if (partitionResources.gpus == undefined) { - utils.setLabAsNA(id, reason_broken_lab); - return false; - } - let min = (partitionResources.gpus.minmax || [0, 1])[0]; - let max = (partitionResources.gpus.minmax || [0, 1])[1]; - if (gpus < min || gpus > max) { - reason += "number of GPUs"; - utils.setLabAsNA(id, reason); - return false; - } - } - if (runtime !== undefined) { - if (partitionResources.runtime == undefined) { - utils.setLabAsNA(id, reason_broken_lab); - return false; - } - let min = (partitionResources.runtime.minmax || [0, 1])[0]; - let max = (partitionResources.runtime.minmax || [0, 1])[1]; - if (runtime < min || runtime > max) { - reason += "runtime"; - utils.setLabAsNA(id, reason); - return false; - } - } - if (xserver !== undefined) { - if (!("xserver" in partitionResources)) { - reason += "XServer"; - utils.setLabAsNA(id, reason); - return false; - } - } - - return true; - } - - var setUserOptions = function (id, options, available) { - const name = options["name"]; - const service = getService(options); - const system = options["system"]; - - // customDockerImage - const image = options["image"]; - const dockerregistry = options["dockerregistry"]; - const userdata_path = options["userdata_path"]; - - // default - const flavor = options["flavor"]; - const account = options["account"]; - const project = options["project"]; - const partition = options["partition"]; - const reservation = options["reservation"]; - const nodes = options["nodes"]; - const runtime = options["runtime"]; - const gpus = options["gpus"]; - const xserver = options["xserver"]; - const modules = options["userModules"]; - - // repo2Docker values - const r2dType = options["type"]; - const r2dRepo = options["repo"]; - const r2dGitref = options["gitref"] - const r2dNotebook = options["notebook"] - const r2dNotebookType = options["notebook_type"] - - function _updateDockerRegistryFields(id, value){ - $(`#${id}-image-private-cb-input`)[0].checked = true; - // Decode the value of the dockerRegistry. It comes encoded in base64 from the backend - let privateRegistryString = atob(value); - let privateRegistry = JSON.parse(privateRegistryString); - let auths = Object.values(privateRegistry)[0]; // returns a dictionary in the form of {"registry_url" : {"username": <username>, "password": <password>}} - let url = Object.keys(auths)[0]; - $(`#${id}-image-private-url-input`).val(url); - $(`#${id}-image-private-user-input`).val(auths[url]["username"]); - $(`#${id}-image-private-pass-input`).val(auths[url]["password"]); - } - - $(`#${id}-name-input`).val(name); - let registryAuthsInputDivs = $(`#${id}-image-private-url-input-div,#${id}-image-private-user-input-div, #${id}-image-private-pass-input-div`); - if (available) { - /* Set allowed values. Do not rely on change events here as without - passing a value explicitely, the first allowed option would be - chosen regardless of the user option value. */ - try { - dropdowns.updateServices("JupyterLab", id, service); - if (image) $(`#${id}-image-input`).val(image); - if (userdata_path) $(`#${id}-image-mount-input`).val(userdata_path); - if (dockerregistry){ - _updateDockerRegistryFields(id, dockerregistry); - } - else { - registryAuthsInputDivs.hide(); - } - if (r2dRepo) $(`#${id}-repo-input`).val(r2dRepo); - if (r2dGitref) $(`#${id}-gitref-input`).val(r2dGitref); - if (r2dNotebook) $(`#${id}-notebook-input`).val(r2dNotebook); - dropdowns.updateSystems(id, service, system); - dropdowns.updateFlavors(id, service, system, flavor); - dropdowns.updateAccounts(id, service, system, account); - dropdowns.updateProjects(id, service, system, account, project); - dropdowns.updatePartitions(id, service, system, account, project, partition); - dropdowns.updateReservations(id, service, system, account, project, partition, reservation); - dropdowns.updateResources(id, service, system, account, project, partition, nodes, gpus, runtime, xserver); - dropdowns.updateModules(id, service, system, account, project, partition, modules); - dropdowns.updateR2dType(id, r2dType); - dropdowns.updateR2dNotebookTypes(id, r2dNotebookType); - } - catch (e) { utils.setLabAsNA(id, "due to a JS error"); - console.log(e) - } - } - else { - function _setSelectOption(key, value, displayValue) { - if (!displayValue) displayValue = value; - if (value) $(`#${id}-${key}-select`).append(`<option value="${value}">${displayValue}</option>`); - else $(`#${id}-${key}-select-div`).hide(); - dropdowns.updateLabConfigSelect($(`#${id}-${key}-select`), value); - } - - function _setInputValue(key, value) { - if (value) $(`#${id}-${key}-input`).val(value); - else $(`#${id}-${key}-input-div`).hide(); - } - - $(`input[id*=${id}], select[id*=${id}]`).addClass("no-update"); - - const serviceInfo = getServiceInfo(); - let serviceName = (serviceInfo.JupyterLab.options[service] || {}).name || service; - - // Selects which are always visible - $(`#${id}-version-select`).append(`<option value="${service}">${serviceName}</option>`); - _setSelectOption("system", system); - _setInputValue("image", image); - if (userdata_path) { - $(`#${id}-image-mount-cb-input-div`)[0].checked = true; - $(`#${id}-image-mount-cb-input-div`).show(); - } - else { - $(`#${id}-image-mount-cb-input-div`)[0].checked = false; - $(`#${id}-image-mount-cb-input-div`).hide(); - } - if (dockerregistry) { - $(`#${id}-image-private-cb-input-div`)[0].checked = true; - $(`#${id}-image-private-cb-input-div`).show(); - _updateDockerRegistryFields(id, dockerregistry); - } - else { - $(`#${id}-image-private-cb-input-div`)[0].checked = false; - // $(`#${id}-image-private-cb-input-div`).hide(); - registryAuthsInputDivs.hide(); - } - - _setInputValue("image-mount", userdata_path); - _setSelectOption("flavor", flavor, ((window.flavorInfo[system] || {})[flavor] || {}).display_name); - utils.createFlavorInfo(id, system); - _setSelectOption("account", account); - _setSelectOption("project", project); - let maintenance = checkComputeMaintenance(system, partition); - _setSelectOption("partition", maintenance ? `${partition} (in maintenance)` : partition); - - // Reservation - var hasReservationInfo = false; - if (reservation) { - const reservationInfo = getReservationInfo(); - const systemReservationInfo = reservationInfo[system] || []; - for (const info of systemReservationInfo) { - if (info.ReservationName == reservation) { - hasReservationInfo = true; - var inactive = (info.State == "INACTIVE"); - if (inactive) { - $(`#${id}-reservation-select`).append( - `<option value="${reservation}">${reservation} [INACTIVE]</option>` - ); - } - else { - $(`#${id}-reservation-select`).append( - `<option value="${reservation}">${reservation}</option>` - ); - } - $(`#${id}-reservation-select`).trigger("change"); - } - } - if (!hasReservationInfo) - $(`#${id}-reservation-select`).append(`<option value="${reservation}">${reservation}</option>`); - } - else { - $(`#${id}-reservation-select-div`).hide(); - $(`#${id}-reservation-hr`).hide(); - } - if (hasReservationInfo) $(`#${id}-reservation-info-div`).show() - else $(`#${id}-reservation-info-div`).hide(); - - // Resources - if ((nodes || runtime || gpus || xserver) !== undefined) { - _setInputValue("nodes", nodes); - _setInputValue("runtime", runtime); - _setInputValue("gpus", gpus); - // Don't have info about resources, so just never show the xserver checkbox - _setInputValue("xserver", xserver); - } - else { - $(`#${id}-resources-tab`).addClass("disabled"); - $(`#${id}-resources-tab`).hide(); - } - - // Modules - dropdowns.updateModules(id, service, system, account, project, partition, modules); - - // Disable all user input elements if N/A - $(`input[id*=${id}]`).attr("disabled", true); - $(`select[id*=${id}]`).not("[id*=log]").addClass("disabled"); - } - } - - var labConfigs = { - checkComputeMaintenance: checkComputeMaintenance, - checkIfAvailable: checkIfAvailable, - setUserOptions: setUserOptions, - } - - return labConfigs; - -}) \ No newline at end of file diff --git a/static/js/home/utils.js b/static/js/home/utils.js deleted file mode 100644 index 02c3a64..0000000 --- a/static/js/home/utils.js +++ /dev/null @@ -1,280 +0,0 @@ -define(["jquery", "jhapi",], function ( - $, - JHAPI -) { - "use strict"; - var base_url = window.jhdata.base_url; - var api = new JHAPI(base_url); - - const progressStates = { - "running": { - "text": "running", - "background": "bg-success", - "width": 100 - }, - "stop_failed": { - "text": "running (stop failed)", - "background": "bg-success", - "width": 100 - }, - "cancelling": { - "text": "cancelling...", - "background": "bg-danger", - "width": 100 - }, - "stopping": { - "text": "stopping...", - "background": "bg-danger", - "width": 100 - }, - "failed": { - "text": "last spawn failed", - "background": "bg-danger", - "width": 100 - }, - "reset": { - "text": "", - "background": "", - "width": 0 - } - } - - var parseJSON = function (inputString) { - try { - return JSON.stringify(JSON.parse(inputString), null, 2); - } catch (e) { - return inputString; - } - } - - var getId = function (element, slice_index = -2) { - let id_array = $(element).attr("id").split('-'); - let id = id_array.slice(0, slice_index).join('-'); - return id; - } - - var getSpecificValuesInTab = function(id, prefix) { - const values = {}; - $(`[id^="${id}"]`).filter('select, input').each(function() { - const id = $(this).attr('id'); - const suffixLength = $(this).prop("tagName").length + 1; - const key = id.substring(prefix.length, id.length - suffixLength); - if ($(this).is(':checkbox')) { - values[key] = $(this).is(':checked'); // Checkbox value - } else { - values[key] = $(this).val(); // Standard value - } - }); - return values; - } - - var getLabConfigSelectValues = function (id) { - - return { - "service": $(`select#${id}-version-select`).val(), - "system": $(`select#${id}-system-select`).val(), - "flavor": $(`select#${id}-flavor-select`).val(), - "account": $(`select#${id}-account-select`).val(), - "project": $(`select#${id}-project-select`).val(), - "partition": $(`select#${id}-partition-select`).val(), - "r2dtype": $(`select#${id}-type-select`).val(), - "r2dnotebooktype": $(`select#${id}-notebook_type-select`).val(), - } - } - - var setLabAsNA = function (id, reason) { - $(`#${id}-start-btn, #${id}-open-btn, #${id}-cancel-btn, #${id}-stop-btn`).addClass("disabled").hide(); - $(`#${id}-na-btn`).show(); - $(`#${id}-na-status`).html(1); - $(`#${id}-na-info`).html(reason).show(); - } - - var setSpawnActive = function (id, active) { - window.spawnActive[id] = active; - } - - var updateProgressState = function (id, state) { - $(`#${id}-progress-bar`) - .width(progressStates[state].width) - .removeClass("bg-success bg-danger") - .addClass(progressStates[state].background) - .html(""); - $(`#${id}-progress-info-text`).html(progressStates[state].text); - } - - var appendToLog = function (log, htmlMsg) { - try { htmlMsg = htmlMsg.replace(/ /g, ' '); } - catch (e) { return; } // Not a valid htmlMsg - // Only append if a log message has not been appended yet - var exists = false; - log.children().each(function (i, e) { - let logMsg = $(e).html(); - if (htmlMsg == logMsg) exists = true; - }) - if (!exists) - log.append($('<div class="log-div">').html(htmlMsg)); - } - - var updateSpawnEvents = function (spawnEvents, id) { - if (spawnEvents[id]["latest"].length) { - var re = /([0-9]+(-[0-9]+)+).*[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?/; - for (const [_, event] of spawnEvents[id]["latest"].entries()) { - const startMsg = event.html_message || event.message; - const startTimeMatch = re.exec(startMsg); - if (startTimeMatch) { - const startTime = startTimeMatch[0]; - // Have we already created a log entry for this time? - let logOptions = $(`#${id}-log-select option`); - let logTimes = $.map(logOptions, function (option) { - return option.value; - }); - // If yes, we do not need to do anything anymore - if (logTimes.includes(startTime)) return; - // Otherwise, save the current events to the startTime, - // update the log select and reset the "latest" events. - spawnEvents[id][startTime] = spawnEvents[id]["latest"]; - spawnEvents[id]["latest"] = []; - $(`#${id}-log-select`) - .append(`<option value="${startTime}">${startTime}</option>`) - .val("latest"); - break; - } - } - // We didn't manage to find a time, so update with no timestamp - if ((spawnEvents[id]["latest"].length)) { - spawnEvents[id]["previous"] = spawnEvents[id]["latest"]; - spawnEvents[id]["latest"] = []; - } - } - } - - var createFlavorInfo = function (id, system) { - $(`#${id}-flavor-info-div`).empty(); - - const systemFlavors = window.flavorInfo[system] || {}; - for (const [_, description] of Object.entries(systemFlavors).sort(([, a], [, b]) => (a["weight"] || 99) < (b["weight"] || 99) ? 1 : -1)) { - var current = description.current || 0; - var maxAllowed = description.max; - // Flavor not valid, so skip - if (maxAllowed == 0 || current < 0 || maxAllowed == null || current == null) continue; - - var bgColor = "bg-primary"; - // Infinite allowed - if (maxAllowed == -1) { - var progressTooltip = `${current} used`; - var maxAllowedLabel = '∞'; - if (current == 0) { - var currentWidth = 0; - var maxAllowedWidth = 100; - } - else { - var currentWidth = 20; - var maxAllowedWidth = 80; - } - } - else { - var progressTooltip = `${current} out of ${maxAllowed} used`; - var maxAllowedLabel = maxAllowed - current; - var currentWidth = current / maxAllowed * 100; - var maxAllowedWidth = maxAllowedLabel / maxAllowed * 100; - - if (maxAllowedLabel < 0) { - maxAllowedLabel = 0; - maxAllowedWidth = 0; - bgColor = "bg-danger"; - } - } - - var diagramHtml = ` - <div class="row align-items-center g-0 mt-4"> - <div class="col-4"> - <span>${description.display_name}</span> - <a class="lh-1 ms-3" style="padding-top: 1px;" - data-bs-toggle="tooltip" data-bs-placement="right" title="${description.description}"> - ${getInfoSvg()} - </a> - </div> - <div class="progress col ms-2 fw-bold" style="height: 20px;" - data-bs-toggle="tooltip" data-bs-placement="top" title="${progressTooltip}"> - <div class="progress-bar ${bgColor}" role="progressbar" style="width: ${currentWidth}%">${current}</div> - <div class="progress-bar bg-success" role="progressbar" style="width: ${maxAllowedWidth}%">${maxAllowedLabel}</div> - </div> - </div> - ` - $(`#${id}-flavor-info-div`).append(diagramHtml); - } - - // The lab has a flavor configured or is a new lab, but we could not get any flavor information - if (((window.userOptions[id] || {}).flavor || id == "new-jupyterlab") && $.isEmptyObject(systemFlavors)) { - var noFlavorsHtml = ` - <div class="row g-0 mt-3"> - <div class="col-4"></div> - <div class="col ms-2 fw-bold text-danger">No flavors could be fetched. Try logging out and back in to fix the issue.</div> - </div> - `; - $(`#${id}-flavor-info-div`).append(noFlavorsHtml); - } - } - - // Updates number of users in ther footer - var updateNumberOfUsers = function () { - api.api_request("usercount", { - success: function (data) { - // Get all systems from footer and track if updated - var systems = {}; - $("div[id^='ampel'").each((i, e) => { - let system = $(e).attr("id").split('-')[1]; - systems[system] = false; - }) - // Update systems with info from request - for (const [system, usercount] of Object.entries(data)) { - switch (system) { - case 'jupyterhub': - $("#jupyter-users").html(usercount); - systems['jupyter'] = true; - break; - case 'JSC-Cloud': - $(`#jsccloud-users`).html(usercount['total']); - systems['jsccloud'] = true; - break; - default: - $(`#${system.toLowerCase()}-users`).html(usercount['total']); - systems[`${system.toLowerCase()}`] = true; - var partitionInfos = ""; - for (const [partition, users] of Object.entries(usercount['partitions'])) { - partitionInfos += `\n${partition}: ${users}`; - } - $(`#${system.toLowerCase()}-users`) - .parents("[data-bs-toggle]") - .attr("data-bs-original-title", `Number of active servers${partitionInfos}`); - } - } - // If there was no info about a system, set running labs to 0 and reset tooltip - for (const [system, systemInfo] of Object.entries(systems)) { - if (systemInfo == false) { - $(`#${system}-users`).html(0); - $(`#${system.toLowerCase()}-users`) - .parents("[data-toggle]") - .attr("data-bs-original-title", `Number of active servers`); - } - } - } - }) - } - - var utils = { - parseJSON: parseJSON, - getId: getId, - getSpecificValuesInTab: getSpecificValuesInTab, - getLabConfigSelectValues: getLabConfigSelectValues, - setLabAsNA: setLabAsNA, - setSpawnActive: setSpawnActive, - updateProgressState: updateProgressState, - appendToLog: appendToLog, - updateSpawnEvents: updateSpawnEvents, - createFlavorInfo: createFlavorInfo, - updateNumberOfUsers: updateNumberOfUsers, - }; - - return utils; -}) \ No newline at end of file diff --git a/static/js/jhapi.js b/static/js/jhapi.js index 23f64e6..862696c 100755 --- a/static/js/jhapi.js +++ b/static/js/jhapi.js @@ -103,8 +103,7 @@ define(["jquery", "utils"], function ($, utils) { options = update(options, { type: "POST" }); options.data = JSON.stringify({ failed: true, - progress: 100, - html_message: "<details><summary>Start cancelled by user.</summary>You clicked the cancel button.</details>" + progress: 100 }); this.api_request( utils.url_path_join("users/progress/events", user, server_name), @@ -118,7 +117,7 @@ define(["jquery", "utils"], function ($, utils) { options.data = JSON.stringify({ failed: true, progress: 100, - html_message: "<details><summary>Start cancelled by user.</summary>You clicked the cancel button.</details>" + html_message: "<details><summary>Start cancelled.</summary></details>" }); this.api_request( utils.url_path_join("users/progress/events", user), @@ -218,18 +217,6 @@ define(["jquery", "utils"], function ($, utils) { ); }; - JHAPI.prototype.remove_2fa = function (user, options) { - options = options || {}; - options = update(options, { type: "DELETE", dataType: null }); - this.api_request(utils.url_path_join("2FA"), options); - }; - - JHAPI.prototype.activate_2fa = function (user, options) { - options = options || {}; - options = update(options, { type: "POST", dataType: null }); - this.api_request(utils.url_path_join("2FA"), options); - }; - JHAPI.prototype.shutdown_hub = function (data, options) { options = options || {}; options = update(options, { type: "POST" }); diff --git a/templates/footer.html b/templates/footer.html index dad8e1e..931ea13 100644 --- a/templates/footer.html +++ b/templates/footer.html @@ -17,7 +17,7 @@ <footer class="navbar mt-auto p-0"> <div id="footer-top" class="container-fluid justify-content-evenly p-4"> {#- We create a carousel to be able to show all systems in the footer #} - <div id="footerSystemsCarousel" class="carousel carousel-dark slide w-100" data-bs-ride="carousel" data-bs-interval="10000"> + <div id="footerSystemsCarousel" data-sse-usercount class="carousel carousel-dark slide w-100" data-bs-ride="carousel" data-bs-interval="10000"> <div id="carousel-inner" class="carousel-inner"> <!-- Carousel items will be injected here dynamically via JavaScript --> </div> @@ -57,9 +57,8 @@ {%- block script -%} <script type="text/javascript"> -require(["jquery", "home/utils"], function ( - $, - utils +require(["jquery"], function ( + $ ) { "use strict"; @@ -148,13 +147,16 @@ require(["jquery", "home/utils"], function ( } const ampelHtml = ` - <div id="ampel-${systemLower}" class="text-center"> + <div id="ampel-${systemLower}" class="text-center" + data-system=${system} + data-systemlower=${systemLower} + > <img class="ampel-img" src="${staticUrl}/images/footer/systems/${systemLower}.svg?v=${imgVersion}" /> <a id="ampel-${systemLower}-tooltip" href="https://status.jsc.fz-juelich.de/services/${systemId}" target="_blank" class="align-middle" - data-bs-toggle="tooltip" + data-bs-toggle="tooltip" data-bs-placement="top"> ${displayName} </a> @@ -259,18 +261,48 @@ require(["jquery", "home/utils"], function ( $(document).ready(function() { createCarouselPages(); updateSystemHoverTooltips(); - utils.updateNumberOfUsers(); }) - if (!(window.location.pathname.endsWith("home") || window.location.pathname.includes("spawn-pending"))) { - console.log("setup SSE") - let userSpawnerNotificationUrl = `${jhdata.base_url}api/users/${jhdata.user}/notifications/spawners?_xsrf=${window.jhdata.xsrf_token}`; - evtSourcesGlobal["footer"] = new EventSource(userSpawnerNotificationUrl); - evtSourcesGlobal["footer"].onmessage = (e) => { - utils.updateNumberOfUsers(); - }; - } - + + $(`[data-sse-usercount]`).on("sse", function (event, data) { + var systems = {}; + $("div[id^='ampel']").each((i, e) => { + let system = $(e).attr("id").split('-')[1]; + systems[system] = false; + }) + // Update systems with info from request + for (const [system, usercount] of Object.entries(data)) { + switch (system) { + case 'jupyterhub': + $("#jupyter-users").html(usercount); + systems['jupyter'] = true; + break; + case 'JSC-Cloud': + $(`#jsccloud-users`).html(usercount['total']); + systems['jsccloud'] = true; + break; + default: + $(`#${system.toLowerCase()}-users`).html(usercount['total']); + systems[`${system.toLowerCase()}`] = true; + var partitionInfos = ""; + for (const [partition, users] of Object.entries(usercount['partitions'])) { + partitionInfos += `\n${partition}: ${users}`; + } + $(`#${system.toLowerCase()}-users`) + .parents("[data-bs-toggle]") + .attr("data-bs-original-title", `Number of active servers${partitionInfos}`); + } + } + // If there was no info about a system, set running labs to 0 and reset tooltip + for (const [system, systemInfo] of Object.entries(systems)) { + if (systemInfo == false) { + $(`#${system}-users`).html(0); + $(`#${system.toLowerCase()}-users`) + .parents("[data-toggle]") + .attr("data-bs-original-title", `Number of active servers`); + } + } + }); }) </script> {%- endblock -%} diff --git a/templates/header.html b/templates/header.html index 69698b3..cd2ab61 100644 --- a/templates/header.html +++ b/templates/header.html @@ -34,6 +34,9 @@ <div class="d-flex"> {%- if user %} <li class="nav-item"><a id="{{prefix}}start-nav-item" class="nav-link text-decoration-none" href="{{ base_url }}home">JupyterLab</a></li> + {%- if auth_state and "geant:dfn.de:fz-juelich.de:jsc:jupyter:workshop_instructors" in auth_state.get("groups", []) %} + <li class="nav-item"><a id="{{prefix}}workshop-manage-nav-item" class="nav-link text-decoration-none" href="{{ base_url }}workshopmanager">Manage Workshops</a></li> + {%- endif -%} {%- if user.admin %} <li class="nav-item"><a id="{{prefix}}admin-nav-item" class="nav-link text-decoration-none" href="{{ base_url }}admin">Admin</a></li> {%- endif -%} diff --git a/templates/home.html b/templates/home.html index b37808a..81eea44 100644 --- a/templates/home.html +++ b/templates/home.html @@ -1,499 +1,55 @@ {%- extends "page.html" -%} -{%- import "macros/home.jinja" as home -%} -{%- import "macros/svgs.jinja" as svg -%} {%- block stylesheet -%} <link rel="stylesheet" href='{{static_url("css/home.css")}}' type="text/css"/> {%- endblock -%} -{#- Set some convenience variables -#} -{%- set lab_spawners = [] -%} -{%- for s in spawners -%} - {%- if s.user_options -%} - {%- if ( - "profile" in s.user_options - and s.user_options.get("profile").startswith("JupyterLab") - ) - or ( - "service" in s.user_options - and s.user_options.get("service").startswith("JupyterLab") - ) - -%} - {%- do lab_spawners.append(s) -%} - {%- endif -%} - {%- endif -%} -{%- endfor -%} -{%- set software_key = "JupyterLab" %} -{%- set software_prefix = "jupyterlab" %} -{%- set new = "new" -%} {# id for new software entry #} - {%- block main -%} -<div class="container-fluid p-4"> - {#- ANNOUNCEMENT #} - {%- if custom_config.get("announcement", {}).get("show", False) %} - {{ home.create_announcement(custom_config) }} - {%- endif -%} - {#- REAUTHENTICATE #} - {%- if auth_state and auth_state.get("reauthenticate", False) %} - <div class="alert bg-info alert-dismissible fade show" style="color: #023d6b;" role="alert"> - <span class="align-middle"> Your access to the HPC-systems has been updated. <a style="text-decoration-line: underline; "href={{base_url}}logout?alldevices=false&stop=false&next=oauth_login> Please click here to reload. </a></span> - <button type="button" class="btn-close" data-bs-dismiss="alert"></button> - </div> - {%- endif -%} - - {#- TABLE #} - <p>You can configure your existing JupyterLabs by expanding the corresponding table row.</p> - - <div class="table-responsive-md"> - <table id="jupyterlabs-table" class="table table-bordered table-striped table-hover table-light align-middle"> - {#- TABLE HEAD #} - <thead class="table-secondary"> - <tr> - <th scope="col" width="1%"></th> - <th scope="col" width="20%">Name</th> - <th scope="col">Configuration</th> - <th scope="col" width="10%;">Status</th> - <th scope="col" width="10%;">Actions</th> - </tr> - </thead> - {#- TABLE BODY #} - <tbody> - {#- New JupyterLab row #} - <tr data-server-id="{{ software_prefix }}-{{ new }}" class="new-spawner-tr summary-tr"> - <th scope="col" class="details-td"> - <div class="d-flex mx-4"> - <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-plus-lg m-auto" viewBox="0 0 16 16"> - <path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z"/> - </svg> - </div> - </th> - <th scope="row" colspan="100%" class="text-center">New JupyterLab</th> - </tr> - {{ home.create_collapsible_tr(software_key, software_prefix, None, {}, custom_config, lab_id=new) }} - {#- Existing JupyterLab rows -#} - {#- By looping through lab_spawners #} - {# {%- for spawner in lab_spawners -%} - {%- set spawner_name = user.spawners[spawner.name].name -%} - {%- set user_options = spawner.user_options -%} - {{ home.create_summary_tr(software_key, software_prefix, spawner_name, user_options) }} - {{ home.create_collapsible_tr(software_key, software_prefix, spawner_name, user_options, custom_config) }} - {%- endfor %} #} - </tbody> - </table> - </div> {#- table responsive #} -</div> {#- container fluid #} -{%- endblock -%} - +{%- import "macros/table/config/home.jinja" as config %} +{%- import "macros/svgs.jinja" as svg -%} +{%- import "macros/table/variables.jinja" as vars with context %} -{%- block script -%} -<script> -{#- Manually sets some cancel related variables -#} -{%- set cancel_progress_activation = 0 -%} -{#- Percentage when cancel should be disabled again - since it is already in progress -#} -{%- set cancel_progress_deactivation = 99 %} +{%- set pagetype = vars.pagetype_home %} -{#- Save some global variables #} -var evtSources = {}; -var userOptions = {}; -var spawnEvents = {}; +{%- set table_rows = {vars.first_row_id: {}} %} -{%- for spawner in lab_spawners -%} - {%- set userOptions = decrypted_user_options[spawner.name] or {} -%} -userOptions["{{spawner.name}}"] = {{ userOptions | tojson }}; - {%- if spawner.state and spawner.state.get("events") %} -spawnEvents["{{spawner.name}}"] = {{ spawner.state.get("events") | tojson }}; - {%- else -%} - {#- We still want to show a "latest" entry for the log dropdown, - so we manually create an entry for spawners without events #} -spawnEvents["{{spawner.name}}"] = {"latest": []}; - {%- endif -%} +{%- for spawner in user.spawners.values() %} {%- endfor %} -var spawnActive = {}; -{% for spawner in user.spawners.values() -%} - {% if spawner.name -%} - {% if spawner.pending -%} - spawnActive["{{spawner.name}}"] = "pending"; - {% elif spawner.ready -%} - spawnActive["{{spawner.name}}"] = "ready"; - {% else -%} - spawnActive["{{spawner.name}}"] = false; - {%- endif %} +{%- for spawner in spawners %} + {%- if ( spawner.name and spawner.name in user.spawners.keys() ) or + ( spawner.user_options and spawner.user_options.get("name", false) ) %} + {%- set _ = table_rows.update({spawner.name: spawner.user_options}) %} {%- endif %} {%- endfor %} -var systemsHealth = { -threshold: { - interactive: {{custom_config.get("incidentCheck", {}).get("healthThreshold", {}).get("interactive", 50) | int}}, - compute: {{custom_config.get("incidentCheck", {}).get("healthThreshold", {}).get("compute", 40) | int}} - } -}; -{%- for system, system_info in incidents.items() %} -systemsHealth["{{system}}"] = {{ system_info.health }}; -{%- endfor %} - -var flavorInfo = {{ outpostflavors | tojson }}; -</script> - -<script> -/* (Mostly) Jinja dependent functions: - * Define get funtions to enable the use of jinja variables (supplied by the backend) within the JavaScript classes (otherwise they are not reachable) - */ -function getHostname() { - return {{hostname | tojson}} || {}; -} - -function getEntitlements() { - return {{auth_state.get("entitlements", []) | tojson}} || []; -} - -function getMapSystems() { - return {{custom_config.get("mapSystems", {}) | tojson }}; -} - -function getDropdownOptions() { - return {{auth_state.get("options_form", {}).get("dropdown_list", {}) | tojson}}; -} - -function getService(options) { - if ("profile" in options) - return options["profile"].split('/')[1]; - else - return options["service"].split('/')[1]; -} - -function getServiceInfo() { - return {{custom_config.get("services") | tojson}} || {}; -} - -function getShareInfo() { - return {{custom_config.get("share") | tojson}} || {}; -} - -function getBinderRepos() { - return {{custom_config.get("binderRepos") | tojson}} || {}; -} - - -function getBackendServiceInfo() { - return {{custom_config.get("backendServices") | tojson}} || {}; -} - -function getSystemInfo() { - return {{custom_config.get("systems") | tojson}} || {}; -} - -function getReservationInfo() { - return {{auth_state.get("options_form").get("reservations") | tojson}} || {}; -} - -function getResourceInfo() { - return {{auth_state.get("options_form").get("resources") | tojson}} || {}; -} +{%- from "macros/table/table.jinja" import tables with context %} +{%- import "macros/table/content.jinja" as functions with context %} +<div id="toastContainer" class="position-fixed top-0 end-0 p-3"></div> +{{ tables( + config.frontend_config, + functions.home_description, + functions.home_headerlayout, + functions.home_defaultheader, + functions.home_firstheader, + functions.row_content, + { + "stop": "workshopButtonStop", + "start": "workshopButtonStart", + "open": "homeOpen", + "cancel": "workshopButtonCancel", + "del": "homeButtonDelete" + }, + functions.sse_functions +) }} -function getModuleInfo() { - return {{custom_config.get("userModules", {}) | tojson}} || {}; -} -function getInfoSvg() { - return `{{ svg.info_svg | safe }}`; -} -function getLinkSvg() { - return `{{ svg.link_svg | safe }}`; -} - -function get_4_2_system(kwargs) { - return ["JUSYSTEM1", "JUSYSTEM2", "JSC-Cloud"]; -} - -function onEvtMessage(event, id) { - function _updateProgress(infoText, background="", html="") { - $(`#${id}-progress-bar`) - .width(100) - .removeClass("bg-success bg-danger") - .addClass(background) - .html(html); - $(`#${id}-progress-info-text`).html(infoText); - } - - const evt = JSON.parse(event.data); - spawnEvents[id]["latest"].push(evt); - let tr = $(`.summary-tr[data-server-id=${id}]`); - if (evt.progress !== undefined && evt.progress != 0) { - if (evt.progress == 100) { // Spawn finished - evtSources[id].close(); - delete evtSources[id]; - if (evt.failed) { // Spawn failed - spawnActive[id] = false; - // All other UI updates all handled by the evtSourcesGlobal["home"] - // so that they happend after the stop has finished in the backend - } - else if (evt.ready) { // Spawn successful - spawnActive[id] = "ready"; - _updateProgress("running", "bg-success"); - _updateLabButtons(id, true); - } - } - else { // Spawn in progress - spawnActive[id] = "pending"; - let collapsibleTr = $(`.collapsible-tr[data-server-id=${id}]`); - let collapseBtns = collapsibleTr.find("button").not(".nav-link"); - collapseBtns.addClass("disabled"); - if (evt.progress == {{cancel_progress_deactivation}}) { - _updateProgress("cancelling...", "bg-danger", `<b>${evt.progress}%</b>`) - tr.find(".btn-cancel-lab").addClass("disabled"); - } - else { - _updateProgress("spawning...", "", `<b>${evt.progress}%</b>`) - if (evt.progress >= {{cancel_progress_activation}} - && evt.progress < {{cancel_progress_deactivation}}) { - tr.find(".btn-cancel-lab").removeClass("disabled"); - } - } - } - } - - if (evt.html_message !== undefined) { - var htmlMsg = evt.html_message - } else if (evt.message !== undefined) { - var htmlMsg = evt.message; - } - if (htmlMsg) { - // Only append if latest log is selected - if ($(`#${id}-log-select`).val() == "latest") { - // appendToLog($(`#${id}-log`), htmlMsg); - try { htmlMsg = htmlMsg.replace(/ /g, ' '); } - catch (e) { return; } // Not a valid htmlMsg - // Only append if a log message has not been appended yet - var exists = false; - $(`#${id}-log`).children().each(function (i, e) { - let logMsg = $(e).html(); - if (htmlMsg == logMsg) exists = true; - }) - if (!exists) - $(`#${id}-log`).append($('<div class="log-div">').html(htmlMsg)); - } - } -} - -function _updateLabButtons(id, running) { - let tr = $(`.summary-tr[data-server-id=${id}]`); - let collapsibleTr = $(`.collapsible-tr[data-server-id=${id}]`); - let collapseBtns = collapsibleTr.find("button").not(".nav-link"); - if (running) { - // Show open/cancel for starting labs - tr.find(".btn-na-lab, .btn-start-lab, .btn-cancel-lab") - .addClass("disabled") - .hide(); - tr.find(".btn-open-lab, .btn-stop-lab") - .removeClass("disabled") - .show(); - } - else { - // Show start or na for non-running labs - var na = tr.find(".na-status").text() || 0; - if (na != "0") { - tr.find(".btn-na-lab").removeClass("disabled").show(); - tr.find(".btn-start-lab").addClass("disabled").hide(); - } - else { - tr.find(".btn-na-lab").hide() - tr.find(".btn-start-lab").removeClass("disabled").show(); - } - tr.find(".btn-open-lab, .btn-cancel-lab, .btn-stop-lab") - .addClass("disabled").hide(); - } - collapseBtns.removeClass("disabled"); -} -</script> - -<script> -require(["jquery", "jhapi", "home/utils", "home/dropdown-options", "home/lab-configs"], function ( - $, - JHAPI, - utils, - dropdowns, - lab -) { - "use strict"; - - var base_url = window.jhdata.base_url; - var api = new JHAPI(base_url); - - /* - On page load - */ - $(document).ready(function() { - const sharedOptions = JSON.stringify({{ spawner_options_form_values | safe }}); - if (sharedOptions) { - const sharedOp = JSON.parse(sharedOptions); - let id = "{{ server_name }}"; - let available = lab.checkIfAvailable(id, sharedOp); - lab.setUserOptions(id, sharedOp, available); - } - else { - {# for (const id of Object.keys(userOptions)) { - let available = lab.checkIfAvailable(id, userOptions[id]); - lab.setUserOptions(id, userOptions[id], available); - } - updateSpawnProgress(); -#} - - {# Set initial value, which will trigger to fill other dropdowns #} - const defaultOptionTab = "{{ custom_config.get("services", {}).get(software_key, {}).get("frontend", {}).get("versionsSelect", {}).get("tab", "") }}"; - const defaultOptionKey = "{{ custom_config.get("services", {}).get(software_key, {}).get("frontend", {}).get("versionsSelect", {}).get("key", "") }}"; - const defaultOptionValue = "{{ custom_config.get("services", {}).get(software_key, {}).get("frontend", {}).get("versionsSelect", {}).get("defaultValue", "") }}"; - let select = $(`select[id*=${defaultOptionTab}-${defaultOptionKey}]`); - {%- for versionName, versionOptions in custom_config.get("services", {}).get(software_key, {}).get("options", {}).items() %} - select.append(`<option value="{{versionName}}">{{versionOptions.get("name", versionName)}}</option>`); - {%- endfor %} - select.val(defaultOptionValue); - setTimeout(() => { - select.trigger("change"); - }, 50); - - {# dropdowns.updateServices("{{ software_key }}", "{{ software }}-{{ new }}"); #} - - {# $("#{{ software }}-{{ new }}-log-select").prepend(`<option value="latest">latest</option>`); #} - } - - // Remove all tab warnings since initial changes shouldn't cause warnings - $("[id$=tab-warning]").addClass("invisible"); - }) - - // Add event source for user spawner events - let userSpawnerNotificationUrl = `${jhdata.base_url}api/users/${jhdata.user}/notifications/spawners?_xsrf=${window.jhdata.xsrf_token}`; - evtSourcesGlobal["home"] = new EventSource(userSpawnerNotificationUrl); - evtSourcesGlobal["home"].onmessage = (e) => { - const data = JSON.parse(e.data); - const spawning = data.spawning || []; - const stopped = data.stoppedall || []; - var spawnEvents = window.spawnEvents; - utils.updateNumberOfUsers(); - - // Create eventListeners for new labs if they don't exist - for (const id of spawning) { - utils.setSpawnActive(id, "pending"); - if (!(id in spawnEvents)) { - spawnEvents[id] = { "latest": [] }; - } - if (!(id in evtSources)) { - utils.updateSpawnEvents(spawnEvents, id); - - let progressUrl = `${window.jhdata.base_url}api/users/${window.jhdata.user}/servers/${id}/progress?_xsrf=${window.jhdata.xsrf_token}`; - evtSources[id] = new EventSource(progressUrl); - evtSources[id].onmessage = function (e) { - onEvtMessage(e, id); - } - // Reset progress bar and log for new spawns - $(`#${id}-progress-bar`) - .width(0).html("") - .removeClass("bg-danger bg-success"); - $(`#${id}-progress-info-text`).html(""); - $(`#${id}-log`).html(""); - // Update buttons to reflect pending state - let tr = $(`tr.summary-tr[data-server-id=${id}]`); - // _enableTrButtonsRunning - tr.find(".btn-na-lab, .btn-start-lab").hide(); - tr.find(".btn-open-lab, .btn-cancel-lab").show().addClass("disabled"); - } - } - - for (const id of stopped) { - if (!id) continue; // Filter out labs with no name - utils.updateProgressState(id, "reset"); - // Change buttons to start or N/A - _updateLabButtons(id, false); - } - // We have updated flavor information, check if we need to update any flavor UI elements - window.flavorInfo = data.outpostflavors || window.flavorInfo; - for (const id of Object.keys(userOptions)) { - const values = utils.getLabConfigSelectValues(id); - dropdowns.updateFlavors(id, values.service, values.system, values.flavor); - } - - const sharedOptions = JSON.stringify({{ spawner_options_form_values | safe }}); - if (sharedOptions) { - const sharedOp = JSON.parse(sharedOptions); - let id = sharedOp["name"]; - // const sharedValues = utils.getLabConfigSelectValues(id); - // dropdowns.updateFlavors(id, sharedValues.service, sharedValues.system, sharedValues.flavor); - } - else { - // The jinja2 variable "new" points to the id of a new JupyterLab, e.g. "new-jupyterlab" - const newLabValues = utils.getLabConfigSelectValues("{{ software }}-{{ new }}"); - dropdowns.updateFlavors("{{ software }}-{{ new }}", newLabValues.service, newLabValues.system, newLabValues.flavor); - } - }; - - /* - Jinja dependent function definitions - */ - function updateSpawnProgress() { - {%- for spawner in lab_spawners %} - var id = "{{spawner.name}}"; - var latestEvents = spawnEvents[id]["latest"] || []; - // Append log messages - for (const event of latestEvents) { - utils.appendToLog($(`#${id}-log`), event["html_message"]); - } - // Add options to log select and select the latest log by default - var logSelect = $(`#${id}-log-select`); - for (const log in spawnEvents[id]) { - if (log == "latest") logSelect.prepend(`<option value="${log}">${log}</option>`); - else logSelect.append(`<option value="${log}">${log}</option>`); - } - logSelect.val("latest"); - - {%- set s = user.spawners[spawner.name] -%} - {%- if s.active -%} - {%- if s.ready %} - utils.updateProgressState(id, "running"); - {%- elif not s._stop_pending %} - var tr = $(`#${id}.summary-tr`); - tr.find(".btn-open-lab").addClass("disabled"); - var currentProgress = 0; - if (latestEvents.length > 0) { - let lastEvent = latestEvents.slice(-1)[0]; - currentProgress = lastEvent.progress; - } - if (currentProgress < {{cancel_progress_activation}} - || currentProgress >= {{cancel_progress_deactivation}}) { - tr.find(".stop, .cancel").addClass("disabled"); - } - // Disable the delete button during the spawn process. - var collapse = $(`.collapsible-tr[data-server-id=${id}]`); - collapse.find(".btn-delete-lab").addClass("disabled"); - // Update progress with percentage also - $(`#${id}-progress-bar`) - .width(100) - .html(`<b>${currentProgress}%</b>`); - $(`#${id}-progress-info-text`).html("spawning..."); - // Add an event listener to catch and display updates. - var progressUrl = `${jhdata.base_url}api/users/${jhdata.user}/servers/${id}/progress?_xsrf=${window.jhdata.xsrf_token}`; - evtSources[id] = new EventSource(progressUrl); - {%- endif %} - {%- elif s._failed or s._cancel_event_yielded %} - var id = "{{s.name | safe}}"; - utils.updateProgressState(id, "failed"); - {%- endif -%} - - {%- endfor %} - - for (const id in evtSources) { - evtSources[id].onmessage = function (e) { - onEvtMessage(e, id); - } - } - } +{%- endblock -%} -}) -</script> -<script src='{{static_url("js/home/handle-events.js", include_version=True) }}' type="text/javascript" charset="utf-8"></script> -<script src='{{static_url("js/home/handle-servers.js", include_version=True) }}' type="text/javascript" charset="utf-8"></script> -<script> -$("nav [id$=nav-item]").removeClass("active"); -$("#start-nav-item, #collapse-start-nav-item").addClass("active"); -</script> -{%- endblock -%} +{%- block script -%} +{%- import "macros/table/variables.jinja" as vars with context %} +{%- set pagetype = vars.pagetype_home %} +{%- import "macros/table/config/home.jinja" as config with context %} +{%- include "macros/table/elements_js.jinja" with context %} +{%- endblock %} diff --git a/templates/macros/home.jinja b/templates/macros/home.jinja index be375fc..618800c 100644 --- a/templates/macros/home.jinja +++ b/templates/macros/home.jinja @@ -12,12 +12,12 @@ </div> {%- endmacro -%} -{%- macro create_summary_tr(software_key, software_prefix, spawner_name, user_options, lab_id="", share_structure=false) -%} +{%- macro create_summary_tr(s, user_options, lab_id="", share_structure=false) -%} {%- set name = user_options.get("name", "") -%} -{%- if lab_id != "" %} {%- set id = software_prefix ~ "-" ~ lab_id -%} -{%- elif spawner_name %} {%- set id = software_prefix ~ "-" ~ spawner_name -%} -{%- else %} {%- set id = software_prefix ~ "-new-jupyterlab" -%} +{%- if lab_id != "" %} {%- set id = lab_id -%} +{%- elif s %} {%- set id = s.name -%} +{%- else %} {%- set id = "new-jupyterlab" -%} {%- endif -%} {%- set system = user_options.get("system", "") -%} @@ -120,103 +120,175 @@ </td> {%- endmacro -%} -{%- macro create_collapsible_tr(software_key, software_prefix, spawner_name, user_options, custom_config, lab_id="", share_structure=false) -%} +{%- macro create_collapsible_tr(s, user_options, custom_config, lab_id="", share_structure=false) -%} {%- set name = user_options.get("name", "") -%} -{%- set new_lab_id = software_prefix ~ "-new" %} -{%- if lab_id != "" %} {%- set id = software_prefix ~ "-" ~ lab_id -%} -{%- elif spawner %} {%- set id = software_prefix ~ "-" ~ spawner_name -%} -{%- else %} {%- set id = software_prefix ~ "-new" -%} +{%- set new_lab_id = "new-jupyterlab" %} +{%- if lab_id != "" %} {%- set id = lab_id -%} +{%- elif s %} {%- set id = s.name -%} +{%- else %} {%- set id = "new-jupyterlab" -%} {%- endif -%} <tr data-server-id="{{id}}" class="collapsible-tr" style="--bs-table-accent-bg: transparent;"> <td colspan="100%" class="p-0"> {#- Remove padding to hide td when collapsed #} <div class="collapse{% if share_structure %} show {% endif %}" id="{{id}}-collapse"> <div class="d-flex align-items-start m-3"> - - - {#- TAB NAV PILLS -#} {%- set nav_tab_margins = "mb-3" %} <div class="nav flex-column nav-pills p-3 ps-0" id="{{ id }}-tab" role="tablist"> - {#- In the custom config we defined the required sidebar navigations for this service - We create a tab + button for each of them, and fill them with all required values. - Later, we will se what we want to show / hide. - This will be updated, everytime an event is triggered. - #} - {%- for navsidebar in custom_config.services.get(software_key, {}).get("frontend", {}).get("navsidebar", []) -%} - {%- set counter = loop.index0 -%} - {%- for key, options in navsidebar.items() -%} - <button class="nav-link {{ nav_tab_margins }} {% if counter > 0 %}d-none{% endif %}" id="{{ id }}-{{ key }}-tab" data-bs-toggle="pill" data-bs-target="#{{ id }}-{{ key }}" type="button" role="tab"> - <span>{{ options.label }}</span> - <span id="{{id}}-resources-tab-warning" class="d-flex invisible"> - {{ svg.warning_svg | safe }} - <span class="visually-hidden">settings changed</span> - </span> - </button> - {%- endfor -%} - {%- endfor -%} + <button class="nav-link active {{ nav_tab_margins }}" id="{{ id }}-config-tab" data-bs-toggle="pill" data-bs-target="#{{ id }}-config" type="button" role="tab">Lab Config</button> + <button class="nav-link {{ nav_tab_margins }}" id="{{ id }}-resources-tab" data-bs-toggle="pill" data-bs-target="#{{ id }}-resources" type="button" role="tab"> + <span>Resources</span> + <span id="{{id}}-resources-tab-warning" class="d-flex invisible"> + {{ svg.warning_svg | safe }} + <span class="visually-hidden">settings changed</span> + </span> + </button> + <button class="nav-link {{ nav_tab_margins }}" id="{{ id }}-modules-tab" data-bs-toggle="pill" data-bs-target="#{{ id }}-modules" type="button" role="tab"> + <span>Kernels and Extensions</span> + <span id="{{id}}-modules-tab-warning" class="d-flex invisible"> + {{ svg.warning_svg | safe }} + <span class="visually-hidden">settings changed</span> + </span> + </button> {%- if id != new_lab_id %} - <button class="nav-link {{ nav_tab_margins }}" id="{{ id }}-logs-tab" data-bs-toggle="pill" data-bs-target="#{{ id }}-logs" type="button" role="tab">Logs</button> + <button class="nav-link {{ nav_tab_margins }}" id="{{ id }}-logs-tab" data-bs-toggle="pill" data-bs-target="#{{ id }}-logs" type="button" role="tab">Logs</button> {%- endif %} </div> - - - {#- TAB NAV CONTENT -#} - {#- We create all elements for all tabs, but everything will be hidden. - It will be filled with values later via JS, same goes for display/hidden #} + {#- We only create empty elements here as they will be filled via JS #} <div class="tab-content w-100" id="{{ id }}-tabContent"> - {# Create a block for each version of the software, always start with the general configuration of the software - We only add `tabs` which are also available in `navsidecar` #} - {%- for version_name, version_options in custom_config.services.get(software_key, {}).get("options", {}).items() %} - {%- for nav_item in custom_config.services.get(software_key, {}).get("frontend", {}).get("navsidebar", []) %} - {%- set counter = loop.index0 -%} - {%- for tab_key in nav_item.keys() %} - {%- set _id = id ~ "-" ~ version_name ~ "-" ~ tab_key %} - <div class="{% if counter > 0 %}d-none{% endif %} tab-pane fade show active" id="{{ _id }}" role="tabpanel"> - <form id="{{ _id }}-form"> - {# This blocks comes from the software.frontend configuration and is added to all versions #} - {%- for tabOption in custom_config.services.get(software_key, {}).get("frontend", {}).get("tabs", {}).get(tab_key, []) %} - {%- if tabOption.show is boolean %} - {%- set show = tabOption.show %} - {%- else %} - {%- set show = false %} - {%- endif %} - {%- if tabOption.type == "inputtext" %} - {{ inputs.create_text_input_2(_id, software_key, tab_key, tabOption.key, tabOption.options, tabOption.get("label", {}), show ) }} - {%- elif tabOption.type == "dropdown" %} - {{ inputs.create_select_2(_id, software_key, tab_key, tabOption.key, tabOption.options, tabOption.get("label", {}), true ) }} - {%- elif tabOption.type == "hr" %} - <hr> - {%- endif %} - {%- endfor %} - {# Version specific #} - <div id={{ _id }}-specific> - {# This block is specific for each version of the software - In the end we can simply show the right div and hide the others #} - {%- for tabOption in version_options.get("frontend", {}).get("tabs", {}).get(tab_key, {}).get("options", []) %} - {%- if tabOption.type == "dropdown" %} - {{ inputs.create_select_2(_id, software_key, tab_key, tabOption.key, tabOption.options, tabOption.label, true ) }} - {%- elif tabOption.type == "flavorsummary" %} - {{ create_flavor_summary(_id, software_key, tab_key, tabOption.key, tabOption.options, true ) }} - {%- elif tabOption.type == "number" %} - {{ inputs.create_number_input_2(_id, software_key, tab_key, tabOption.key, tabOption.options, tabOption.label, true ) }} - {%- elif tabOption.type == "reservationsummary" %} - {{ create_reservation_summary(_id, software_key, tab_key, tabOption.key, true) }} - {%- elif tabOption.type == "multiple_checkboxes" %} - {{ inputs.create_multiple_checkboxes(_id, software_key, tab_key, tabOption.key, tabOption.options, tabOption.label, custom_config, true ) }} - {%- endif %} - {%- endfor %} - </div> - </form> - {{ create_lab_config_buttons_2(_id, add_save_reset_buttons=true) }} - {#- {{ create_lab_config_buttons(id, id == new_lab_id or share_structure) }} #} + {#- Lab Config #} + <div class="tab-pane fade show active" id="{{ id }}-config" role="tabpanel"> + <form id="{{id}}-lab-config-form"> + {{ inputs.create_text_input(id, "name", placeholder="Give your lab a name") }} + {{ inputs.create_select(id, "version", custom_config.get("services").get("JupyterLab").get("optionsName", "Version")) }} + {#- Docker images #} + {%- call inputs.create_text_input(id, "image", + placeholder="e.g. jupyter/datascience-notebook", + warning="Please enter a valid docker image, e.g. jupyter/datascience-notebook", + persistent_hover=True) %} + A public registry docker image starting a single-user notebook server. + <a href='https://jupyter-docker-stacks.readthedocs.io/en/latest/using/selecting.html' target='_blank' style='color: white !important;'>Examples.</a> + {%- endcall %} + {#- Fields for private docker repository #} + {{ inputs.create_checkbox_input(id, "image-private-cb", "Private repository")}} + {%- call inputs.create_text_input(id, "image-private-url", "Image Repository", + placeholder="URL for your image registry", + pattern="^(([a-zA-Z0-9.\-]+)(:[0-9]+)?\/)?([a-zA-Z0-9._\-]+\/)*[a-zA-Z0-9._\-]+(:[a-zA-Z0-9._\-]+|@[A-Fa-f0-9]+(:[A-Fa-f0-9]+)*)?$", + warning="Please enter a valid docker repository URL") %} + {%- endcall %} + {%- call inputs.create_text_input(id, "image-private-user", "Username", + placeholder="Please enter your username") %} + Username for the private docker repository + {%- endcall %} + {{ inputs.create_password_input(id, "image-private-pass", "Password", placeholder="Please enter your password")}} + {#-----#} + {{ inputs.create_checkbox_input(id, "image-mount-cb", "Mount user data")}} + {%- call inputs.create_text_input(id, "image-mount", "User data path", + pattern="^\/[A-Za-z0-9\-\/]+", + warning="Please input a valid Unix-style path, e.g. /mnt/userdata") %} + Path to which your persistent user data will be mounted + {%- endcall %} + <hr> + {#- Repo2Docker fields #} + {{ inputs.create_select(id, key="type", label="Repository") }} + {{ inputs.create_text_input(id, key="repo", label="Repository URL", placeholder="GitHub repository name or URL") }} + {{ inputs.create_text_input(id, key="gitref", label="Git ref (branch,tag, or commit)", placeholder="HEAD") }} + {{ inputs.create_text_input(id, key="notebook", label="URL path to notebook (optional)", placeholder="URL path to notebook (optional)", clazz="optional") }} + {{ inputs.create_select(id, key="notebook_type", label="Notebook Type") }} + + {#- Standard HPC system fields #} + {{ inputs.create_select(id, "system") }} + {{ inputs.create_select(id, "flavor") }} + <div id="{{id}}-flavor-legend-div" class="row align-items-center g-0 mt-4"> + <span class="col-4 fw-bold">Available Flavors</span> + <div class="col d-flex align-items-center ms-2"> + {%- set box_style = "height: 15px; width: 15px; border-radius: 0.25rem;"%} + <div style="{{box_style}} background-color: #198754;"></div> + <span class="ms-1">= Free</span> + <span class="mx-2"></span> + <div style="{{box_style}} background-color: #023d6b;"></div> + <span class="ms-1">= Used</span> + <span class="mx-2"></span> + <div style="{{box_style}} background-color: #dc3545;"></div> + <span class="ms-1">= Limit exceeded</span> + </div> + </div> + <div id="{{id}}-flavor-info-div" class="mb-3"></div> + {{ inputs.create_select(id, "account") }} + {{ inputs.create_select(id, "project") }} + {{ inputs.create_select(id, "partition") }} + <hr id="{{id}}-reservation-hr"> + {{ inputs.create_select(id, "reservation") }} + <div id="{{id}}-reservation-info-div" class="row mb-3"> + {%- set reservation_info_classes = "col-4 fw-bold"%} + <div id="{{id}}-reservation-info" class="col-8 offset-4"> + <div class="row"> + <span class="{{ reservation_info_classes }}">Start Time:</span> + <span id="{{id}}-reservation-start" class="col-auto"></span> + </div> + <div class="row"> + <span class="{{ reservation_info_classes }}">End Time:</span> + <span id="{{id}}-reservation-end" class="col-auto"></span> + </div> + <div class="row"> + <span class="{{ reservation_info_classes }}">State:</span> + <span id="{{id}}-reservation-state" class="col-auto"></span> + </div> + <div class="mt-1"> + <details> + <summary class="fw-bold">Detailed reservation information:</summary> + <pre id="{{id}}-reservation-details"></pre> + {#- TODO: Fix horizontal width upon expanding the detail #} + </details> + </div> </div> - {%- set first_navitem = false %} + </div> + </form> + {{ create_lab_config_buttons(id, id == new_lab_id or share_structure) }} + </div> + {#- Lab Resources #} + <div class="tab-pane fade" id="{{ id }}-resources" role="tabpanel"> + <form id="{{id}}-resources-form"> + {{ inputs.create_number_input(id, "nodes") }} + {{ inputs.create_number_input(id, "gpus", "GPUs") }} + {{ inputs.create_number_input(id, "runtime", "Runtime (minutes)") }} + {{ inputs.create_checkbox_input(id, "xserver-cb", "Activate XServer")}} + {{ inputs.create_number_input(id, "xserver", "Use XServer GPU Index") }} + </form> + {{ create_lab_config_buttons(id, id == new_lab_id or share_structure) }} + </div> + {#- User-selected Modules #} + <div class="tab-pane fade" id="{{ id }}-modules" role="tabpanel"> + <form id="{{id}}-modules-form"> + {%- for module_set in custom_config.get("userModules", {}).keys() %} + <div id="{{id}}-{{module_set}}-div"> + <h4>{{ module_set | title}}</h4> + <div id="{{id}}-{{module_set}}-checkboxes-div" class="row g-0"></div> + </div> {%- endfor %} - {%- endfor %} - {%- endfor %} + </form> + <hr> + <div id="{{id}}-modules-selector-div" class="row g-0"> + {%- set module_cols = "col-sm-6 col-md-4 col-lg-3" %} + <div class="form-check {{module_cols}}"> + <input class="form-check-input module-selector" type="checkbox" id="{{id}}-modules-select-all"> + <label class="form-check-label" for="{{id}}-modules-select-all">Select all</label> + </div> + <div class="form-check {{module_cols}}"> + <input class="form-check-input module-selector" type="checkbox" id="{{id}}-modules-select-none"> + <label class="form-check-label" for="{{id}}-modules-select-none">Deselect all</label> + </div> + </div> + {{ create_lab_config_buttons(id, id == new_lab_id or share_structure) }} + </div> + {#- Lab Logs #} + <div class="tab-pane fade" id="{{ id }}-logs" role="tabpanel"> + {{ inputs.create_select(id, "log") }} + <div id="{{id}}-log" class="card card-body"></div> + {{ create_lab_config_buttons(id, id == new_lab_id or share_structure) }} + </div> </div> {#- End of tab content #} </div> {#- End of d-flex #} </div> {#- End of collapse #} @@ -224,100 +296,6 @@ </tr> {%- endmacro -%} -{%- macro create_share_modal(id) %} -<!-- Modal --> -<div class="modal fade" id="{{ id }}-share-link" role="dialog" tabindex="-1"> - <div class="modal-dialog modal-dialog-centered"> - - <!-- Modal content--> - <div class="modal-content"> - <div class="modal-header"> - <h4 class="modal-title">Share Lab</h4> - <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"> - </div> - <div class="modal-body"> - <p>Share your lab via URL</p> - <a href=""></a> - </div> - <div class="modal-footer"> - <button type="button" class="btn btn-default" data-bs-dismiss="modal">Close</button> - <button type="button" id="{{id}}-copy-btn" class="btn btn-outline-primary" data-bs-toggle="tooltip" data-bs-placement="top" title="Copy to clipboard">Copy</button> - </div> - </div> - - </div> -</div> -{%- endmacro %} - -{%- macro create_reservation_summary(id, software_key, tab_key, input_key, show) %} -<div id="{{id}}-{{input_key}}-reservation-info-div" class="row mb-3{% if not show %} d-none{% endif %}"> - {%- set reservation_info_classes = "col-4 fw-bold"%} - <div id="{{id}}-{{input_key}}-reservation-info" class="col-8 offset-4"> - <div class="row"> - <span class="{{ reservation_info_classes }}">Start Time:</span> - <span id="{{id}}-{{input_key}}-reservation-start" class="col-auto"></span> - </div> - <div class="row"> - <span class="{{ reservation_info_classes }}">End Time:</span> - <span id="{{id}}-{{input_key}}-reservation-end" class="col-auto"></span> - </div> - <div class="row"> - <span class="{{ reservation_info_classes }}">State:</span> - <span id="{{id}}-{{input_key}}-reservation-state" class="col-auto"></span> - </div> - <div class="mt-1"> - <details> - <summary class="fw-bold">Detailed reservation information:</summary> - <pre id="{{id}}-{{input_key}}-reservation-details"></pre> - {#- TODO: Fix horizontal width upon expanding the detail #} - </details> - </div> - </div> -</div> -{%- endmacro %} - -{%- macro create_flavor_summary(id, software_key, tab_key, input_key, options, show) -%} -<div id="{{id}}-{{input_key}}-flavor-legend-div" class="row align-items-center g-0 mt-4{% if not show %} d-none{% endif %}"> - <span class="col-4 fw-bold">{{ options.text }}</span> - <div class="col d-flex align-items-center ms-2"> - {%- set box_style = "height: 15px; width: 15px; border-radius: 0.25rem;"%} - <div style="{{box_style}} background-color: #198754;"></div> - <span class="ms-1">= Free</span> - <span class="mx-2"></span> - <div style="{{box_style}} background-color: #023d6b;"></div> - <span class="ms-1">= Used</span> - <span class="mx-2"></span> - <div style="{{box_style}} background-color: #dc3545;"></div> - <span class="ms-1">= Limit exceeded</span> - </div> -</div> -<div id="{{id}}-{{input_key}}-flavor-info-div" class="mb-3"></div> -{%- endmacro %} - -{%- macro create_lab_config_buttons_2(id, add_save_reset_buttons=true) -%} -<hr> -<div class="d-flex"> - {# everyone gets a share button, we decide later if we would like to show it #} - <button type="button" id="{{id}}-share-btn" class="btn btn-share-lab d-none" data-toggle="modal" data-target="#{{ id }}-share-link">{{ svg.share_svg | safe }} Share </button> - - {%- if add_save_reset_buttons %} - <button type="button" id="{{ id }}-save-btn" class="btn btn-success btn-save-lab me-2">{{ svg.save_svg | safe }} Save </button> - <button type="button" id="{{ id }}-reset-btn" class="btn btn-danger btn-reset-lab me-2">{{ svg.reset_svg | safe }} Reset</button> - {%- endif %} - - {# If Lab is N/A we will use this div to show information later #} - <button type="button" id="{{id}}-na-btn" class="btn btn-secondary btn-na-lab disabled ms-auto me-2" style="display: none;"> - {{ svg.na_svg | safe }} N/A - </button> - <div id="{{id}}-na-info" class="text-muted my-auto" style="display: none; font-size: smaller;"></div> - - - <button type="button" id="{{id}}-delete-btn" class="btn btn-danger btn-delete-lab ms-auto"> {{ svg.delete_svg | safe }} Delete</button> - - {{ create_share_modal(id) }} -</div> -{%- endmacro -%} - {%- macro create_lab_config_buttons(id, start_button_only=false) -%} <hr> <div class="d-flex"> diff --git a/templates/macros/svgs.jinja b/templates/macros/svgs.jinja index 0663de1..c32638c 100644 --- a/templates/macros/svgs.jinja +++ b/templates/macros/svgs.jinja @@ -2,6 +2,11 @@ <path d="M8 0c-.176 0-.35.006-.523.017l.064.998a7.117 7.117 0 0 1 .918 0l.064-.998A8.113 8.113 0 0 0 8 0zM6.44.152c-.346.069-.684.16-1.012.27l.321.948c.287-.098.582-.177.884-.237L6.44.153zm4.132.271a7.946 7.946 0 0 0-1.011-.27l-.194.98c.302.06.597.14.884.237l.321-.947zm1.873.925a8 8 0 0 0-.906-.524l-.443.896c.275.136.54.29.793.459l.556-.831zM4.46.824c-.314.155-.616.33-.905.524l.556.83a7.07 7.07 0 0 1 .793-.458L4.46.824zM2.725 1.985c-.262.23-.51.478-.74.74l.752.66c.202-.23.418-.446.648-.648l-.66-.752zm11.29.74a8.058 8.058 0 0 0-.74-.74l-.66.752c.23.202.447.418.648.648l.752-.66zm1.161 1.735a7.98 7.98 0 0 0-.524-.905l-.83.556c.169.253.322.518.458.793l.896-.443zM1.348 3.555c-.194.289-.37.591-.524.906l.896.443c.136-.275.29-.54.459-.793l-.831-.556zM.423 5.428a7.945 7.945 0 0 0-.27 1.011l.98.194c.06-.302.14-.597.237-.884l-.947-.321zM15.848 6.44a7.943 7.943 0 0 0-.27-1.012l-.948.321c.098.287.177.582.237.884l.98-.194zM.017 7.477a8.113 8.113 0 0 0 0 1.046l.998-.064a7.117 7.117 0 0 1 0-.918l-.998-.064zM16 8a8.1 8.1 0 0 0-.017-.523l-.998.064a7.11 7.11 0 0 1 0 .918l.998.064A8.1 8.1 0 0 0 16 8zM.152 9.56c.069.346.16.684.27 1.012l.948-.321a6.944 6.944 0 0 1-.237-.884l-.98.194zm15.425 1.012c.112-.328.202-.666.27-1.011l-.98-.194c-.06.302-.14.597-.237.884l.947.321zM.824 11.54a8 8 0 0 0 .524.905l.83-.556a6.999 6.999 0 0 1-.458-.793l-.896.443zm13.828.905c.194-.289.37-.591.524-.906l-.896-.443c-.136.275-.29.54-.459.793l.831.556zm-12.667.83c.23.262.478.51.74.74l.66-.752a7.047 7.047 0 0 1-.648-.648l-.752.66zm11.29.74c.262-.23.51-.478.74-.74l-.752-.66c-.201.23-.418.447-.648.648l.66.752zm-1.735 1.161c.314-.155.616-.33.905-.524l-.556-.83a7.07 7.07 0 0 1-.793.458l.443.896zm-7.985-.524c.289.194.591.37.906.524l.443-.896a6.998 6.998 0 0 1-.793-.459l-.556.831zm1.873.925c.328.112.666.202 1.011.27l.194-.98a6.953 6.953 0 0 1-.884-.237l-.321.947zm4.132.271a7.944 7.944 0 0 0 1.012-.27l-.321-.948a6.954 6.954 0 0 1-.884.237l.194.98zm-2.083.135a8.1 8.1 0 0 0 1.046 0l-.064-.998a7.11 7.11 0 0 1-.918 0l-.064.998zM4.5 7.5a.5.5 0 0 0 0 1h7a.5.5 0 0 0 0-1h-7z"/> </svg>'-%} +{%- set plus_svg = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-plus-lg m-auto" viewBox="0 0 16 16"> + <path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z"></path> +</svg>' +-%} + {%- set start_svg = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-play-fill" viewBox="0 0 16 16"> <path d="m11.596 8.697-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.696l6.363 3.692a.802.802 0 0 1 0 1.393z"></path> </svg>'-%} diff --git a/templates/macros/table/config/home.jinja b/templates/macros/table/config/home.jinja new file mode 100644 index 0000000..7526f81 --- /dev/null +++ b/templates/macros/table/config/home.jinja @@ -0,0 +1,722 @@ +{%- set frontend_config = { + "services": { + "default": "jupyterlab", + "options": { + "jupyterlab": { + "fillingOrder": ["option", "system", "account", "project", "partition"], + "navbar": { + "labconfig": { + "show": true, + "displayName": "Lab Config" + }, + "modules": { + "displayName": "Kernels and Extensions", + "dependency": { + "option": [ + "lmod" + ] + } + }, + "resources": { + "show": false, + "displayName": "Resources", + "trigger": { + "partition": "resourceButton" + }, + "dependency": { + "system": [ + "unicore" + ] + } + }, + "logs": { + "show": true, + "firstRow": false, + "displayName": "Logs" + } + }, + "tabs": { + "labconfig": { + "center": { + "name": { + "input": { + "type": "text", + "options": { + "collect": true, + "enabled": true, + "show": true, + "placeholder": "Give your lab a name" + } + }, + "label": { + "type": "text", + "width": 4, + "value": "Name" + }, + }, + "option": { + "input": { + "type": "select", + "options": { + "collect": true, + "show": true + } + }, + "label": { + "type": "text", + "value": "Select Version" + }, + "trigger": { + "init": "workshopManagerFillOptions" + } + }, + "hr1": { + "input": { + "type": "hr" + } + }, + "system": { + "input": { + "type": "select", + "options": { + "collect": true, + "show": true + } + }, + "label": { + "type": "text", + "value": "Systems" + }, + "trigger": { + "init": "workshopManagerUpdateSystem", + "option": "workshopManagerUpdateSystem" + } + }, + "lrzcb": { + "input": { + "type": "checkbox", + "options": { + "enabled": true, + "default": false + } + }, + "label": { + "type": "text", + "value": "Some Checkbox" + }, + "dependency": { + "option": [ + "lmod" + ], + "system": [ + "kube" + ] + } + }, + "repotype": { + "input": { + "type": "select", + "options": { + "enabled": true + } + }, + "label": { + "type": "text", + "value": "Repository Type" + }, + "trigger": { + "init": "setR2DType" + }, + "dependency": { + "option": [ + "repo2docker" + ] + } + }, + "repourl": { + "input": { + "type": "text", + "options": { + "enabled": "show", + "placeholder": "GitHub repository name or URL", + "patternDisabled": "^([a-zA-Z0-9._-]+\\/[a-zA-Z0-9._-]+(\\.git)?|https?:\\/\\/[a-zA-Z0-9._-]+(\\.[a-z]{2,})?(:\\d+)?\\/[a-zA-Z0-9._-]+\\/[a-zA-Z0-9._-]+(\\.git)?)(\\/)?$" + } + }, + "label": { + "type": "text", + "value": "Repository name or URL" + }, + "dependency": { + "option": [ + "repo2docker" + ] + } + }, + "reporef": { + "input": { + "type": "text", + "options": { + "enabled": true, + "placeholder": "HEAD", + "patternDisabled": "^([a-zA-Z0-9._-]+|([a-f0-9]{7,40})|([a-zA-Z0-9._-]+\\/[a-zA-Z0-9._-]+)|([a-zA-Z0-9._-]+@~\\d+)|@~\\d+)$" + } + }, + "label": { + "type": "text", + "value": "Git ref (branch, tag, or commit)" + }, + "dependency": { + "option": [ + "repo2docker" + ] + } + }, + "repopath": { + "input": { + "type": "text", + "options": { + "enabled": false, + "placeholder": "Path to a notebook file (optional)", + "patternDisabled": "^(\\/?[a-zA-Z0-9/_-]+(?:\\.[a-zA-Z0-9]+)?(?:\\/[a-zA-Z0-9/_-]+(?:\\.[a-zA-Z0-9]+)?)*|[a-zA-Z0-9/_-]+)$" + } + }, + "label": { + "type": "textcheckbox", + "value": "Open notebook or path (optional)", + "options": { + "default": false + } + }, + "dependency": { + "option": [ + "repo2docker" + ] + } + }, + "repopathtype": { + "input": { + "type": "select", + "options": { + "enabled": false, + "show": false + } + }, + "label": { + "type": "textcheckbox", + "value": "Notebook Type", + "options": { + "default": false + } + }, + "trigger": { + "init": "setR2DPathType", + "repopath": "workshopManagerRepoPathType" + }, + "dependency": { + "option": [ + "repo2docker" + ] + } + }, + "image": { + "input": { + "type": "text", + "options": { + "value": "jupyter/datascience-notebook", + "placeholder": "jupyter/datascience-notebook", + "patternDisabled": "^(([a-zA-Z0-9.\\-]+)(:[0-9]+)?\\/)?([a-zA-Z0-9._\\-]+\\/)*[a-zA-Z0-9._\\-]+(:[a-zA-Z0-9._\\-]+|@[A-Fa-f0-9]{64})?$" + } + }, + "label": { + "type": "text", + "value": "Image" + }, + "dependency": { + "option": [ + "custom" + ] + } + }, + "privaterepo": { + "input": { + "type": "text", + "options": { + "enabled": false, + "placeholder": "myregistry.com:5000/myuser/myrepo", + "patternDisabled": "^([a-zA-Z0-9.\\-]+(:[0-9]+)?\\/)?[a-zA-Z0-9._\\-]+\\/[a-zA-Z0-9._\\-]+$" + } + }, + "label": { + "type": "texticoncheckbox", + "value": "Private image registry", + "icontext": "Use private images from your own registry", + "options": { + "default": false + } + }, + "dependency": { + "option": [ + "custom" + ] + } + }, + "privaterepousername": { + "input": { + "type": "text", + "options": { + "show": false, + "placeholder": "Enter your username" + } + }, + "label": { + "type": "texticon", + "value": "Username", + "icontext": "Username for the private registry", + "options": { + "default": false + } + }, + "trigger": { + "privaterepo": "toggleExternalCB" + }, + "dependency": { + "option": [ + "custom" + ] + } + }, + "privaterepopassword": { + "input": { + "type": "text", + "options": { + "secret": true, + "show": false, + "placeholder": "Enter your password" + } + }, + "label": { + "type": "texticon", + "value": "Password", + "icontext": "Password for the private registry", + "options": { + "default": false + } + }, + "trigger": { + "privaterepo": "toggleExternalCB" + }, + "dependency": { + "option": [ + "custom" + ] + } + }, + "mountuserdata": { + "input": { + "type": "text", + "options": { + "enabled": true, + "placeholder": "/home/jovyan/work", + "value": "/home/jovyan/work", + "pattern": "^\\/(?:[^\\/\\0]+\\/)*[^\\/\\0]*$" + } + }, + "label": { + "type": "textcheckbox", + "value": "Mount user data", + "options": { + "default": false + } + }, + "dependency": { + "option": [ + "custom", + "repo2docker" + ] + } + }, + "account": { + "input": { + "type": "select", + "options": { + "enabled": true, + "group": "hpc" + } + }, + "label": { + "type": "text", + "value": "Account" + }, + "trigger": { + "system": "workshopUpdateAccount" + }, + "dependency": { + "system": [ + "unicore" + ] + } + }, + "project": { + "input": { + "type": "select", + "options": { + "enabled": true, + "group": "hpc" + } + }, + "label": { + "type": "text", + "value": "Project" + }, + "trigger": { + "account": "workshopManagerUpdateProject" + }, + "dependency": { + "system": [ + "unicore" + ] + } + }, + "partition": { + "input": { + "type": "select", + "options": { + "enabled": true, + "group": "hpc" + } + }, + "label": { + "type": "text", + "value": "Partition" + }, + "trigger": { + "project": "workshopManagerUpdatePartition", + "system": "workshopManagerUpdatePartition" + }, + "dependency": { + "system": [ + "unicore" + ] + } + }, + "reservation": { + "input": { + "type": "select", + "options": { + "show": false, + "group": "hpc" + } + }, + "label": { + "type": "text", + "value": "Reservation" + }, + "trigger": { + "partition": "workshopManagerUpdateReservation", + "project": "workshopManagerUpdateReservation", + "system": "workshopManagerUpdateReservation" + }, + "triggerOnChange": "workshopManagerUpdateReservation" + }, + "reservationinfo": { + "input": { + "type": "reservationinfo", + "options": { + "show": false + } + }, + "triggerSuffix": "input-div", + "trigger": { + "reservation": "updateReservationInfo" + } + }, + "flavor": { + "input": { + "type": "select", + "options": { + "enabled": true + } + }, + "label": { + "type": "text", + "value": "Flavor" + }, + "trigger": { + "system": "workshopManagerUpdateFlavor" + }, + "dependency": { + "system": [ + "kube" + ] + } + }, + "flavorlegend": { + "input": { + "type": "flavorlegend" + }, + "dependency": { + "system": [ + "kube" + ] + }, + "triggerSuffix": "input-div" + }, + "flavorinfo": { + "input": { + "type": "flavorinfo" + }, + "triggerSuffix": "input-div", + "trigger": { + "system": "updateFlavorInfo" + }, + "dependency": { + "system": [ + "kube" + ] + } + } + }, + }, + "modules": { + "center": { + "extensions": { + "input": { + "type": "multiple_checkboxes" + }, + "label": { + "type": "header", + "value": "Extensions" + }, + "options": { + "group": "modules", + "setName": "extensionSet" + }, + "triggerSuffix": "checkboxes-div", + "trigger": { + "option": "updateMultipleCheckboxes" + }, + "dependency": { + "option": [ + "lmod" + ] + } + }, + "kernels": { + "input": { + "type": "multiple_checkboxes" + }, + "options": { + "group": "modules", + "setName": "kernelSet" + }, + "label": { + "type": "header", + "value": "Kernels" + }, + "triggerSuffix": "checkboxes-div", + "trigger": { + "option": "updateMultipleCheckboxes" + }, + "dependency": { + "option": [ + "lmod" + ] + } + }, + "proxies": { + "input": { + "type": "multiple_checkboxes" + }, + "options": { + "group": "modules", + "setName": "proxySet" + }, + "label": { + "type": "header", + "value": "Proxies" + }, + "triggerSuffix": "checkboxes-div", + "trigger": { + "option": "updateMultipleCheckboxes" + }, + "dependency": { + "option": [ + "lmod" + ] + } + }, + "selecthelper": { + "input": { + "type": "selecthelper" + }, + "triggerSuffix": "input-div" + } + }, + }, + "resources": { + "center": { + "nodes": { + "input": { + "type": "number", + "options": { + "show": false, + "group": "resources", + "collectstatic": true + } + }, + "label": { + "type": "text", + "value": "Nodes" + }, + "trigger": { + "system": "workshopManagerUpdateResourcesElementTrigger", + "partition": "workshopManagerUpdateResourcesElementTrigger" + }, + "dependency": { + "system": [ + "unicore" + ] + } + }, + "runtime": { + "input": { + "type": "number", + "options": { + "show": false, + "group": "resources", + "collectstatic": true + } + }, + "label": { + "type": "text", + "value": "Runtime" + }, + "trigger": { + "system": "workshopManagerUpdateResourcesElementTrigger", + "partition": "workshopManagerUpdateResourcesElementTrigger" + }, + "dependency": { + "system": [ + "unicore" + ] + } + }, + "gpus": { + "input": { + "type": "number", + "options": { + "show": false, + "group": "resources", + "collectstatic": true + } + }, + "label": { + "type": "text", + "value": "GPUs" + }, + "trigger": { + "system": "workshopManagerUpdateResourcesElementTrigger", + "partition": "workshopManagerUpdateResourcesElementTrigger" + }, + "dependency": { + "system": [ + "unicore" + ] + } + }, + "xserver": { + "input": { + "type": "number", + "options": { + "show": false, + "group": "resources", + "collectstatic": true + } + }, + "label": { + "type": "textcheckbox", + "value": "Use XServer GPU index", + "options": { + "default": false + } + }, + "trigger": { + "system": "workshopManagerUpdateResourcesElementTrigger", + "partition": "workshopManagerUpdateResourcesElementTrigger" + }, + "dependency": { + "system": [ + "unicore" + ] + } + } + }, + }, + "logs": { + "center": { + "logcontainer": { + "input": { + "type": "logcontainer" + } + } + }, + }, + "buttonrow": { + "center": { + "buttonrow": { + "input": { + "type": "buttons", + "options": { + "buttons": [ + "share", + "save", + "reset", + "delete", + "startgreen" + ], + "share": { + "text": "Share", + "trigger": "workshopManagerShowLink", + "dependency": { + "option": [ + "repo2docker", + "custom" + ] + } + }, + "startgreen": { + "text": "Start", + "trigger": "homeButtonNew", + "defaultRow": false, + "alignRight": true + }, + "save": { + "trigger": "workshopManagerButtonSave", + "firstRow": false + }, + "reset": { + "firstRow": false, + "trigger": "workshopManagerButtonReset" + }, + "delete": { + "firstRow": false, + "trigger": "homeButtonDelete", + "alignRight": true + } + } + } + } + } + } + }, + "default": { + "tab": "labconfig", + "options": { + "option": "4.2", + "system": "JUWELS" + } + } + } + } + } +}%} \ No newline at end of file diff --git a/templates/macros/table/config/workshop.jinja b/templates/macros/table/config/workshop.jinja new file mode 100644 index 0000000..f6a4c76 --- /dev/null +++ b/templates/macros/table/config/workshop.jinja @@ -0,0 +1,667 @@ +{%- set frontend_config = { + "services": { + "default": "jupyterlab", + "options": { + "jupyterlab": { + "navbar": { + "labconfig": { + "show": true, + "displayName": "Lab Config" + }, + "modules": { + "displayName": "Kernels and Extensions", + "dependency": { + "option": [ + "lmod" + ] + } + }, + "resources": { + "show": false, + "displayName": "Resources", + "trigger": { + "partition": "resourceButton" + }, + "dependency": { + "system": [ + "unicore" + ] + } + }, + "logs": { + "show": true, + "displayName": "Logs" + } + }, + "tabs": { + "labconfig": { + "center": { + "name": { + "input": { + "type": "text", + "options": { + "collect": true, + "enabled": true, + "show": true, + "placeholder": "Give your lab a name" + } + }, + "label": { + "type": "text", + "width": 4, + "value": "Name" + }, + "trigger": { + "init": "workshopLabName" + } + }, + "option": { + "input": { + "type": "select", + "options": { + "collect": true, + "show": true + } + }, + "label": { + "type": "text", + "value": "Select Version" + }, + "trigger": { + "init": "workshopManagerFillOptions" + } + }, + "hr1": { + "input": { + "type": "hr" + } + }, + "system": { + "input": { + "type": "select", + "options": { + "collect": true, + "show": true + } + }, + "label": { + "type": "text", + "value": "Systems" + }, + "trigger": { + "init": "workshopManagerUpdateSystem", + "option": "workshopManagerUpdateSystem" + } + }, + "repotype": { + "input": { + "type": "select", + "options": { + "enabled": true + } + }, + "label": { + "type": "text", + "value": "Repository Type" + }, + "trigger": { + "init": "setR2DType" + }, + "dependency": { + "option": [ + "repo2docker" + ] + } + }, + "repourl": { + "input": { + "type": "text", + "options": { + "enabled": "show", + "placeholder": "GitHub repository name or URL", + "patternDisabled": "^([a-zA-Z0-9._-]+\\/[a-zA-Z0-9._-]+(\\.git)?|https?:\\/\\/[a-zA-Z0-9._-]+(\\.[a-z]{2,})?(:\\d+)?\\/[a-zA-Z0-9._-]+\\/[a-zA-Z0-9._-]+(\\.git)?)(\\/)?$" + } + }, + "label": { + "type": "text", + "value": "Repository name or URL" + }, + "dependency": { + "option": [ + "repo2docker" + ] + } + }, + "reporef": { + "input": { + "type": "text", + "options": { + "enabled": true, + "placeholder": "HEAD", + "patternDisabled": "^([a-zA-Z0-9._-]+|([a-f0-9]{7,40})|([a-zA-Z0-9._-]+\\/[a-zA-Z0-9._-]+)|([a-zA-Z0-9._-]+@~\\d+)|@~\\d+)$" + } + }, + "label": { + "type": "text", + "value": "Git ref (branch, tag, or commit)" + }, + "dependency": { + "option": [ + "repo2docker" + ] + } + }, + "repopath": { + "input": { + "type": "text", + "options": { + "enabled": false, + "placeholder": "Path to a notebook file (optional)", + "patternDisabled": "^(\\/?[a-zA-Z0-9/_-]+(?:\\.[a-zA-Z0-9]+)?(?:\\/[a-zA-Z0-9/_-]+(?:\\.[a-zA-Z0-9]+)?)*|[a-zA-Z0-9/_-]+)$" + } + }, + "label": { + "type": "textcheckbox", + "value": "Open notebook or path (optional)", + "options": { + "default": false + } + }, + "dependency": { + "option": [ + "repo2docker" + ] + } + }, + "repopathtype": { + "input": { + "type": "select", + "options": { + "enabled": false, + "show": false + } + }, + "label": { + "type": "textcheckbox", + "value": "Notebook Type", + "options": { + "default": false + } + }, + "trigger": { + "init": "setR2DPathType", + "repopath": "workshopManagerRepoPathType" + }, + "dependency": { + "option": [ + "repo2docker" + ] + } + }, + "image": { + "input": { + "type": "text", + "options": { + "value": "jupyter/datascience-notebook", + "placeholder": "jupyter/datascience-notebook", + "patternDisabled": "^(([a-zA-Z0-9.\\-]+)(:[0-9]+)?\\/)?([a-zA-Z0-9._\\-]+\\/)*[a-zA-Z0-9._\\-]+(:[a-zA-Z0-9._\\-]+|@[A-Fa-f0-9]{64})?$" + } + }, + "label": { + "type": "text", + "value": "Image" + }, + "dependency": { + "option": [ + "custom" + ] + } + }, + "privaterepo": { + "input": { + "type": "text", + "options": { + "enabled": false, + "placeholder": "myregistry.com:5000/myuser/myrepo", + "patternDisabled": "^([a-zA-Z0-9.\\-]+(:[0-9]+)?\\/)?[a-zA-Z0-9._\\-]+\\/[a-zA-Z0-9._\\-]+$" + } + }, + "label": { + "type": "texticoncheckbox", + "value": "Private image registry", + "icontext": "Use private images from your own registry", + "options": { + "default": false + } + }, + "dependency": { + "option": [ + "custom" + ] + } + }, + "privaterepousername": { + "input": { + "type": "text", + "options": { + "show": false, + "placeholder": "Enter your username" + } + }, + "label": { + "type": "texticon", + "value": "Username", + "icontext": "Username for the private registry", + "options": { + "default": false + } + }, + "trigger": { + "privaterepo": "toggleExternalCB" + }, + "dependency": { + "option": [ + "custom" + ] + } + }, + "privaterepopassword": { + "input": { + "type": "text", + "options": { + "show": false, + "placeholder": "Enter your password" + } + }, + "label": { + "type": "texticon", + "value": "Password", + "icontext": "Password for the private registry", + "options": { + "default": false + } + }, + "trigger": { + "privaterepo": "toggleExternalCB" + }, + "dependency": { + "option": [ + "custom" + ] + } + }, + "mountuserdata": { + "input": { + "type": "text", + "options": { + "enabled": true, + "placeholder": "/home/jovyan/work", + "value": "/home/jovyan/work", + "pattern": "^\\/(?:[^\\/\\0]+\\/)*[^\\/\\0]*$" + } + }, + "label": { + "type": "textcheckbox", + "value": "Mount user data", + "options": { + "default": true + } + }, + "dependency": { + "option": [ + "custom" + ] + } + }, + "account": { + "input": { + "type": "select", + "options": { + "enabled": true + } + }, + "label": { + "type": "text", + "value": "Account" + }, + "trigger": { + "system": "workshopUpdateAccount" + }, + "dependency": { + "system": [ + "unicore" + ] + } + }, + "project": { + "input": { + "type": "select", + "options": { + "enabled": true + } + }, + "label": { + "type": "text", + "value": "Project" + }, + "trigger": { + "account": "workshopManagerUpdateProject" + }, + "dependency": { + "system": [ + "unicore" + ] + } + }, + "partition": { + "input": { + "type": "select", + "options": { + "enabled": true + } + }, + "label": { + "type": "text", + "value": "Partition" + }, + "trigger": { + "project": "workshopManagerUpdatePartition", + "system": "workshopManagerUpdatePartition" + }, + "dependency": { + "system": [ + "unicore" + ] + } + }, + "reservation": { + "input": { + "type": "select", + "options": { + "show": false + } + }, + "label": { + "type": "text", + "value": "Reservation" + }, + "trigger": { + "partition": "workshopManagerUpdateReservation", + "project": "workshopManagerUpdateReservation", + "system": "workshopManagerUpdateReservation" + }, + "triggerOnChange": "workshopManagerUpdateReservation" + }, + "reservationinfo": { + "input": { + "type": "reservationinfo", + "options": { + "show": false + } + }, + "triggerSuffix": "input-div", + "trigger": { + "reservation": "updateReservationInfo" + } + }, + "flavor": { + "input": { + "type": "select", + "options": { + "enabled": true + } + }, + "label": { + "type": "text", + "value": "Flavor" + }, + "trigger": { + "system": "workshopManagerUpdateFlavor" + }, + "dependency": { + "system": [ + "kube" + ] + } + }, + "flavorlegend": { + "input": { + "type": "flavorlegend" + }, + "dependency": { + "system": [ + "kube" + ] + }, + "triggerSuffix": "input-div" + }, + "flavorinfo": { + "input": { + "type": "flavorinfo" + }, + "triggerSuffix": "input-div", + "trigger": { + "system": "updateFlavorInfo" + }, + "dependency": { + "system": [ + "kube" + ] + } + } + }, + }, + "modules": { + "center": { + "extensions": { + "input": { + "type": "multiple_checkboxes" + }, + "label": { + "type": "header", + "value": "Extensions" + }, + "options": { + "group": "modules", + "setName": "extensionSet" + }, + "triggerSuffix": "checkboxes-div", + "trigger": { + "option": "updateMultipleCheckboxes" + }, + "dependency": { + "option": [ + "lmod" + ] + } + }, + "kernels": { + "input": { + "type": "multiple_checkboxes" + }, + "options": { + "group": "modules", + "setName": "kernelSet" + }, + "label": { + "type": "header", + "value": "Kernels" + }, + "triggerSuffix": "checkboxes-div", + "trigger": { + "option": "updateMultipleCheckboxes" + }, + "dependency": { + "option": [ + "lmod" + ] + } + }, + "proxies": { + "input": { + "type": "multiple_checkboxes" + }, + "options": { + "group": "modules", + "setName": "proxySet" + }, + "label": { + "type": "header", + "value": "Proxies" + }, + "triggerSuffix": "checkboxes-div", + "trigger": { + "option": "updateMultipleCheckboxes" + }, + "dependency": { + "option": [ + "lmod" + ] + } + }, + "selecthelper": { + "input": { + "type": "selecthelper" + }, + "triggerSuffix": "input-div" + } + }, + }, + "resources": { + "center": { + "nodes": { + "input": { + "type": "number", + "options": { + "show": false, + "group": "resources", + "collectstatic": true + } + }, + "label": { + "type": "text", + "value": "Nodes" + }, + "trigger": { + "system": "workshopManagerUpdateResourcesElementTrigger", + "partition": "workshopManagerUpdateResourcesElementTrigger" + }, + "dependency": { + "system": [ + "unicore" + ] + } + }, + "runtime": { + "input": { + "type": "number", + "options": { + "show": false, + "group": "resources", + "collectstatic": true + } + }, + "label": { + "type": "text", + "value": "Runtime" + }, + "trigger": { + "system": "workshopManagerUpdateResourcesElementTrigger", + "partition": "workshopManagerUpdateResourcesElementTrigger" + }, + "dependency": { + "system": [ + "unicore" + ] + } + }, + "gpus": { + "input": { + "type": "number", + "options": { + "show": false, + "group": "resources", + "collectstatic": true + } + }, + "label": { + "type": "text", + "value": "GPUs" + }, + "trigger": { + "system": "workshopManagerUpdateResourcesElementTrigger", + "partition": "workshopManagerUpdateResourcesElementTrigger" + }, + "dependency": { + "system": [ + "unicore" + ] + } + }, + "xserver": { + "input": { + "type": "number", + "options": { + "show": false, + "group": "resources", + "collectstatic": true + } + }, + "label": { + "type": "textcheckbox", + "value": "Use XServer GPU index", + "options": { + "default": false + } + }, + "trigger": { + "system": "workshopManagerUpdateResourcesElementTrigger", + "partition": "workshopManagerUpdateResourcesElementTrigger" + }, + "dependency": { + "system": [ + "unicore" + ] + } + } + }, + }, + "logs": { + "center": { + "logcontainer": { + "input": { + "type": "logcontainer" + } + } + }, + }, + "buttonrow": { + "center": { + "buttonrow": { + "input": { + "type": "buttons", + "options": { + "buttons": [ + "reset" + ], + "reset": { + "firstRow": false, + "trigger": "workshopManagerButtonReset" + } + } + } + } + } + } + }, + "default": { + "tab": "labconfig", + "options": { + "option": "4.2", + "system": "JUWELS" + } + } + } + } + } +}%} \ No newline at end of file diff --git a/templates/macros/table/config/workshop_manager.jinja b/templates/macros/table/config/workshop_manager.jinja new file mode 100644 index 0000000..c74a320 --- /dev/null +++ b/templates/macros/table/config/workshop_manager.jinja @@ -0,0 +1,775 @@ +{% set frontend_config = { + "services": { + "default": "jupyterlab", + "options": { + "jupyterlab": { + "navbar": {}, + "tabs": { + "default": { + "left": { + "workshopid": { + "input": { + "type": "text", + "options": { + "collect": true, + "alwaysDisabled": true, + "enabled": false, + "show": true, + "placeholder": "An ID will be generated for you", + "placeholderInstructor": "Choose a descriptive ID", + "pattern": "[a-z][a-z0-9_\\-]*", + "warning": "Allowed chars: a-z 0-9 _ - . Must start with a lowercase latter ([a-z][a-z0-9_-]*)", + "group": "none" + } + }, + "trigger": { + "init": "workshopManagerWorkshopId" + }, + "label": { + "type": "texticonclick", + "width": 6, + "value": "Workshop ID:", + "icontext": "For more information check out <a href='https://jupyterjsc.pages.jsc.fz-juelich.de/docs/jupyterjsc/users/jupyterlab/4.2/' target='_blank'>documentation</a>" + } + }, + "description": { + "input": { + "type": "text", + "options": { + "collect": true, + "show": true, + "required": true, + "group": "none", + "warning": "A description of your workshop is required. This will be displayed to users to help them select the appropriate workshop." + } + }, + "label": { + "type": "text", + "width": 6, + "value": "Description:" + } + }, + "enddate": { + "input": { + "type": "date", + "options": { + "show": true, + "enabled": false, + "group": "none", + "instructor": "enabled" + } + }, + "label": { + "type": "text", + "width": 6, + "value": "Available until:", + "options": { + "name": "public" + } + } + }, + "public": { + "input": { + "type": "checkbox", + "options": { + "group": "none", + "instructor": "show", + "show": false, + "default": false, + "enabled": true + } + }, + "label": { + "type": "text", + "width": 6, + "value": "List workshop at /hub/workshops:" + } + }, + "expertmode": { + "input": { + "type": "checkbox", + "options": { + "default": false, + "group": "none", + "instructor": "show", + "enabled": true + } + }, + "trigger": { + "init": "workshopManagerToggleExpertMode" + }, + "triggerOnChange": "workshopManagerToggleExpertMode", + "label": { + "type": "texticon", + "icontext": "Expert Mode allows you to select multiple Options + Systems", + "width": 6, + "value": "Enable expert mode" + } + }, + "buttons": { + "input": { + "type": "buttons", + "options": { + "summaryButtons": [ + "new", + "open" + ], + "buttons": [ + "reset", + "delete", + "share", + "save", + "new" + ], + "share": { + "text": "Show Link", + "trigger": "workshopManagerShowLink", + "align-right": true, + "firstRow": false + }, + "save": { + "trigger": "workshopManagerButtonSave", + "firstRow": false + }, + "new": { + "trigger": "workshopManagerButtonNew", + "align-right": true, + "text": "Create Workshop", + "textFirst": true, + "firstRow": true + }, + "reset": { + "firstRow": false, + "trigger": "workshopManagerButtonReset" + }, + "delete": { + "firstRow": false, + "trigger": "workshopManagerButtonDelete" + } + } + } + } + }, + "right": { + "option": { + "input": { + "type": "select", + "options": { + "show": true, + "enabled": false + } + }, + "label": { + "type": "textcheckbox", + "value": "Select Version", + "options": { + "default": false + } + }, + "trigger": { + "init": "workshopManagerFillOptions" + } + }, + "system": { + "input": { + "type": "select", + "options": { + "show": true, + "enabled": false + } + }, + "label": { + "type": "textcheckbox", + "value": "Systems", + "options": { + "default": false + } + }, + "trigger": { + "init": "workshopManagerUpdateSystem", + "option": "workshopManagerUpdateSystem" + } + }, + "repotype": { + "input": { + "type": "select", + "options": { + "enabled": false + } + }, + "label": { + "type": "textcheckbox", + "value": "Repository Type", + "options": { + "default": false + } + }, + "trigger": { + "init": "setR2DType" + }, + "dependency": { + "option": [ + "repo2docker" + ] + } + }, + "repourl": { + "input": { + "type": "text", + "options": { + "enabled": false, + "placeholder": "GitHub repository name or URL", + "patternDisabled": "^([a-zA-Z0-9._-]+\\/[a-zA-Z0-9._-]+(\\.git)?|https?:\\/\\/[a-zA-Z0-9._-]+(\\.[a-z]{2,})?(:\\d+)?\\/[a-zA-Z0-9._-]+\\/[a-zA-Z0-9._-]+(\\.git)?)(\\/)?$" + } + }, + "label": { + "type": "textcheckbox", + "value": "Repository name or URL", + "options": { + "default": false + } + }, + "dependency": { + "option": [ + "repo2docker" + ] + } + }, + "reporef": { + "input": { + "type": "text", + "options": { + "enabled": false, + "placeholder": "HEAD", + "patternDisabled": "^([a-zA-Z0-9._-]+|([a-f0-9]{7,40})|([a-zA-Z0-9._-]+\\/[a-zA-Z0-9._-]+)|([a-zA-Z0-9._-]+@~\\d+)|@~\\d+)$" + } + }, + "label": { + "type": "textcheckbox", + "value": "Git ref (branch, tag, or commit)", + "options": { + "default": false + } + }, + "dependency": { + "option": [ + "repo2docker" + ] + } + }, + "repopath": { + "input": { + "type": "text", + "options": { + "enabled": false, + "placeholder": "Path to a notebook file (optional)", + "patternDisabled": "^(\\/?[a-zA-Z0-9/_-]+(?:\\.[a-zA-Z0-9]+)?(?:\\/[a-zA-Z0-9/_-]+(?:\\.[a-zA-Z0-9]+)?)*|[a-zA-Z0-9/_-]+)$" + } + }, + "label": { + "type": "textcheckbox", + "value": "Open notebook or path (optional)", + "options": { + "default": false + } + }, + "dependency": { + "option": [ + "repo2docker" + ] + } + }, + "repopathtype": { + "input": { + "type": "select", + "options": { + "enabled": false, + "show": false + } + }, + "label": { + "type": "textcheckbox", + "value": "Notebook Type", + "options": { + "default": false + } + }, + "trigger": { + "init": "setR2DPathType", + "repopath": "workshopManagerRepoPathType" + }, + "dependency": { + "option": [ + "repo2docker" + ] + } + }, + "image": { + "input": { + "type": "text", + "options": { + "enabled": false, + "value": "jupyter/datascience-notebook", + "placeholder": "jupyter/datascience-notebook", + "patternDisabled": "^(([a-zA-Z0-9.\\-]+)(:[0-9]+)?\\/)?([a-zA-Z0-9._\\-]+\\/)*[a-zA-Z0-9._\\-]+(:[a-zA-Z0-9._\\-]+|@[A-Fa-f0-9]{64})?$" + } + }, + "label": { + "type": "textcheckbox", + "value": "Image", + "options": { + "default": false + } + }, + "dependency": { + "option": [ + "custom" + ] + } + }, + "privaterepo": { + "input": { + "type": "text", + "options": { + "enabled": false, + "placeholder": "myregistry.com:5000/myuser/myrepo", + "patternDisabled": "^([a-zA-Z0-9.\\-]+(:[0-9]+)?\\/)?[a-zA-Z0-9._\\-]+\\/[a-zA-Z0-9._\\-]+$" + } + }, + "label": { + "type": "textcheckbox", + "value": "Private image repository", + "options": { + "default": false + } + }, + "dependency": { + "option": [ + "custom" + ] + } + }, + "mountuserdata": { + "input": { + "type": "text", + "options": { + "enabled": false, + "placeholder": "/home/jovyan/work", + "value": "/home/jovyan/work", + "pattern": "^\\/(?:[^\\/\\0]+\\/)*[^\\/\\0]*$" + } + }, + "label": { + "type": "textcheckbox", + "value": "Mount user data", + "options": { + "default": false + } + }, + "dependency": { + "option": [ + "custom" + ] + } + }, + "extensions": { + "input": { + "type": "select", + "options": { + "group": "modules", + "enabled": false, + "size": 4, + "multiple": true, + "setName": "extensionSet" + } + }, + "label": { + "type": "textcheckbox", + "value": "Extensions", + "options": { + "default": false + } + }, + "trigger": { + "option": "workshopManagerUpdateModuleWorkshop" + }, + "dependency": { + "option": [ + "lmod" + ] + } + }, + "kernels": { + "input": { + "type": "select", + "options": { + "group": "modules", + "enabled": false, + "size": 4, + "multiple": true, + "setName": "kernelSet" + } + }, + "label": { + "type": "textcheckbox", + "value": "Kernels", + "options": { + "default": false + } + }, + "trigger": { + "option": "workshopManagerUpdateModuleWorkshop" + }, + "dependency": { + "option": [ + "lmod" + ] + } + }, + "proxies": { + "input": { + "type": "select", + "options": { + "group": "modules", + "enabled": false, + "size": 4, + "multiple": true, + "setName": "proxySet" + } + }, + "label": { + "type": "textcheckbox", + "value": "Proxies", + "options": { + "default": false + } + }, + "trigger": { + "option": "workshopManagerUpdateModuleWorkshop" + }, + "dependency": { + "option": [ + "lmod" + ] + } + }, + "project": { + "input": { + "type": "select", + "options": { + "enabled": false, + "multiple": true, + "size": 4 + } + }, + "label": { + "type": "textcheckbox", + "options": { + "default": false + }, + "value": "Project" + }, + "trigger": { + "system": "workshopManagerUpdateProject" + }, + "dependency": { + "system": [ + "unicore" + ] + } + }, + "defaultvaluesproject": { + "input": { + "type": "select", + "options": { + "show": false, + "collect": false, + "enabled": false, + "parent": "project", + "group": "defaultvalues" + } + }, + "label": { + "type": "textcheckbox", + "options": { + "default": false + }, + "value": "Specify default project" + }, + "trigger": { + "project": "defaultValue" + }, + "dependency": { + "system": [ + "unicore" + ] + } + }, + "partition": { + "input": { + "type": "select", + "options": { + "enabled": false, + "multiple": true, + "size": 4 + } + }, + "label": { + "type": "textcheckbox", + "value": "Partition", + "options": { + "default": false + } + }, + "trigger": { + "project": "workshopManagerUpdatePartition", + "system": "workshopManagerUpdatePartition" + }, + "dependency": { + "system": [ + "unicore" + ] + } + }, + "defaultvaluespartition": { + "input": { + "type": "select", + "options": { + "show": false, + "collect": false, + "enabled": false, + "parent": "partition", + "group": "defaultvalues" + } + }, + "label": { + "type": "textcheckbox", + "options": { + "default": false + }, + "value": "Specify default partition" + }, + "trigger": { + "partition": "defaultValue" + }, + "dependency": { + "system": [ + "unicore" + ] + } + }, + "reservation": { + "input": { + "type": "select", + "options": { + "show": false, + "collectstatic": true, + "enabled": false, + "multiple": true, + "size": 4 + } + }, + "label": { + "type": "textcheckbox", + "options": { + "default": false + }, + "triggerOnChange": "toggleCollectCB", + "value": "Reservation" + }, + "trigger": { + "system": "workshopManagerUpdateReservation", + "partition": "workshopManagerUpdateReservation", + "project": "workshopManagerUpdateReservation" + }, + "dependency": { + "system": [ + "unicore" + ] + } + }, + "defaultvaluesreservation": { + "input": { + "type": "select", + "options": { + "show": false, + "collect": false, + "enabled": false, + "parent": "reservation", + "group": "defaultvalues" + } + }, + "label": { + "type": "textcheckbox", + "options": { + "default": false + }, + "value": "Specify default reservation" + }, + "trigger": { + "reservation": "defaultValue" + }, + "dependency": { + "system": [ + "unicore" + ] + } + }, + "nodes": { + "input": { + "type": "number", + "options": { + "show": false, + "group": "resources", + "enabled": false + } + }, + "label": { + "type": "textcheckbox", + "value": "Nodes", + "options": { + "default": false + } + }, + "trigger": { + "system": "workshopManagerUpdateResourcesElementTrigger", + "partition": "workshopManagerUpdateResourcesElementTrigger" + }, + "dependency": { + "system": [ + "unicore" + ] + } + }, + "runtime": { + "input": { + "type": "number", + "options": { + "show": false, + "group": "resources" + } + }, + "label": { + "type": "textcheckbox", + "value": "Runtime", + "options": { + "default": false + } + }, + "trigger": { + "system": "workshopManagerUpdateResourcesElementTrigger", + "partition": "workshopManagerUpdateResourcesElementTrigger" + }, + "dependency": { + "system": [ + "unicore" + ] + } + }, + "gpus": { + "input": { + "type": "number", + "options": { + "show": false, + "enabled": false, + "group": "resources" + } + }, + "label": { + "type": "textcheckbox", + "value": "GPUs", + "options": { + "default": false + } + }, + "trigger": { + "system": "workshopManagerUpdateResourcesElementTrigger", + "partition": "workshopManagerUpdateResourcesElementTrigger" + }, + "dependency": { + "system": [ + "unicore" + ] + } + }, + "xserver": { + "input": { + "type": "number", + "options": { + "show": false, + "group": "resources" + } + }, + "label": { + "type": "textcheckbox", + "value": "Use XServer GPU index", + "options": { + "default": false + } + }, + "trigger": { + "system": "workshopManagerUpdateResourcesElementTrigger", + "partition": "workshopManagerUpdateResourcesElementTrigger" + }, + "dependency": { + "system": [ + "unicore" + ] + } + }, + "flavor": { + "input": { + "type": "select", + "options": { + "enabled": false + } + }, + "label": { + "type": "textcheckbox", + "options": { + "default": false + }, + "value": "Flavor" + }, + "trigger": { + "system": "workshopManagerUpdateFlavor" + }, + "dependency": { + "system": [ + "kube" + ] + } + }, + "envvariables": { + "input": { + "type": "textgrower", + "options": { + "enabled": false, + "required": true, + "show": true, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*=[^\\s]+$", + "placeholder": "KEY=VALUE" + } + }, + "label": { + "type": "textcheckbox", + "value": "Add environment variables", + "options": { + "default": false + } + } + } + } + } + }, + "default": { + "tab": "default", + "options": { + "option": "4.2", + "system": "JUWELS" + } + } + } + } + } +} %} \ No newline at end of file diff --git a/templates/macros/table/content.jinja b/templates/macros/table/content.jinja new file mode 100644 index 0000000..b97b59f --- /dev/null +++ b/templates/macros/table/content.jinja @@ -0,0 +1,362 @@ +{%- import "macros/table/elements.jinja" as table_elements with context %} +{%- import "macros/svgs.jinja" as svg -%} + +{% macro workshopmanager_description() %} + <p>{{ db_workshops }}</p> + <h2>Workshop Manager</h2> + <p>Select the options users might be able to use during your workshop.</p> + <p>Use shift or ctrl to select multiple items. <a style="color:#fff" href="https://jupyterjsc.pages.jsc.fz-juelich.de/docs/jupyterjsc/" target="_">Click here for more information.</a></p> +{% endmacro %} + +{% macro workshopmanager_headerlayout() %} + <th scope="col" width="1%"></th> + <th scope="col" width="20%">Name</th> + <th scope="col">Description</th> + <th scope="col" class="text-center" width="10%">Action</th> +{% endmacro %} + +{% macro workshopmanager_defaultheader(service_id, row_id, row_options) %} + <th scope="row" class="name-td">{{ row_id }}</th> + <th scope="row" class="description-td">{{ row_options.get("user_options", {}).get("description", "") }}</th> + <th scope="row" class="url-td text-center"> + <button type="button" id="{{ service_id }}-{{row_id}}-open-workshop-btn" class="btn btn-success open-workshop-btn" data-target="#{{ row_id }}-workshop-link" onclick="window.open('https://{{ hostname }}{{ base_url }}workshops/{{ row_id }}');">{{ svg.open_svg | safe }} Open</button> + </th> +{% endmacro %} + +{% macro workshopmanager_firstheader(service_id, row_id, row_options) %} + <th scope="row" class="name-td">New Workshop</th> + <th scope="row" class="description-td">Design a simplified set of options for your workshop to make it more accessible for your students.</th> + <th scope="row" class="url-td text-center"> + <button type="button" data-service="{{ service_id }}" data-row="{{ row_id }}" id="{{ service_id }}-header-{{row_id}}-new-btn" class="btn btn-primary" data-target="#{{ row_id }}-workshop-link">{{ svg.plus_svg | safe }} Create</button> + </th> +{% endmacro %} + +{% macro workshopmanager_row_content(service_id, service_options, row_id, tab_id) %} + {%- for side in service_options.get("tabs", {}).get(tab_id, {}).keys() %} + <div class="col-6"> + {%- for element_id, element_options in service_options.get("tabs", {}).get(tab_id, {}).get(side, {}).items() %} + {{ table_elements.create_element(service_id, row_id, tab_id, element_id, element_options) }} + {%- endfor %} + </div> + {%- endfor %} +{% endmacro %} + +{% macro workshop_headerlayout() %} + <th scope="col" width="1%"></th> + <th scope="col" width="20%">Name</th> + <th scope="col">Description</th> + <th scope="col" class="text-center" width="10%">Status</th> + <th scope="col" class="text-center" width="10%">Action</th> +{% endmacro %} + +{% macro workshop_firstheader(service_id, row_id, row_options) %} + <th scope="row" class="name-td">Workshop {{ workshop_id }}</th> + <th scope="row" class="description-td">{{ db_workshops.get(row_id, {}).get("user_options", {}).get("description") }}</th> + <th scope="row" class="status-td"> + <div class="d-flex justify-content-center"> + <div class="d-flex flex-column"> + <div class="d-flex justify-content-center progress" style="background-color: #d3e4f4; height: 20px; min-width: 100px;"> + <div id="{{ service_id }}-{{ row_id }}-progress-bar" data-service="{{ service_id }}" data-row="{{ row_id }}" data-sse-progress class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0px; margin-right: auto;"></div> + <span id="{{ service_id }}-{{ row_id }}-progress-text" style="position: absolute; width: 100px; text-align: center; line-height: 20px; color: black">0%</span> + </div> + <span id="{{ service_id }}-{{ row_id }}-progress-info-text" class="progress-info-text text-center text-muted" style="font-size: smaller;"></span> + </div> + </div> + </th> + <th scope="row" class="url-td text-center" style="white-space: nowrap"> + <button type="button" + id="{{ service_id }}-{{row_id}}-open-btn-header" + class="btn btn-success" + data-service="{{ service_id }}" + data-row="{{ row_id }}" + data-element="open" + {%- if not spawner.active %} + style="display: none" + {%- endif %} + > + {{ svg.open_svg | safe }} Open + </button> + <button type="button" + id="{{ service_id }}-{{row_id}}-stop-btn-header" + class="btn btn-danger" + data-service="{{ service_id }}" + data-row="{{ row_id }}" + data-element="stop" + {%- if not spawner.ready %} + style="display: none" + {%- endif %} + > + {{ svg.stop_svg | safe }} Stop + </button> + <button type="button" + id="{{ service_id }}-{{row_id}}-cancel-btn-header" + class="btn btn-danger" + data-service="{{ service_id }}" + data-row="{{ row_id }}" + data-element="cancel" + {%- if spawner.ready or not spawner.active %} + style="display: none" + {%- endif %} + > + {{ svg.stop_svg | safe }} Cancel + </button> + <button type="button" + id="{{ service_id }}-{{row_id}}-start-btn-header" + class="btn btn-primary" + data-service="{{ service_id }}" + data-row="{{ row_id }}" + data-element="start" + {%- if spawner.active %} + style="display: none" + {%- endif %} + > + {{ svg.start_svg | safe }} Start + </button> + </th> +{% endmacro %} + + +{% macro sse_functions() %} + $(`[data-sse-progress][id$='-tabContent-div']`).on("sse", function (event, data) { + const $this = $(this); + const serviceId = $this.attr("data-service"); + const rowId = $this.attr("data-row"); + if ( Object.keys(data).includes(rowId) ){ + const ready = data[rowId]?.ready ?? false; + const failed = data[rowId]?.failed ?? false; + const progress = data[rowId]?.progress ?? 10; + if ( ready ) { + updateHeaderButtons(serviceId, rowId, "waiting"); + const url = data[rowId]?.url ?? "{{ url }}"; + checkAndOpenUrl(serviceId, rowId, url); + } else if ( failed ) { + updateHeaderButtons(serviceId, rowId, "stopped"); + $(`button[id^='${serviceId}-${rowId}-'][id$='-btn']`).prop("disabled", false); + } else if ( progress == 99 ) { + updateHeaderButtons(serviceId, rowId, "cancelling"); + $(`button[id^='${serviceId}-${rowId}-'][id$='-btn']`).prop("disabled", true); + } else { + updateHeaderButtons(serviceId, rowId, "starting"); + $(`button[id^='${serviceId}-${rowId}-'][id$='-btn']`).prop("disabled", true); + } + appendToLog(serviceId, rowId, data[rowId]); + } + }); + + $(`[data-sse-progress].progress-bar`).on("sse", function (event, data) { + const $this = $(this); + const rowId = $this.attr("data-row"); + const serviceId = $this.attr("data-service"); + if ( Object.keys(data).includes(rowId) ){ + const ready = data[rowId]?.ready ?? false; + const failed = data[rowId]?.failed ?? false; + const progress = data[rowId]?.progress ?? 10; + let status = "starting"; + if ( ready ) status = "connecting"; + else if ( failed ) status = "stopped"; + else if ( progress == 99 ) status = "cancelling"; + else if ( progress == 0 ) status = ""; + progressBarUpdate(serviceId, rowId, status, progress); + } + }); +{% endmacro %} + +{% macro workshop_description() %} + <p>{{ db_workshops }}</p> +{% endmacro %} + +{% macro home_description() %} + <p>You can configure your existing JupyterLabs by expanding the corresponding table row.</p> +{% endmacro %} + + +{% macro home_headerlayout() %} + <th scope="col" width="1%"></th> + <th scope="col" width="20%">Name</th> + <th scope="col">Configuration</th> + <th scope="col" class="text-center" width="10%">Status</th> + <th scope="col" class="text-center" width="10%">Action</th> +{% endmacro %} + +{% macro home_firstheader(service_id, row_id, row_options) %} + <th scope="row" colspan="100%" class="text-center">New JupyterLab</th> +{% endmacro %} + + +{% macro home_defaultheader(service_id, row_id, row_options) %} + {%- for s in spawners %} + {%- if s.name == row_id %} + {%- set spawner = user.spawners.get(s.name, s) %} + {%- set ready = spawner.ready %} + {%- set active = spawner.active %} + {%- set failed = spawner.failed %} + {%- set progress = 0 %} + {%- if ready %} + {%- set progress = 100 %} + {%- elif active %} + {%- set progress = (spawner.events | last | default({}, true)).get("progress", 0) %} + {%- elif spawner.events %} + {%- set progress = 0 %} + {%- endif %} + + <th scope="row" class="name-td">{{ spawner.user_options.get("name", "") }}</th> + <td scope="row" class="config-td"> + <div style="max-height: 152px; overflow: auto;"> + <div class="row mx-3 mb-1 justify-content-between"> + <div id="{{ service_id }}-{{ row_id }}-config-td-div" class="row col-12 col-md-6 col-lg-12 d-flex align-items-center"> + <div id="{{ service_id }}-{{ row_id }}-config-td-option-div" class="col text-lg-center col-12 col-lg-3"> + <span class="text-muted" style="font-size: smaller;">Option</span><br> + <span id="{{ service_id }}-{{ row_id }}-config-td-option">{{ spawner.user_options.get("option", false) }}</span> + </div> + <div id="{{ service_id }}-{{ row_id }}-config-td-system-div" class="col text-lg-start col-12 col-lg-3"> + <span class="text-muted" style="font-size: smaller;">System</span><br> + <span id="{{ service_id }}-{{ row_id }}-config-td-system">{{ spawner.user_options.get("system", false) }}</span> + </div> + {%- if spawner.user_options.get("project", false) %} + <div id="{{ service_id }}-{{ row_id }}-config-td-project-div" class="col text-lg-start col-12 col-lg-3"> + <span class="text-muted" style="font-size: smaller;">Project</span><br> + <span id="{{ service_id }}-{{ row_id }}-config-td-project">{{ spawner.user_options.get("project", "") }}</span> + </div> + {%- endif %} + {%- if spawner.user_options.get("partition", false) %} + <div id="{{ service_id }}-{{ row_id }}-config-td-partition-div" class="col text-lg-start col-12 col-lg-3"> + <span class="text-muted" style="font-size: smaller;">Partition</span><br> + <span id="{{ service_id }}-{{ row_id }}-config-td-partition">{{ spawner.user_options.get("partition", "") }}</span> + </div> + {%- endif %} + </div> + </div> + </div> + </td> + + <th scope="row" class="status-td"> + <div class="d-flex justify-content-center"> + <div class="d-flex flex-column"> + <div class="d-flex justify-content-center progress" style="background-color: #d3e4f4; height: 20px; min-width: 100px;"> + <div id="{{ service_id }}-{{ row_id }}-progress-bar" + data-service="{{ service_id }}" + data-row="{{ row_id }}" + data-sse-progress + class=" + {%- if ready %} + bg-success + {%- endif %} + progress-bar progress-bar-striped progress-bar-animated + " + role="progressbar" + style="width: {{ progress }}px; margin-right: auto;" + ></div> + <span id="{{ service_id }}-{{ row_id }}-progress-text" + style="position: absolute; + width: 100px; + text-align: center; + line-height: 20px; + {% if progress >= 60 %} + color: white + {% else %} + color: black + {% endif -%} + " + >{{ progress }}%</span> + </div> + <span id="{{ service_id }}-{{ row_id }}-progress-info-text" class="progress-info-text text-center text-muted" style="font-size: smaller;"> + {%- if ready %} + running + {%- elif active %} + starting + {%- elif progress == 99 %} + cancelling + {%- endif %} + </span> + </div> + </div> + </th> + <th scope="row" class="url-td text-center" style="white-space: nowrap"> + <button type="button" + id="{{ service_id }}-{{row_id}}-open-btn-header" + class="btn btn-success" + data-service="{{ service_id }}" + data-row="{{ row_id }}" + data-element="open" + {%- if not spawner.active %} + style="display: none" + {%- endif %}> + {{ svg.open_svg | safe }} Open + </button> + <button type="button" + id="{{ service_id }}-{{row_id}}-stop-btn-header" + class="btn btn-danger" + data-service="{{ service_id }}" + data-row="{{ row_id }}" + data-element="stop" + {%- if not spawner.ready %} + style="display: none" + {%- endif %} + > + {{ svg.stop_svg | safe }} Stop + </button> + <button type="button" + id="{{ service_id }}-{{row_id}}-cancel-btn-header" + class="btn btn-danger" + data-service="{{ service_id }}" + data-row="{{ row_id }}" + data-element="cancel" + {%- if spawner.ready or not spawner.active %} + style="display: none" + {%- endif %} + > + {{ svg.stop_svg | safe }} Cancel + </button> + <button type="button" + id="{{ service_id }}-{{row_id}}-start-btn-header" + class="btn btn-primary" + data-service="{{ service_id }}" + data-row="{{ row_id }}" + data-element="start" + {%- if spawner.active %} + style="display: none" + {%- endif %} + > + {{ svg.start_svg | safe }} Start + </button> + <button type="button" + id="{{ service_id }}-{{row_id}}-na-btn-header" + class="btn btn-secondary btn-na-lab disabled" + data-service="{{ service_id }}" + data-row="{{ row_id }}" + data-element="na" + style="display: none" + > + {{ svg.na_svg | safe }} N/A + </button> + <button type="button" + id="{{ service_id }}-{{row_id}}-del-btn-header" + class="btn btn-danger" + data-service="{{ service_id }}" + data-row="{{ row_id }}" + data-element="del" + style="display: none" + > + {{ svg.delete_svg | safe }} + </button> + </th> + + {%- endif %} + {%- endfor %} +{%- endmacro %} + +{% macro workshop_user_not_ready() %} + <h1 class="user-documentation-info" style="text-align: center; color: red">Account not ready yet!</h1> + <div class="user-documentation-info"> + Your account is not ready for {{ db_workshops.get(workshop_id, {}).get("user_options", {}) }}. + </div> +{% endmacro %} + + +{% macro row_content(service_id, service_options, row_id, tab_id) %} + <div class="col-12"> + {%- for element_id, element_options in service_options.get("tabs", {}).get(tab_id, {}).get("center", {}).items() %} + {{ table_elements.create_element(service_id, row_id, tab_id, element_id, element_options) }} + {%- endfor %} + </div> +{% endmacro %} diff --git a/templates/macros/table/elements.jinja b/templates/macros/table/elements.jinja new file mode 100644 index 0000000..ab94ddc --- /dev/null +++ b/templates/macros/table/elements.jinja @@ -0,0 +1,836 @@ +{%- import "macros/svgs.jinja" as svg -%} +{%- include "macros/table/table_js.jinja" %} + + +{%- macro create_button( + service_id, + row_id, + button, + button_options +)%} + {%- set clazz = "" %} + {%- set svgicon = "" %} + {%- set buttontext = "" %} + {%- if button == "share" %} + {%- set clazz = "" %} + {%- set svgicon = svg.share_svg | safe %} + {%- set buttontext = button_options.get("text", "Share") %} + {%- elif button == "reset" %} + {%- set clazz = "btn-danger" %} + {%- set svgicon = svg.reset_svg | safe %} + {%- set buttontext = button_options.get("text", "Reset") %} + {%- elif button == "delete" %} + {%- set clazz = "btn-danger" %} + {%- set svgicon = svg.delete_svg | safe %} + {%- set buttontext = button_options.get("text", "Delete") %} + {%- elif button == "save" %} + {%- set clazz = "btn-success" %} + {%- set svgicon = svg.save_svg | safe %} + {%- set buttontext = button_options.get("text", "Save") %} + {%- elif button == "create" %} + {%- set clazz = "btn-primary" %} + {%- set svgicon = svg.plus_svg | safe %} + {%- set buttontext = button_options.get("text", "Create") %} + {%- elif button == "start" %} + {%- set clazz = "btn-success" %} + {%- set svgicon = svg.start_svg | safe %} + {%- set buttontext = button_options.get("text", "Start") %} + {%- elif button == "startblue" %} + {%- set clazz = "btn-primary" %} + {%- set svgicon = svg.start_svg | safe %} + {%- set buttontext = button_options.get("text", "Start") %} + {%- elif button == "startgreen" %} + {%- set clazz = "btn-success" %} + {%- set svgicon = svg.start_svg | safe %} + {%- set buttontext = button_options.get("text", "Start") %} + {%- elif button == "new" %} + {%- set clazz = "btn-primary" %} + {%- set svgicon = svg.plus_svg | safe %} + {%- set buttontext = button_options.get("text", "New") %} + {%- elif button == "open" %} + {%- set clazz = "btn-success" %} + {%- set svgicon = svg.open_svg | safe %} + {%- set buttontext = button_options.get("text", "Open") %} + {%- elif button == "retry" %} + {%- set clazz = "btn-success" %} + {%- set svgicon = svg.retry_svg | safe %} + {%- set buttontext = button_options.get("text", "Retry") %} + {%- elif button == "cancel" %} + {%- set clazz = "btn-danger" %} + {%- set svgicon = svg.stop_svg | safe %} + {%- set buttontext = button_options.get("text", "Cancel") %} + {%- elif button == "stop" %} + {%- set clazz = "btn-success" %} + {%- set svgicon = svg.stop_svg | safe %} + {%- set buttontext = button_options.get("text", "Stop") %} + {%- endif %} + <button + type="button" + data-service="{{ service_id }}" + data-row="{{ row_id }}" + id="{{ service_id }}-{{ row_id }}-{{ button }}-btn" + class="btn {{ clazz }} + {% if button_options.get("alignRight", false) -%} ms-auto {%- else -%} me-2 {%- endif %}" + {%- for specific_key, specific_values in button_options.get("dependency", {}).items() %} + data-dependency-{{ specific_key }}="true" + {%- for specific_value in specific_values %} + data-dependency-{{ specific_key }}-{{ specific_value }}="true" + {%- endfor %} + {%- endfor %} + > + {%- if button_options.get("textFirst", false ) %} + {{ buttontext }} + {{ svgicon | safe }} + {%- else %} + {{ svgicon | safe }} + {{ buttontext }} + {%- endif %} + </button> +{%- endmacro %} + + + +{%- macro create_workshop_modal( + service_id, + row_id, + workshop_id) +%} + <div class="modal fade" id="{{ service_id }}-{{ row_id }}-workshop-modal" role="dialog" tabindex="-1"> + <div class="modal-dialog modal-dialog-centered"> + + <!-- Modal content--> + <div class="modal-content"> + <div class="modal-header"> + <h4 class="modal-title" style="color: black">Share Workshop</h4> + <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"> + </div> + <div class="modal-body"> + <p style="color: black">Share your workshop via URL</p> + <a href="" target="_blank"></a> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-default" data-bs-dismiss="modal">Close</button> + <button type="button" data-service="{{ service_id }}" data-row="{{ row_id }}" id="{{ service_id }}-{{ row_id }}-workshop-modal-copy-btn" class="btn btn-outline-primary" data-bs-toggle="tooltip" data-service="{{ service_id }}" data-row="{{ row_id }}" data-bs-placement="top" title="Copy to clipboard">Copy</button> + </div> + </div> + </div> + </div> +{%- endmacro %} + +{%- macro create_buttons( + service_id, + row_id, + element_options +)%} + <hr> + <div class="d-flex" id="{{ service_id }}-{{ row_id }}-buttons-div" role="dialog" tabindex="-1"> + {%- for button in element_options.get("input", {}).get("options", {}).get("buttons",[] ) %} + {%- set button_options = element_options.get("input", {}).get("options", {}).get(button, {}) %} + {%- if ( row_id == vars.first_row_id and button_options.get("firstRow", true) ) or + ( row_id != vars.first_row_id and button_options.get("defaultRow", true) ) %} + {{ create_button(service_id, row_id, button, button_options) }} + {%- endif %} + {%- endfor %} + </div> + {%- if pagetype == vars.pagetype_workshopmanager %} + {{ create_workshop_modal(service_id, row_id) }} + {%- endif %} +{%- endmacro %} + +{%- macro create_trigger( + id_prefix, + service_id, + row_id, + tab_id, + element_id, + element_options, + full_id +) %} +{%- endmacro %} + +{%- macro create_label( + id_prefix, + service_id, + row_id, + tab_id, + element_id, + element_options +)%} + {# + <label class="col-{{ label.get("width", "4") }} col-form-label" for="{{ id_prefix }}-{{ element_id }}-input"> + #} + {%- set label = element_options.get("label", {}) %} + <div class="col-{{ label.get("width", "4") }} col-form-label d-flex align-items-start justify-content-between"> + <label for="{{ id_prefix }}-{{ element_id }}-input" class="d-flex align-items-center w-100"> + {%- if label.type in ["text", "texticon", "texticonclick", "texticonclickcheckbox", "textcheckbox", "texticoncheckbox"] %} + {%- if label.value is string %} + {{ label.value }} + {%- endif %} + {%- endif %} + {%- if label.type in ["texticon", "texticoncheckbox"] %} + <a class="lh-1 ms-3" style="padding-top: 1px;" + data-bs-toggle="tooltip" data-bs-placement="right" data-bs-html="true" + title="{{ label.icontext }}"> + {{ svg.info_svg | safe }} + </a> + {%- endif %} + {%- if label.type == "texticonclick" %} + <button type="button" class="btn" + data-bs-toggle="tooltip" data-bs-placement="right" data-bs-html="true" + title="{{ label.icontext }}"> + {{ svg.info_svg | safe }} + <span class="text-muted" style="font-size: smaller">(click me)</span> + </button> + {%- endif %} + {%- if label.type == "textdropdown" %} + {%- endif %} + {%- if label.type in ["textcheckbox", "texticoncheckbox", "texticonclickcheckbox"] %} + <input type="checkbox" + {%- if label.get('options', {}).get("align-right", true ) %} + style="margin-left: auto;" + {%- else %} + style="margin-left: .5em;" + {%- endif %} + class="form-check-input" + id="{{ id_prefix }}-{{ element_id }}-input-cb" + data-service="{{ service_id }}" + data-row="{{ row_id }}" + data-tab="{{ tab_id }}" + data-element="{{ element_id }}" + {%- for specific_key, specific_values in element_options.get("dependency", {}).items() %} + data-dependency-{{ specific_key }}="true" + {%- for specific_value in specific_values %} + data-dependency-{{ specific_key }}-{{ specific_value }}="true" + {%- endfor %} + {%- endfor %} + + {%- set ignore_keys = ["name", "value", "show", "align-right", "placeholder", "pattern", "warning", "required"] %} + {%- for key, value in label.get('options', {}).items() %} + {%- if key not in ignore_keys %} + {%- if value is string or value is boolean %} + data-{{key}}="{{ value | lower }}" + {%- endif %} + {%- endif %} + {%- endfor %} + + {# add default group #} + {%- if "group" not in label.get('options', {}).keys() %} + data-group="default" + {%- endif %} + + {%- set checked = label.get('options', {}).get('default', false) %} + data-enabled="{{ label.get('options', {}).get('enabled', true) | lower }}" + {%- if not label.get('options', {}).get('enabled', true) %} + disabled="true" + {%- endif %} + data-label-input="true" + data-checked={{ checked | lower }} + {%- if checked %} + checked + {%- endif -%} + {%- if label.get('options', {}).get('name', false) %} + name="{{ label.get('options', {}).get('name', false) }}" + {%- endif %} + /> + {%- endif %} + {%- if label.type == "dropdown" %} + {%- endif %} + {%- if label.type == "function" %} + {%- endif %} + {%- if label.type == "header" %} + <h4>{{ label.value }}</h4> + {%- endif %} + </label> + </div> + {# + #} +{%- endmacro %} + + +{%- macro create_multiple_checkboxes( + id_prefix, + service_id, + row_id, + tab_id, + element_id, + element_options +) -%} + {#- User-selected Modules #} + <div id="{{ id_prefix }}-{{ element_id }}-input-div" + {{ element_parameters(service_id, row_id, tab_id, element_id, element_options, define_show=true) }} + > + {{ create_label(id_prefix, service_id, row_id, tab_id, element_id, element_options) }} + <div id="{{ id_prefix }}-{{ element_id }}-checkboxes-div" class="row g-0" + {{ element_parameters(service_id, row_id, tab_id, element_id, element_options) }} + > + </div> + </div> + {# create_trigger suffix: checkboxes-div #} +{%- endmacro %} + +{%- macro create_label_input( + id_prefix, + service_id, + row_id, + tab_id, + element_id, + element_options +) %} + <div id="{{ id_prefix }}-{{ element_id }}-input-div" + class="row mb-1" + {{ element_parameters(service_id, row_id, tab_id, element_id, element_options, define_show=true) }} + > + {{ create_label(id_prefix, service_id, row_id, tab_id, element_id, element_options) }} + </div> +{%- endmacro %} + + +{%- macro element_parameters( + service_id, + row_id, + tab_id, + element_id, + element_options, + define_show=false, + collect=true +) %} + {# data parameter for all elements #} + data-service="{{ service_id }}" + data-row="{{ row_id }}" + data-tab="{{ tab_id }}" + data-type="{{ element_options.get('input', {}).get('type', 'default') }}" + data-element="{{ element_id }}" + + {%- set ignore_keys = ["name", "value", "show", "align-right", "placeholder", "pattern", "warning", "required", "collect-static"] %} + {%- for key, value in element_options.get('input', {}).get('options', {}).items() %} + {%- if key not in ignore_keys %} + {%- if value is string or value is boolean %} + data-{{key}}="{{ value | lower }}" + {%- endif %} + {%- endif %} + {%- endfor %} + + {# add default group #} + {%- if "group" not in element_options.get('input', {}).get('options', {}).keys() %} + data-group="default" + {%- endif %} + {%- if collect %} + {%- if "collect" not in element_options.get('input', {}).get('options', {}).keys() %} + data-collect=false + {%- endif %} + {%- endif %} + {%- if element_options.get('input', {}).get('options', {}).get('collectstatic', false) %} + data-collect-static + {%- endif %} + + + {# data parameter for input elements #} + {%- if element_options.get("input", {}).get("options", {}).get("size", false) %} + size={{ element_options.get("input", {}).get("options", {}).get("size", 1) }} + {%- endif %} + {%- if element_options.get("input", {}).get("options", {}).get("multiple", false) %} + multiple + {%- endif %} + {%- if not element_options.get('input', {}).get('options', {}).get('enabled', true) and + not ( is_instructor and element_options.get("input", {}).get("options", {}).get("instructor", "") == "enabled" ) + %} + disabled + {%- endif %} + {%- if element_options.get("input", {}).get("options", {}).get("required", false) %} + required + {%- endif %} + {%- if element_options.get("input", {}).get("options", {}).get("pattern", "") %} + pattern="{{ element_options.get("input", {}).get("options", {}).get("pattern", "") }}" + {%- endif %} + {%- if element_options.get("input", {}).get("options", {}).get("placeholder", "") %} + placeholder="{{ element_options.get("input", {}).get("options", {}).get("placeholder", "") }}" + {%- endif %} + {%- if element_options.get("input", {}).get("options", {}).get("value", "") %} + value="{{ element_options.get("input", {}).get("options", {}).get("value", "") }}" + {%- endif %} + + {# If it's only available in a specific environment, hide it always by default #} + + {%- for specific_key, specific_values in element_options.get("dependency", {}).items() %} + data-dependency-{{ specific_key }}="true" + {%- for specific_value in specific_values %} + data-dependency-{{ specific_key }}-{{ specific_value }}="true" + {%- endfor %} + {%- endfor %} + {%- if define_show %} + {%- if element_options.get("input", {}).get("options", {}).get("show", true) or + ( is_instructor and element_options.get("input", {}).get("options", {}).get("instructor", "") == "show" ) + %} + data-show="true" + {%- else %} + data-show="false" + {%- endif %} + {%- if not element_options.get("input", {}).get("options", {}).get("show", false) and + not ( is_instructor and element_options.get("input", {}).get("options", {}).get("instructor", "") == "show" ) + %} + style="display: none" + {%- endif %} + {%- endif %} +{%- endmacro %} + +{%- macro create_text_input( + id_prefix, + service_id, + row_id, + tab_id, + element_id, + element_options +) %} + <div id="{{ id_prefix }}-{{ element_id }}-input-div" class="row mb-1" + {{ element_parameters(service_id, row_id, tab_id, element_id, element_options, define_show=true) }} + > + {{ create_label(id_prefix, service_id, row_id, tab_id, element_id, element_options) }} + <div class="col-{{ 12 - element_options.get("label", {}).get("width", "4") | int }} d-flex flex-column justify-content-center"> + {%- if element_options.get("input", {}).get("options", {}).get("secret", false) %} + <div class="input-group"> + {%- endif %} + <input type="{%- if element_options.get("input", {}).get("options", {}).get("secret", false) -%}password{%- else -%}text{%- endif -%}" + class="form-control" + {{ element_parameters(service_id, row_id, tab_id, element_id, element_options) }} + name="{{ element_options.get('input', {}).get('options', {}).get('name', element_id) }}" + id="{{ id_prefix }}-{{ element_id }}-input" + /> + {%- if element_options.get("input", {}).get("options", {}).get("secret", false) %} + <span class="input-group-append"> + <button class="btn btn-light" type="button" + data-service="{{ service_id }}" + data-row="{{ row_id }}" + data-element="{{ element_id }}" + id="{{ id_prefix }}-{{ element_id }}-view-password" + > + <i id="{{ id_prefix }}-{{ element_id }}-password-eye" class="fa fa-eye" aria-hidden="true"></i> + </button> + </span> + {%- endif %} + <div class="invalid-feedback">{{ element_options.get("input", {}).get("options", {}).get("warning", "") }}</div> + {%- if element_options.get("input", {}).get("options", {}).get("secret", false) %} + </div> + {%- endif %} + </div> + </div> +{%- endmacro %} + +{%- macro create_textgrower_input( + id_prefix, + service_id, + row_id, + tab_id, + element_id, + element_options +) %} + <div id="{{ id_prefix }}-{{ element_id }}-input-div" class="row mb-1" + {{ element_parameters(service_id, row_id, tab_id, element_id, element_options, define_show=true) }} + > + {{ create_label(id_prefix, service_id, row_id, tab_id, element_id, element_options) }} + <div data-count=1 class="container col-{{ 12 - element_options.get("label", {}).get("width", "4") | int }} d-flex flex-column justify-content-center"> + + <div class="input-group" style="display: flex; align-items: center; margin-bottom: 10px;"> + <input id="{{ id_prefix }}-1-{{ element_id }}-input" type="{%- if element_options.get("input", {}).get("options", {}).get("secret", false) -%}password{%- else -%}text{%- endif -%}" + class="form-control" + {{ element_parameters(service_id, row_id, tab_id, element_id, element_options) }} + name="{{ element_options.get('input', {}).get('options', {}).get('name', element_id) }}" + /> + <button data-service="{{ service_id }}" data-row="{{ row_id }}" data-tab="{{ tab_id }}" data-collect-static data-element="{{ element_id }}" data-textgrower-btn-type="add" data-collect="false" {% if not element_options.get('input', {}).get('options', {}).get('enabled', true) -%} disabled {% endif -%} type="button" id="{{ id_prefix }}-1-addbtn-{{ element_id }}-input" data-btn-type="add" style="margin-left: 8px;" class="btn btn-primary">{{ svg.plus_svg | safe }}</button> + </div> + <div class="invalid-feedback">{{ element_options.get("input", {}).get("options", {}).get("warning", "") }}</div> + </div> + </div> +{%- endmacro %} + +{%- macro create_date_input( + id_prefix, + service_id, + row_id, + tab_id, + element_id, + element_options +) %} + <div id="{{ id_prefix }}-{{ element_id }}-input-div" + class="row mb-1" + {{ element_parameters(service_id, row_id, tab_id, element_id, element_options, define_show=true) }} + > + {{ create_label(id_prefix, service_id, row_id, tab_id, element_id, element_options) }} + <div class="col-{{ 12 - element_options.get("label", {}).get("width", "4") | int }} d-flex flex-column justify-content-center"> + <input type="date" + {{ element_parameters(service_id, row_id, tab_id, element_id, element_options) }} + class="form-control" + name="{{ element_options.get('input', {}).get('options', {}).get('name', element_id) }}" + id="{{ id_prefix }}-{{ element_id }}-input" + value="{{ today_plus_half_year }}" + min="{{ today }}" + max="{{ today_plus_one_year }}" + /> + <div class="invalid-feedback">{{ element_options.get("input", {}).get("options", {}).get("warning", "") }}</div> + </div> + </div> +{%- endmacro %} + + +{%- macro create_number_input( + id_prefix, + service_id, + row_id, + tab_id, + element_id, + element_options +) %} + {% set name_ = element_options.get('input', {}).get('options', {}).get('name', element_id) %} + <div id="{{ id_prefix }}-{{ element_id }}-input-div" class="row mb-2" + {{ element_parameters(service_id, row_id, tab_id, element_id, element_options, define_show=true) }} + > + {{ create_label(id_prefix, service_id, row_id, tab_id, element_id, element_options) }} + <div class="col-{{ 12 - element_options.get("label", {}).get("width", "4") | int }} d-flex flex-column justify-content-center"> + <input type="number" + {{ element_parameters(service_id, row_id, tab_id, element_id, element_options) }} + name="{{ name_ }}" + class="form-control" + id="{{ id_prefix }}-{{ element_id }}-input" + {%- if element_options.get("input", {}).get("options", {}).get("required", false) %} + required + {%- endif %} + {%- if element_options.get("input", {}).get("options", {}).get("pattern", "") %} + pattern="{{ element_options.get("input", {}).get("options", {}).get("pattern", "") }}" + {%- endif %} + {%- if element_options.get("input", {}).get("options", {}).get("placeholder", "") %} + placeholder="{{ element_options.get("input", {}).get("options", {}).get("placeholder", "") }}" + {%- endif %} + {%- if element_options.get("input", {}).get("options", {}).get("value", "") %} + value="{{ element_options.get("input", {}).get("options", {}).get("value", "") }}" + {%- endif %} + {%- if not element_options.get("input", {}).get("options", {}).get("enabled", true) %} + disabled + {%- endif %} + /> + <div class="invalid-feedback">{{ element_options.get("input", {}).get("options", {}).get("warning", "") }}</div> + </div> + </div> +{%- endmacro %} + +{%- macro create_checkbox_input( + id_prefix, + service_id, + row_id, + tab_id, + element_id, + element_options +) %} + <div id="{{ id_prefix }}-{{ element_id }}-input-div" class="row mb-2" + {{ element_parameters(service_id, row_id, tab_id, element_id, element_options, define_show=true) }} + > + {{ create_label(id_prefix, service_id, row_id, tab_id, element_id, element_options) }} + <div class="col-{{ 12 - element_options.get("label", {}).get("width", "4") | int }} d-flex flex-column justify-content-center"> + <input type="checkbox" + name="{{ element_options.get('input', {}).get('options', {}).get('name', element_id) }}" + {{ element_parameters(service_id, row_id, tab_id, element_id, element_options) }} + class="form-check-input" + id="{{ id_prefix }}-{{ element_id }}-input" + {%- if element_options.get("input", {}).get("options", {}).get("required", false) %} + required + {%- endif %} + {%- if element_options.get("input", {}).get("options", {}).get("pattern", "") %} + pattern="{{ element_options.get("input", {}).get("options", {}).get("pattern", "") }}" + {%- endif %} + {%- if element_options.get("input", {}).get("options", {}).get("placeholder", "") %} + placeholder="{{ element_options.get("input", {}).get("options", {}).get("placeholder", "") }}" + {%- endif %} + {%- if element_options.get("input", {}).get("options", {}).get("value", "") %} + value="{{ element_options.get("input", {}).get("options", {}).get("value", "") }}" + {%- endif %} + {%- if not element_options.get("input", {}).get("options", {}).get("enabled", true) %} + disabled + {%- endif %} + {% if element_options.get('input', {}).get('options', {}).get('default', false) %} + checked + {%- endif %} + /> + <div class="invalid-feedback">{{ element_options.get("input", {}).get("options", {}).get("warning", "") }}</div> + </div> + </div> +{%- endmacro %} + +{%- macro create_select_input( + id_prefix, + service_id, + row_id, + tab_id, + element_id, + element_options +) %} + {# Versions #} + <div id="{{ id_prefix }}-{{ element_id }}-input-div" class="row mb-1" + {{ element_parameters(service_id, row_id, tab_id, element_id, element_options, define_show=true) }} + > + {{ create_label(id_prefix, service_id, row_id, tab_id, element_id, element_options) }} + <div class="col-{{ 12 - element_options.get("label", {}).get("width", "4") | int }} d-flex flex-column justify-content-center"> + <select + name="{{ element_options.get('input', {}).get('options', {}).get('name', element_id) }}" + id="{{ id_prefix }}-{{ element_id }}-input" + class="form-select" + {{ element_parameters(service_id, row_id, tab_id, element_id, element_options) }} + > + </select> + <div class="invalid-feedback">{{ element_options.get("input", {}).get("options", {}).get("warning", "You have to select at least one item.") }}</div> + </div> + </div> +{%- endmacro %} + +{%- macro create_reservation_info( + id_prefix, + service_id, + row_id, + tab_id, + element_id, + element_options +)%} + <div id="{{ id_prefix }}-{{ element_id }}-input-div" class="row mb-3" + {{ element_parameters(service_id, row_id, tab_id, element_id, element_options, define_show=true) }} + > + {%- set reservation_info_classes = "col-4 fw-bold"%} + <div id="{{ id_prefix }}-{{ element_id }}-info" class="col-8 offset-4"> + <div class="row"> + <span class="{{ reservation_info_classes }}">Start Time:</span> + <span id="{{ id_prefix }}-{{ element_id }}-start" class="col-auto"></span> + </div> + <div class="row"> + <span class="{{ reservation_info_classes }}">End Time:</span> + <span id="{{ id_prefix }}-{{ element_id }}-end" class="col-auto"></span> + </div> + <div class="row"> + <span class="{{ reservation_info_classes }}">State:</span> + <span id="{{ id_prefix }}-{{ element_id }}-state" class="col-auto"></span> + </div> + <div class="mt-1"> + <details> + <summary class="fw-bold">Detailed reservation information:</summary> + <pre id="{{ id_prefix }}-{{ element_id }}-details"></pre> + </details> + </div> + </div> + </div> + {# create_trigger suffix: input-div #} +{%- endmacro %} + +{%- macro create_flavor_legend( + id_prefix, + service_id, + row_id, + tab_id, + element_id, + element_options +)%} + <div id="{{ id_prefix }}-{{ element_id }}-input-div" class="row align-items-center g-0 mt-4" + {{ element_parameters(service_id, row_id, tab_id, element_id, element_options, define_show=true) }} + > + <span class="col-4 fw-bold">Available Flavors</span> + <div class="col d-flex align-items-center ms-2"> + {%- set box_style = "height: 15px; width: 15px; border-radius: 0.25rem;"%} + <div style="{{box_style}} background-color: #198754;"></div> + <span class="ms-1">= Free</span> + <span class="mx-2"></span> + <div style="{{box_style}} background-color: #023d6b;"></div> + <span class="ms-1">= Used</span> + <span class="mx-2"></span> + <div style="{{box_style}} background-color: #dc3545;"></div> + <span class="ms-1">= Limit exceeded</span> + </div> + </div> + {# create_trigger suffix: input-div #} +{%- endmacro %} + +{%- macro create_flavor_info( + id_prefix, + service_id, + row_id, + tab_id, + element_id, + element_options +)%} + <div id="{{ id_prefix }}-{{ element_id }}-input-div" data-sse-flavors class="mb-3" + {{ element_parameters(service_id, row_id, tab_id, element_id, element_options, define_show=true) }} + ></div> +{# create_trigger suffix: input-div #} +{%- endmacro %} + +{%- macro create_selecthelper( + id_prefix, + service_id, + row_id, + tab_id, + element_id, + element_options +)%} + <div id="{{ id_prefix }}-{{ element_id }}-input-div" class="row g-0" + {{ element_parameters(service_id, row_id, tab_id, element_id, element_options, define_show=true) }} + > + <hr> + <div class="form-check col-sm-6 col-md-4 col-lg-3"> + <input class="form-check-input data-service="{{ service_id }}" data-row="{{ row_id }}" data-tab="{{ tab_id }}" module-selector" type="checkbox" id="{{ id_prefix }}-{{ element_id }}-select-all"> + <label class="form-check-label" for="{{ id_prefix }}-{{ element_id }}-select-all">Select all</label> + </div> + <div class="form-check col-sm-6 col-md-4 col-lg-3"> + <input class="form-check-input data-service="{{ service_id }}" data-row="{{ row_id }}" data-tab="{{ tab_id }}" module-selector" type="checkbox" id="{{ id_prefix }}-{{ element_id }}-select-none"> + <label class="form-check-label" for="{{ id_prefix }}-{{ element_id }}-select-none">Deselect all</label> + </div> + </div> +{# create_trigger suffix: input-div #} +{%- endmacro %} + +{%- macro create_logcontainer( + id_prefix, + service_id, + row_id, + tab_id, + element_id, + element_options +)%} + <div id="{{ id_prefix }}-{{ element_id }}-input" class="card card-body text-black row g-0" + {{ element_parameters(service_id, row_id, tab_id, element_id, element_options) }} + > + <div class="log-div"> + Logs collected during the Start process will be shown here. + </div> + </div> +{# create_trigger suffix: input-div #} +{%- endmacro %} + +{%- macro create_element( + service_id, + row_id, + tab_id, + element_id, + element_options +) %} + {% set id_prefix = service_id ~ '-' ~ row_id ~ '-' ~ tab_id %} + {%- if element_options.get("input", {}).get("type", "") == "text" %} + {{ create_text_input( + id_prefix, + service_id, + row_id, + tab_id, + element_id, + element_options + )}} + {%- elif element_options.get("input", {}).get("type", "") == "textgrower" %} + {{ create_textgrower_input( + id_prefix, + service_id, + row_id, + tab_id, + element_id, + element_options + )}} + {%- elif element_options.get("input", {}).get("type", "") == "label" %} + {{ create_label_input( + id_prefix, + service_id, + row_id, + tab_id, + element_id, + element_options + )}} + {%- elif element_options.get("input", {}).get("type", "") == "date" %} + {{ create_date_input( + id_prefix, + service_id, + row_id, + tab_id, + element_id, + element_options + )}} + {%- elif element_options.get("input", {}).get("type", "") == "number" %} + {{ create_number_input( + id_prefix, + service_id, + row_id, + tab_id, + element_id, + element_options + )}} + {%- elif element_options.get("input", {}).get("type", "") == "checkbox" %} + {{ create_checkbox_input( + id_prefix, + service_id, + row_id, + tab_id, + element_id, + element_options + )}} + {%- elif element_options.get("input", {}).get("type", "") == "buttons" %} + {{ create_buttons( + service_id, + row_id, + element_options + )}} + {%- elif element_options.get("input", {}).get("type", "") == "select" %} + {{ create_select_input( + id_prefix, + service_id, + row_id, + tab_id, + element_id, + element_options + )}} + {%- elif element_options.get("input", {}).get("type", "") == "reservationinfo" %} + {{ create_reservation_info( + id_prefix, + service_id, + row_id, + tab_id, + element_id, + element_options + )}} + {%- elif element_options.get("input", {}).get("type", "") == "flavorlegend" %} + {{ create_flavor_legend( + id_prefix, + service_id, + row_id, + tab_id, + element_id, + element_options + )}} + {%- elif element_options.get("input", {}).get("type", "") == "flavorinfo" %} + {{ create_flavor_info( + id_prefix, + service_id, + row_id, + tab_id, + element_id, + element_options + )}} + {%- elif element_options.get("input", {}).get("type", "") == "multiple_checkboxes" %} + {{ create_multiple_checkboxes( + id_prefix, + service_id, + row_id, + tab_id, + element_id, + element_options + )}} + {%- elif element_options.get("input", {}).get("type", "") == "selecthelper" %} + {{ create_selecthelper( + id_prefix, + service_id, + row_id, + tab_id, + element_id, + element_options + )}} + {%- elif element_options.get("input", {}).get("type", "") == "logcontainer" %} + {{ create_logcontainer( + id_prefix, + service_id, + row_id, + tab_id, + element_id, + element_options + )}} + {%- elif element_options.get("input", {}).get("type", "") == "hr" %} + <hr> + {%- endif %} +{%- endmacro %} + diff --git a/templates/macros/table/elements_js.jinja b/templates/macros/table/elements_js.jinja new file mode 100644 index 0000000..7f3865c --- /dev/null +++ b/templates/macros/table/elements_js.jinja @@ -0,0 +1,646 @@ +{%- import "macros/svgs.jinja" as svg -%} +{%- include "macros/table/table_js.jinja" %} +{%- import "macros/table/variables.jinja" as vars with context %} + +<script> + $(document).ready(function() { + require(["jquery", "jhapi", "utils"], function ( + $, + JHAPI, + utils + ) { + var base_url = window.jhdata.base_url; + var api = new JHAPI(base_url); + var user = window.jhdata.user; + const logDebug = false; + + let optionElement; + + {%- for service_id, service_options in config.frontend_config.get("services", {}).get("options", {}).items() %} + // Configure element specific elements: + {%- for tab_id, tab_options in service_options.get("tabs", {}).items() %} + {%- for side in tab_options.keys() %} + {%- for element_id, element_options in tab_options.get(side, {}).items() %} + {%- if element_options.get("input", {}).get("type", "") == "buttons" %} + {%- for button in element_options.get("input", {}).get("options", {}).get("buttons", []) %} + {% set trigger = element_options.get("input", {}).get("options", {}).get(button, {}).get("trigger", false) %} + {%- if trigger %} + {%- set button_options = element_options.get("input", {}).get("options", {}).get(button, {}) %} + $(`[id^='{{ service_id }}-'][id$='-{{ button }}-btn']`).on("click", function() { + logDebug && console.log("Button click {{ button }}"); + const $this = $(this); + const serviceId = $this.attr("data-service"); + const rowId = $this.attr("data-row"); + {{ button_options.get("trigger", "") }}(serviceId, rowId, "{{ button }}", {{ button_options | tojson }}, user, api, base_url, utils); + logDebug && console.log("Button click {{ button }} done"); + }); + {%- endif %} + {%- endfor %} + {%- else %} + {%- set trigger_suffix = element_options.get("triggerSuffix", "input") %} + {%- for trigger_key, trigger_func in element_options.get("trigger", {}).items() %} + $(`[id^='{{ service_id }}-'][id$='-{{ element_id }}-{{ trigger_suffix}}']`).on("trigger_{{ trigger_key }}", function (event) { + if (event.target !== this) { + return; // Ignore events bubbling up from child elements + } + logDebug && console.log("update {{ element_id }}. Triggered by: ({{ trigger_key }})"); + const $this = $(this); + const serviceId = $this.attr("data-service"); + const rowId = $this.attr("data-row"); + const preValue = $this.val(); + const dependencies = {{ element_options.get("dependency", {}) | tojson }}; + let trigger = true; + {%- if trigger_key != "init" %} + for (const [key, allowedValues] of Object.entries(dependencies)) { + const currentValues = val(getInputElement(serviceId, rowId, key)); + trigger = currentValues.some(value => { + const mappedValue = mappingDict?.[serviceId]?.[key]?.[value] ?? value; + return allowedValues.includes(mappedValue); + }); + if ( !trigger ) { + break; + } + } + {%- endif %} + if ( trigger ) { + {{ trigger_func }}("{{ trigger_key }}", serviceId, rowId, "{{ tab_id }}", "{{ element_id }}", {{ element_options | tojson }}); + $this.trigger("change"); + logDebug && console.log("update {{ element_id }}. Triggered by: ({{ trigger_key }}) done"); + } else { + logDebug && console.log("update {{ element_id }}. Triggered by: ({{ trigger_key }}) no function call"); + } + }); + {%- endfor %} + {%- if element_options.get("triggerOnChange", "") %} + $(`[id^='{{ service_id }}-'][id$='-{{ tab_id }}-{{ element_id }}-{{ trigger_suffix}}']`).on("change", function () { + const $this = $(this); + const serviceId = $this.attr("data-service"); + const rowId = $this.attr("data-row"); + {{ element_options["triggerOnChange"] }}("onChange", serviceId, rowId, "{{ tab_id }}", "{{ element_id }}", {{ element_options | tojson }}); + }); + {%- endif %} + {%- set label_options = element_options.get("label", {}) %} + {%- if label_options.get("type", "text") in ["textcheckbox", "texticoncheckbox", "texticonclickcheckbox"] %} + {%- for trigger_key, trigger_func in label_options.get("trigger", {}).items() %} + $(`[id^='{{ service_id }}-'][id$='-{{ tab_id }}-{{ element_id }}-input-cb']`).on("trigger_{{ trigger_key }}"), function (event) { + if (event.target !== this) { + return; // Ignore events bubbling up from child elements + } + const $this = $(this); + const serviceId = $this.attr("data-service"); + const rowId = $this.attr("data-row"); + logDebug && console.log("update {{ element_id }}. Triggered by: ({{ trigger_key }})"); + {{ trigger_func }}("{{ trigger_key }}", serviceId, rowId, "{{ tab_id }}", "{{ element_id }}", {{ label_options.get("options", {}) | tojson }}); + logDebug && console.log("update {{ element_id }}. Triggered by: ({{ trigger_key }}) done"); + }); + {%- endfor %} + $(`[id^='{{ service_id }}-'][id$='-{{ tab_id }}-{{ element_id }}-input-cb']`).on("change", function() { + const $this = $(this); + const serviceId = $this.attr("data-service"); + const rowId = $this.attr("data-row"); + const checked = $this.prop("checked"); + $(`[id^='${serviceId}-${rowId}-'][id$='-{{ element_id }}-input']`).prop("disabled", !checked); + $(`[id^='${serviceId}-${rowId}-'][id$='-{{ element_id }}-input']:not([data-collect-static])`).attr("data-collect", checked); + if ( !checked ) { + $(`[id^='${serviceId}-${rowId}-'][id$='-{{ element_id }}-input']`).removeClass("is-invalid"); + } + + {%- if label_options.get("triggerOnChange", "") %} + {{ label_options["triggerOnChange"] }}("change", serviceId, rowId, "{{ tab_id }}", "{{ element_id }}", {{ label_options.get("options", {}) | tojson }}); + {%- endif %} + $(`[id^='${serviceId}-${rowId}-']`).trigger(`trigger_{{ element_id }}`); + logDebug && console.log("update {{ element_id }}. Trigger Change by: {{ trigger_key }}"); + $(`[id^='${serviceId}-${rowId}-'][id$='-{{ element_id }}-input']`).trigger("change"); + logDebug && console.log("update {{ element_id }}. Trigger Change by: {{ trigger_key }} done"); + }); + {%- endif %} + {%- endif %} + {%- endfor %} + {%- endfor %} + {%- endfor %} + {%- for button_id, button_options in service_options.navbar.items() %} + $(`button[id^='{{ service_id }}-'][id$='-{{ button_id }}-navbar-button']`).on("show", function (event) { + const $this = $(this); + const rowId = $this.attr("data-row"); + const serviceId = $this.attr("data-service"); + let show = true; + if ( !$this.data('show') ) { + const dependencies = {{ button_options.get("dependency", {}) | tojson }}; + for (const [key, allowedValues] of Object.entries(dependencies)) { + const currentValues = val(getInputElement(serviceId, rowId, key)); + show = currentValues.some(value => { + const mappedValue = mappingDict[serviceId]?.[key]?.[value] ?? value; + return allowedValues.includes(mappedValue); + }); + if ( !show ) { + break; + } + } + } + if ( show ) { + $this.addClass("show"); + $this.attr("style", ""); + $this.show(); + $(`#${serviceId}-${rowId}-{{ button_id }}-tab-input-warning`).addClass("invisible"); + } + }); + $(`button[id^='{{ service_id }}-'][id$='-{{ button_id }}-navbar-button']`).on("hide", function (event) { + const $this = $(this); + const rowId = $this.attr("data-row"); + const serviceId = $this.attr("data-service"); + $this.removeClass("show"); + $this.attr("style", "{{ style_hide }}"); + $this.hide(); + $(`[id^='${serviceId}-'][id$='-${rowId}-{{ button_id }}-tab-input-warning']`).addClass("invisible"); + }); + $(`button[id^='{{ service_id }}-'][id$='-{{ button_id }}-navbar-button']`).on("activate", function (event) { + const $this = $(this); + const rowId = $this.attr("data-row"); + const serviceId = $this.attr("data-service"); + $(`div[id^='${serviceId}-${rowId}-'][id$='-{{ button_id }}-contenttab-div']`).addClass("show"); + $(`div[id^='${serviceId}-${rowId}-'][id$='-{{ button_id }}-contenttab-div']`).show(); + $(`[id^='${serviceId}-${rowId}-'][id$='-{{ button_id }}-tab-input-warning']`).addClass("invisible"); + }); + $(`button[id^='{{ service_id }}-'][id$='-{{ button_id }}-navbar-button']`).on("deactivate", function (event) { + const $this = $(this); + const rowId = $this.attr("data-row"); + const serviceId = $this.attr("data-service"); + $(`div[id^='${serviceId}-${rowId}-'][id$='-{{ button_id }}-contenttab-div']`).removeClass("show"); + $(`div[id^='${serviceId}-${rowId}-'][id$='-{{ button_id }}-contenttab-div']`).hide(); + }); + $(`button[id^='{{ service_id }}-'][id$='-{{ button_id }}-navbar-button']`).on("click", function (event) { + $this = $(this); + const rowId = $this.attr("data-row"); + const serviceId = $this.attr("data-service"); + $(`button[id^='${serviceId}-${rowId}-'][id$='-navbar-button']:not([data-tab='{{ button_id }}'])`).trigger("deactivate"); + $this.trigger("activate"); + }); + {%- for trigger_key, trigger_func in button_options.get("trigger", {}).items() %} + $(`button[id^='{{ service_id }}-'][id$='-{{ button_id }}-navbar-button']`).on("trigger_{{ trigger_key }}", function (event) { + if (event.target !== this) { + return; // Ignore events bubbling up from child elements + } + $this = $(this); + const rowId = $this.attr("data-row"); + const serviceId = $this.attr("data-service"); + let trigger = true; + const dependencies = {{ button_options.get("dependency", {}) | tojson }}; + for (const [key, allowedValues] of Object.entries(dependencies)) { + const currentValues = val(getInputElement(serviceId, rowId, key)); + trigger = currentValues.some(value => { + const mappedValue = mappingDict[serviceId]?.[key]?.[value] ?? value; + return allowedValues.includes(mappedValue); + }); + if ( !trigger ) { + break; + } + } + if ( trigger ) { + {{ trigger_func }}("{{ button_id }}", serviceId, rowId); + } + }) + {%- endfor %} + + {%- endfor %} + {%- if pagetype == vars.pagetype_workshopmanager %} + let modalWasVisible = false; + new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + modalWasVisible = true; + } else if (modalWasVisible) { + let serviceId = $('#service-input').val(); + let workshopId = $(`input[data-row='{{ vars.first_row_id }}'][id$='-workshopid-input']`).val(); + if ( !Array.isArray(workshopId) ){ + workshopId = [workshopId]; + } + window.location.href = window.location.origin + window.location.pathname + "?service=" + serviceId + "&row=" + workshopId + "&v" + new Date().getTime(); + } + }); + }).observe(document.getElementById('{{ service_id }}-{{ vars.first_row_id }}-workshop-modal')); + {%- endif %} + {%- endfor %} + // show / hide dependent elements + $(`select[id$='-input']`).change(function (event) { + const $this = $(this); + const serviceId = $this.attr("data-service"); + const rowId = $this.attr("data-row"); + const elementId = $this.attr("data-element"); + logDebug && console.log(`${elementId} changed ... `); + $(`[id^='${serviceId}-${rowId}-']`).trigger(`trigger_${elementId}`); + + const isDisabled = $this.prop("disabled"); + let newValues = $this.val(); + if ( !isDisabled && !(!newValues || (Array.isArray(newValues) && newValues.length === 0)) ) { + if ( !Array.isArray(newValues) ){ + newValues = [newValues]; + } + // const mappedValues = newValues.map(newValue => mappingDict[serviceId]?.[elementId]?.[newValue] ?? newValue); + const mappedValues = [...new Set(newValues.map(newValue => mappingDict[serviceId]?.[elementId]?.[newValue] ?? newValue))]; + const excludes = `:not(${mappedValues.map(value => `[data-dependency-${elementId}-${value}]`).join(',')})`; + let prefixSelector = ""; + let selector = ""; + + // Show + set to Active (add collect) + prefixSelector = `div[id^='${serviceId}-${rowId}-'][id$='-input-div'][data-show='true']`; + selector = mappedValues.map(value => `${prefixSelector}[data-dependency-${elementId}-${value}]`).join(','); + $(`${selector}`).show(); + + prefixSelector = `[id^='${serviceId}-${rowId}-'][id$='-input']:not([data-collect-static])`; + selector = mappedValues.map(value => `${prefixSelector}[data-dependency-${elementId}-${value}]`).join(','); + $(`${selector}`).attr("data-collect", true); + + // trigger all new activated label cb, to ensure normally hidden inputs are shown + prefixSelector = `[id^='${serviceId}-${rowId}-'][id$='-input-cb']`; + selector = mappedValues.map(value => `${prefixSelector}[data-dependency-${elementId}-${value}]`).join(','); + $(`${selector}`).trigger("change"); + + // show navbar buttons + prefixSelector = `button[id^='${serviceId}-${rowId}-'][id$='-navbar-button'][data-show="true"]`; + selector = mappedValues.map(value => `${prefixSelector}[data-dependency-${elementId}-${value}]`).join(','); + $(`${selector}`).trigger("show"); + $(`${selector}`).trigger("change"); + + // show buttons + prefixSelector = `button[id^='${serviceId}-${rowId}-'][id$='-btn']`; + selector = mappedValues.map(value => `${prefixSelector}[data-dependency-${elementId}-${value}]`).join(','); + $(`${selector}`).show(); + + {# Show / Hide dependency values #} + // hide + ignore + $(`div[id^='${serviceId}-${rowId}-'][id$='input-div'][data-dependency-${elementId}]${excludes}`).hide(); + $(`[id^='${serviceId}-${rowId}-'][id$='-input'][data-dependency-${elementId}]${excludes}`).attr("data-collect", false); + + // hide navbar buttons + selector = `button[id^='${serviceId}-${rowId}-'][id$='-navbar-button'][data-dependency-${elementId}]${excludes}`; + $(`${selector}`).trigger("hide"); + + // hide buttons + selector = `button[id^='${serviceId}-${rowId}-'][id$='-btn'][data-dependency-${elementId}]${excludes}`; + $(`${selector}`).hide(); + } else { + // hide + ignore all specific inputs + $(`div[id^='${serviceId}-${rowId}-'][id$='input-div'][data-dependency-${elementId}]`).hide(); + $(`[id^='${serviceId}-${rowId}-'][id$='input'][data-dependency-${elementId}]`).attr("data-collect", false); + } + }); + + + {%- if pagetype == vars.pagetype_workshopmanager %} + $(`[id$='-workshop-modal-copy-btn']`).on("click", function (){ + const $this = $(this); + const serviceId = $this.attr("data-service"); + const rowId = $this.attr("data-row"); + const workshopUrl = $(`#${serviceId}-${rowId}-workshop-modal .modal-body a`).attr('href'); + navigator.clipboard.writeText(workshopUrl).then(function() { + $(`#${serviceId}-${rowId}-workshop-modal-copy-btn`).tooltip('dispose').attr('title', 'Copied'); + $(`#${serviceId}-${rowId}-workshop-modal-copy-btn`).tooltip('show'); + }, function(err) { + console.error('Could not copy text: ', err); + }); + }); + + window.workshopManagerShowModal = function (serviceId, rowId, workshopId) { + let workshopUrl = new URL(utils.url_path_join(window.origin, base_url, "workshops", workshopId).replace("//", "/")); + $(`#${serviceId}-${rowId}-workshop-modal .modal-title`).text(`Share Workshop ${workshopId}`); + $(`#${serviceId}-${rowId}-workshop-modal .modal-body a`).text(`${workshopUrl}`); + $(`#${serviceId}-${rowId}-workshop-modal .modal-body a`).attr('href', workshopUrl); + $(`#${serviceId}-${rowId}-workshop-modal`).modal('show'); + } + {%- endif %} + + // secret input text fields --> + $(`button[id$='-view-password']`).on("click", function (event) { + const $this = $(this); + const serviceId = $this.attr("data-service"); + const rowId = $this.attr("data-row"); + const elementId = $this.attr("data-element"); + const passInput = $(`input[id^='${serviceId}-${rowId}-'][id$='-${elementId}-input']`); + const eye = $(`i[id^='${serviceId}-${rowId}-'][id$='-${elementId}-password-eye']`); + {# + const passInput = $('input[id*={{ id_prefix }}-{{ element_id }}-input]')[0]; + const eye = $('i[id*={{ id_prefix }}-{{ element_id }}-password-eye]')[0]; + #} + if (passInput.prop("type") === "password") { + passInput.prop("type", "text"); + eye.removeClass("fa-eye"); + eye.addClass("fa-eye-slash"); + } else { + passInput.prop("type", "password"); + eye.addClass("fa-eye"); + eye.removeClass("fa-eye-slash"); + } + }); + // <-- secret input text fields + + $(document).on("click", `button[data-textgrower-btn-type='add'][id$='-input']`, function (event) { + const $this = $(this); + const serviceId = $this.attr("data-service"); + const rowId = $this.attr("data-row"); + const tabId = $this.attr("data-tab"); + const elementId = $this.attr("data-element"); + + const parentContainer = $this.closest('.container'); + const countElements = parseInt(parentContainer.attr("data-count")) + 1; + parentContainer.attr("data-count", countElements); + const firstInputElement = $(`[id^='${serviceId}-${rowId}-${tabId}'][id$='-1-${elementId}-input']`); + const dataType = firstInputElement.attr("data-type"); + const group = firstInputElement.attr("data-group") ?? "default"; + const name = firstInputElement.attr("name") ?? elementId; + const type = firstInputElement.attr("type") ?? "text"; + let pattern = firstInputElement.attr("pattern"); + if ( !pattern ) { + pattern = ""; + } + let placeholder = firstInputElement.attr("placeholder"); + if ( !placeholder ) { + placeholder = ""; + } + + const newInputGroup = ` + <div class="input-group" style="display: flex; align-items: center; margin-bottom: 10px;"> + <input id="${serviceId}-${rowId}-${tabId}-${countElements}-${elementId}-input" type="${type}" + class="form-control" + data-service="${serviceId}" + data-row="${rowId}" + data-tab="${tabId}" + data-type="${dataType}" + data-element="${elementId}" + data-group="${group}" + data-collect="true" + data-collect-static + name="${name}" + type="${type}" + pattern="${pattern}" + placeholder="${placeholder}" + /> + <button data-collect-static data-textgrower-btn-type="del" data-element="${elementId}" data-service="${serviceId}" data-row="${rowId}" data-tab="${tabId}" data-collect="false" type="button" id="${serviceId}-${rowId}-${tabId}-${countElements}-delbtn-${elementId}-input" style="margin-left: 8px;" class="btn btn-danger">{{ svg.delete_svg | safe }}</button> + <button data-collect-static data-textgrower-btn-type="add" data-element="${elementId}" data-service="${serviceId}" data-row="${rowId}" data-tab="${tabId}" data-collect="false" type="button" id="${serviceId}-${rowId}-${tabId}-${countElements}-addbtn-${elementId}-input" style="margin-left: 8px;" class="btn btn-primary">{{ svg.plus_svg | safe }}</button> + </div> + `; + parentContainer.append(newInputGroup); + }); + $(document).on("click", `button[data-textgrower-btn-type='del'][id$='-input']`, function (event) { + $(this).closest('.input-group').remove(); + }); + + + $(`[data-sse-flavors]`).on("sse", function (event, data) { + const $this = $(this); + const serviceId = $this.attr("data-service"); + const rowId = $this.attr("data-row"); + for ( const [system, systemFlavors] of Object.entries(data) ) { + kubeOutpostFlavors[system] = systemFlavors; + } + const currentSystem = $(`[id^='${serviceId}-${rowId}-'][id$='-system-input']`); + if ( currentSystem.length && currentSystem.attr("data-collect") == "true" ) { + if ( Object.keys(data).includes(currentSystem.val()) ){ + setFlavorInfo(serviceId, rowId, currentSystem.val(), kubeOutpostFlavors[currentSystem.val()]); + } + } + }); + + $(`input[id$='-select-all']`).on("click", function () { + const $this = $(this); + const serviceId = $this.attr("data-service"); + const rowId = $this.attr("data-row"); + const tabId = $this.attr("data-tab"); + if ( $this.prop("checked") ) { + $(`input[id^='${serviceId}-${rowId}-${tabId}-'][id$='-input']`).prop("checked", true); + $(`[id^='${serviceId}-${rowId}-${tabId}-'][id$='-select-none']`).prop("checked", false); + } + }) + $(`input[id$='-select-none']`).on("click", function () { + const $this = $(this); + const serviceId = $this.attr("data-service"); + const rowId = $this.attr("data-row"); + const tabId = $this.attr("data-tab"); + if ( $this.prop("checked") ) { + $(`input[id^='${serviceId}-${rowId}-${tabId}-'][id$='-input']`).prop("checked", false); + $(`[id^='${serviceId}-${rowId}-${tabId}-'][id$='-select-all']`).prop("checked", false); + } + }) + + $(".summary-tr").on("click", function (event) { + if (![event.target, event.target.parentElement, event.target.parentElement?.parentElement] + .some(el => el?.tagName === "BUTTON")) { + const $this = $(this); + const id = $this.data("server-id"); + let accordionIcon = $(this).find(".accordion-icon"); + let collapse = $(`.collapse[id^=${id}]`); + if ( collapse.length > 0 ) { + let shown = collapse.hasClass("show"); + if (shown) accordionIcon.addClass("collapsed"); + else accordionIcon.removeClass("collapsed"); + new bootstrap.Collapse(collapse); + } + } + }); + + + logDebug && console.log(`Fill elements ...`); + $(`[id$='-input']`).trigger("trigger_init"); + logDebug && console.log(`Fill elements ... done`); + + // Set Defaults, as configured in config file + {%- for service_id, service_options in config.frontend_config.get("services", {}).get("options", {}).items() %} + logDebug && console.log("Set default values ( {{ service_id }} ) ..."); + {%- set default_tab_id = service_options.get("default", {}).get("tab", "default") %} + {%- for option_key, option_value in service_options.get("default", {}).get("options", {}).items() %} + optionElement = $(`[id^='{{ service_id }}-'][id$='-{{ default_tab_id }}-{{ option_key }}-input']`); + if ( optionElement.is("select") && optionElement.find(`option[value="{{ option_value }}"]:not(:disabled)`).length ){ + optionElement.val("{{ option_value }}"); + optionElement.trigger("change"); + } else if ( optionElement.is("input") ){ + optionElement.val("{{ option_value }}"); + optionElement.trigger("change"); + } + {%- endfor %} + {%- if pagetype == vars.pagetype_workshopmanager %} + {%- for row_id, values in db_workshops.items() %} + workshopManagerFillExistingRow("{{ service_id }}", "{{ row_id }}", {{ values | tojson }}); + {%- endfor %} + {%- elif pagetype == vars.pagetype_workshop %} + workshopManagerFillExistingRow("{{ service_id }}", "{{ spawner.name }}", {{ db_workshops.get(spawner.name, {}) | tojson }}); + {%- elif pagetype == vars.pagetype_home %} + // homeFillExistingRow + {%- for s in spawners %} + // console.log( {{ s }}); + // console.log( {{ user.spawners.get(s.name, s) }}); + {%- set spawner = user.spawners.get(s.name, s) %} + // console.log( {{ spawner.name }}); + // console.log( {{ spawner.events }}); + {%- if spawner.events %} + // console.log("y"); + {%- endif %} + {%- if spawner.user_options and spawner.user_options.get("name", false) %} + homeFillExistingRow("{{ service_id }}", "{{ spawner.name }}", {{ spawner.user_options | tojson }}, {{ service_options.get("fillingOrder", []) | tojson }}); + {%- if spawner.events %} + {%- for event in spawner.events %} + appendToLog("{{ service_id }}", "{{ spawner.name }}", {{ event | tojson }}); + {%- endfor %} + {%- endif %} + {%- endif %} + {%- endfor %} + {%- endif %} + logDebug && console.log("Set default values ( {{ service_id }} ) done"); + + {%- endfor %} + + {%- if pagetype == vars.pagetype_workshop %} + let service = ""; + let name = ""; + let lastEvent = false; + let updateProgressBar = false; + {# iterate through all spawners #} + service = "{{ spawner.user_options.get("service", "jupyterlab") | lower }}"; + name = "{{ spawner.name }}"; + events = []; + {%- if spawner and spawner.events %} + events = {{ spawner.events | tojson }}; + {%- endif %} + lastEvent = events.length > 0 ? events[events.length - 1] : false; + clearLogs(service, name); + if ( lastEvent ) { + {%- if spawner.cancel_pending or spawner.active %} + updateProgressBar = true; + {%- else %} + updateProgressBar = lastEvent.progress != 100; + {%- endif %} + } + events.forEach( event => { + if ( updateProgressBar ) { + let ready = event.ready ?? false; + let failed = event.failed ?? false; + let progress = event.progress ?? 0; + let status = "starting"; + if ( ready ) status = "running"; + else if ( failed ) status = "stopped"; + else if ( progress == 99 ) status = "cancelling"; + else if ( progress == 0 ) status = ""; + progressBarUpdate(serviceId, rowId, status, progress); + } + appendToLog(service, name, event); + }); + if ( updateProgressBar ) { + $(`#${service}-${name}-logs-navbar-button`).trigger("click"); + } + {# Set Buttons in correct state #} + // status: ["running", "starting", "na", "stopping", "cancelling", "stopped"] + let status = "stopped"; + {%- if spawner.cancel_pending %} + status = "cancelling"; + {%- elif not spawner.ready and spawner.active %} + status = "starting"; + {%- elif spawner.ready %} + status = "running"; + {% endif %} + updateHeaderButtons(service, name, status); + + const workshopValues = {{ db_workshops | tojson }}?.['{{ spawner.name }}']?.user_options || {}; + const serviceId = "{{ spawner.user_options.get("service", "jupyterlab") | lower }}"; + + + $(`input[id^='${serviceId}-{{ spawner.name }}-'][id$='-input']`).each( function () { + const $this = $(this); + const dataGroup = $this.attr("data-group"); + const key = $this.attr("data-element"); + let keys = []; + let newValue = ""; + if ( ["none", "default"].includes(dataGroup) ) { + keys = Object.keys(workshopValues); + if ( keys.includes(key) ) { + newValue = workshopValues[key]; + } + } else { + keys = Object.keys(workshopValues?.[dataGroup] || {}); + if ( keys.includes(key) ) { + newValue = workshopValues[dataGroup][key]; + } + } + if ( newValue ) { + $this.attr("data-alwaysdisabled", "true"); + $this.val(newValue); + $this.attr("value", newValue); + $this.prop("disabled", true); + + const labelElement = $(`#${$this.prop('id')}-cb`); + if ( labelElement ) { + labelElement.prop("checked", "true"); + labelElement.prop("disabled", "true"); + labelElement.trigger("change"); + } + } + }); + + + $(`select[id^='${serviceId}-{{ spawner.name }}-'][id$='-input']`).each( function () { + const $this = $(this); + const dataGroup = $this.attr("data-group"); + const key = $this.attr("data-element"); + let keys = []; + let newValue = ""; + if ( ["none", "default"].includes(dataGroup) ) { + keys = Object.keys(workshopValues); + if ( keys.includes(key) ) { + newValue = workshopValues[key]; + } + } else { + keys = Object.keys(workshopValues?.[dataGroup] || {}); + if ( keys.includes(key) ) { + newValue = workshopValues[dataGroup][key]; + } + } + if ( newValue ) { + $this.val(newValue); + $this.attr("value", newValue); + + const labelElement = $(`#${$this.prop('id')}-cb`); + if ( labelElement ) { + labelElement.prop("checked", "true"); + labelElement.prop("disabled", "true"); + labelElement.trigger("change"); + } + } + }); + for ( const [key, value] of Object.entries(workshopValues?.defaultvalues || {}) ) { + const inputElement = $(`[id^='${serviceId}-{{ spawner.name }}-'][id$='-${key}-input']`); + inputElement.val(value); + inputElement.trigger("change"); + } + + {%- if spawner and spawner.events %} + fillLogContainer(serviceId, "{{ spawner.name }}", {{ spawner.events | tojson }}); + {%- else %} + defaultLogs(serviceId, "{{ spawner.name }}"); + {%- endif %} + {%- endif %} + + const queryString = window.location.search; + const urlParams = new URLSearchParams(queryString); + if (urlParams.has('row') && urlParams.has('service')) { + + // Get the value of the "row" parameter + const serviceValue = urlParams.get('service'); + const rowValue = urlParams.get('row'); + + $(`div[id$='-table-div']:not([id^='${serviceValue}-'])`).hide(); + $(`div[id$='-table-div'][id^='${serviceValue}-']`).show(); + + $(`div[id$='-collapse']:not([id^='${serviceValue}-${rowValue}-collapse'])`).removeClass("show"); + $(`div[id^='${serviceValue}-${rowValue}-collapse']`).addClass("show"); + let x = document.getElementById(`${serviceValue}-${rowValue}-summary-tr`) + if ( x ) x.scrollIntoView(); + if ( urlParams.has('showlogs') ) $(`[id^='${serviceValue}-${rowValue}-'][id$='-logs-navbar-button']`).trigger("click"); + } + {%- if pagetype == vars.pagetype_workshop %} + logDebug && console.log(`Fill elements ...`); + $(`[id$='-input']`).trigger("trigger_init"); + logDebug && console.log(`Fill elements ... done`); + {%- endif %} + + {%- if pagetype == vars.pagetype_workshop %} + {%- endif %} + }); + }); +</script> diff --git a/templates/macros/table/helpers/options_js.jinja b/templates/macros/table/helpers/options_js.jinja new file mode 100644 index 0000000..44f526a --- /dev/null +++ b/templates/macros/table/helpers/options_js.jinja @@ -0,0 +1,88 @@ +{# lmod --> #} + function getModuleValues(serviceId, rowId, name, setName) { + const options = val(getInputElement(serviceId, rowId, "option")); + let values = []; + let keys = new Set(); + options.forEach(option => { + if (getServiceConfig(serviceId)?.options?.[option]?.[setName]) { + const nameSet = getServiceConfig(serviceId)?.options[option]?.[setName]; + Object.entries(userModulesConfig[name]) + .filter(([key, value]) => value.sets && value.sets.includes(nameSet)) + .forEach( ([key, value]) => { + if ( !keys.has(key) ) { + keys.add(key); + values.push([ + key, + value.displayName, + typeof value.default === 'object' && value.default !== null ? value.default.default : value.default, + value.href + ]); + } + }); + } + }); + return values; + } + + + function updateModule(serviceId, rowId, tabId, elementId, elementOptions, name, setName) { + const containerDiv = $(`div[id^='${serviceId}-${rowId}-${modulesTabName}-'][id$='${elementId}-checkboxes-div']`); + const inputDiv = $(`div[id^='${serviceId}-${rowId}-${modulesTabName}-'][id$='${elementId}-input-div']`); + let values = getModuleValues(serviceId, rowId, name, setName); + + // for workshops we will disable all checkboxes, so users cannot change the selection + let workshopPreset = false; + let workshopPresetChecked = []; + {%- if pagetype == vars.pagetype_workshop and db_workshops %} + const workshopValues = {{ db_workshops | tojson }}.user_options || {}; + if ( Object.keys(workshopValues).includes("userModules") && Object.keys(workshopValues.userModules).includes(name) ){ + workshopPreset = true; + const modules = workshopValues.userModules[name]; + if ( modules.length > 0 ) { + workshopPresetChecked = modules; + } + } + {%- endif %} + + // Ensure the container exists + if (containerDiv.length > 0 && values.length > 0) { + const idPrefix = containerDiv.attr('id').replace(/-checkboxes-div$/, ""); + containerDiv.html(''); + values.forEach(function (item) { + let isChecked = ''; + let isDisabled = ''; + if ( workshopPreset ) { + if ( workshopPresetChecked.includes(item[0]) ){ + isChecked = 'checked'; + } + isDisabled = 'disabled="true"'; + } else { + isChecked = item[2] ? 'checked' : ''; + } + // Create the new div block + const newDiv = $(` + <div id="${idPrefix}-${item[0]}-input-div" class="form-check col-sm-6 col-md-4 col-lg-3"> + <input type="checkbox" name="${item[0]}" class="form-check-input" id="${idPrefix}-${item[0]}-input" value="${item[0]}" ${isChecked} ${isDisabled}/> + <label class="form-check-label" for="${idPrefix}-${item[0]}-input"> + <span class="align-middle">${item[1]}</span> + <a href="${item[3]}" target="_blank" class="module-info text-muted ms-3"> + <span>{{ svg.info_svg | safe }}</span> + <div class="module-info-link-div d-inline-block"> + <span class="module-info-link" id="nbdev-info-link"> {{ svg.link_svg | safe }}</span> + </div> + </a> + </label> + </div> + `); + // Append the new div to the container + containerDiv.append(newDiv); + // Add toggle function to each checkbox + $(`#${idPrefix}-${item[0]}-input`).on("click", function (event) { + $(`input[id^='${serviceId}-${rowId}-${modulesTabName}-'][id$='-select-all']`).prop("checked", false); + $(`input[id^='${serviceId}-${rowId}-${modulesTabName}-'][id$='-select-none']`).prop("checked", false); + }); + }); + } + inputDiv.show(); + } +{# <-- lmod #} \ No newline at end of file diff --git a/templates/macros/table/helpers/systems_js.jinja b/templates/macros/table/helpers/systems_js.jinja new file mode 100644 index 0000000..8f884ec --- /dev/null +++ b/templates/macros/table/helpers/systems_js.jinja @@ -0,0 +1,630 @@ +{# Kube Systems --> #} + const kubeOutpostFlavors = {{ auth_state.outpost_flavors | tojson }}; + function _getKubeSystems() { + return Object.keys(systemConfig).filter(system => { + const backendService = systemConfig[system].backendService; + // Check if the backend service type is "kube" + return backendServicesConfig[backendService]?.type === "kube"; + }); + } + + const kubeSystems = _getKubeSystems(); + + function getAvailableKubeFlavorsS(systems) { + let ret = []; + + systems.forEach(system => { + const allFlavors = kubeOutpostFlavors[system]; + if ( allFlavors ) { + ret.push(...Object.keys(allFlavors) + .filter(key => allFlavors[key].max != 0) // do not use flavor.max == 0 + .filter(key => allFlavors[key].current < allFlavors[key].max || allFlavors[key].max == -1 ) // must be room for new jupyterlabs + .sort((a, b) => allFlavors[a].weight - allFlavors[b].weight) // sort by weight + .map(key => [key, allFlavors[key].display_name])); // get keyname + displayname + } + }); + return ret; + } + + function getUnavailableKubeFlavorsS(systems) { + let ret = []; + + systems.forEach(system => { + const allFlavors = kubeOutpostFlavors[system]; + + if ( allFlavors ) { + ret.push(...Object.keys(allFlavors) + .filter(key => allFlavors[key].max != 0) // do not use flavor.max == 0 + .filter(key => allFlavors[key].current >= allFlavors[key].max && allFlavors[key].max != -1) + .sort((a, b) => allFlavors[a].weight - allFlavors[b].weight) + .map(key => [key, allFlavors[key].display_name])); + } + }); + return ret; + } +{# <-- Kube Systems #} + +{# Unicore Systems --> #} + {# + JavaScript functions to get user specific information for the HPC systems which support UNICORE. + #} + + const resPattern = /^urn:(?<namespace>.+?(?=:res:)):res:(?<systempartition>[^:]+):(?<project>[^:]+):act:(?<account>[^:]+):(?<accounttype>[^:]+)$/; + const unicoreEntitlements = {{ auth_state.entitlements | list | tojson }}; + const unicorePreferredUsername = {{ auth_state.preferred_username | tojson }}; + const unicoreReservations = {{ reservations | tojson }}; + const unicoreMapSystems = {{ custom_config.mapSystems | tojson }}; + const unicoreMapPartitions = {{ custom_config.mapPartitions | tojson }}; + const unicoreDefaultPartitions = {{ custom_config.defaultPartitions | tojson }}; + + function extractEntitlementResources(entitlement) { + const match = resPattern.exec(entitlement); + if (match) { + // Access named capture groups using match.groups + let system_ = unicoreMapSystems[match.groups.systempartition.toLowerCase()]; + let partition_ = unicoreMapPartitions[match.groups.systempartition.toLowerCase()]; + if ( Object.keys(resourcesConfig[system_] ?? {}).includes(partition_) ){ + return { + systempartition: match.groups.systempartition, + project: match.groups.project, + account: match.groups.account, + accounttype: match.groups.accounttype + }; + } + } + return null; // Return null if no match is found + } + + function _getUnicoreAccountType() { + for (let entitlement of unicoreEntitlements) { + const entitlementInfo = extractEntitlementResources(entitlement); + + if (entitlementInfo && entitlementInfo.account === unicorePreferredUsername) { + return entitlementInfo.accounttype; + } + } + return null; + } + + const unicoreAccountType = _getUnicoreAccountType(); + + function _getUnicoreSystemPartitions() { + const systemPartitions = unicoreEntitlements + .map(extractEntitlementResources) + .filter(Boolean) + .map(tmp => tmp.systempartition); + + if (unicoreAccountType === "normal") { + return [...new Set(systemPartitions)]; + } + + if (unicoreAccountType === "secondary") { + return [...new Set( + systemPartitions.filter(systempartition => { + return unicoreEntitlements + .map(extractEntitlementResources) // Extract entitlement resources + .filter(Boolean) + .some(tmp => tmp.systempartition === systempartition && tmp.account === unicorePreferredUsername); + }) + )]; + } + + return []; + } + + const unicoreSystemPartitions = _getUnicoreSystemPartitions(); + + function _getUnicoreSystems() { + // Get all systems corresponding to the system partitions + let systems = unicoreSystemPartitions + .map(key => unicoreMapSystems[key.toLowerCase()]) // Map system partitions to their respective systems + .filter(system => system); // Remove falsy values (null, undefined, etc.) + + // If the unicoreAccountType is "normal", return all systems (no filtering) + if (unicoreAccountType === "normal") { + return [...new Set(systems)]; // Remove duplicates using Set + } + + // If the unicoreAccountType is "secondary", filter systems based on unicorePreferredUsername + if (unicoreAccountType === "secondary") { + return [...new Set( + systems.filter(system => { + // Filter systems where the system matches the unicorePreferredUsername in unicoreEntitlements + return unicoreEntitlements + .map(extractEntitlementResources) // Extract entitlement resources + .filter(Boolean) // Remove falsy values (null, undefined, etc.) + .some(tmp => tmp.systempartition && unicoreMapSystems[tmp.systempartition.toLowerCase()] === system && tmp.account === unicorePreferredUsername); + }) + )]; // Remove duplicates using Set + } + + // Return an empty array if unicoreAccountType is neither "normal" nor "secondary" + return []; + } + + const unicoreSystems = _getUnicoreSystems(); + + function _getAllUnicoreAccountsBySystemPartition() { + const accountsBySystemPartition = {}; // The output dictionary where key is systempartition and value is list of accounts + + // Initialize accounts list for each systempartition + unicoreSystemPartitions.forEach(function(systempartition) { + accountsBySystemPartition[systempartition] = new Set(); // Using Set to store unique accounts for each systempartition + }); + + // Iterate through all unicoreEntitlements and populate the accounts for the relevant unicoreSystemPartitions + unicoreEntitlements.forEach(function(entitlement) { + const entitlementInfo = extractEntitlementResources(entitlement); + + if (entitlementInfo && unicoreSystemPartitions.includes(entitlementInfo.systempartition)) { + accountsBySystemPartition[entitlementInfo.systempartition].add(entitlementInfo.account); + } + }); + + // Filter accounts based on unicoreAccountType + Object.keys(accountsBySystemPartition).forEach(function(systempartition) { + // If the unicoreAccountType is "secondary", only keep accounts that match unicorePreferredUsername + if (unicoreAccountType === "secondary") { + accountsBySystemPartition[systempartition] = [...accountsBySystemPartition[systempartition]] + .filter(account => account === unicorePreferredUsername); + } else { + // For "normal" account type, return all accounts + accountsBySystemPartition[systempartition] = [...accountsBySystemPartition[systempartition]]; + } + }); + return accountsBySystemPartition; + } + + const unicoreAccountsBySystemPartition = _getAllUnicoreAccountsBySystemPartition(); + + function getUnicoreProjectsBySystemPartition() { + const projectsBySystemPartition = {}; // Output dictionary where key is systempartition and value is list of projects + + // Initialize projects list for each systempartition + unicoreSystemPartitions.forEach(function(systempartition) { + projectsBySystemPartition[systempartition] = new Set(); // Using Set to store unique projects + }); + + // Iterate through all unicoreEntitlements and populate the projects for the relevant unicoreSystemPartitions + unicoreEntitlements.forEach(function(entitlement) { + const entitlementInfo = extractEntitlementResources(entitlement); + + // Check if entitlement has valid info and if it matches the system partitions list + if (entitlementInfo && unicoreSystemPartitions.includes(entitlementInfo.systempartition)) { + // Only add project if account matches the unicorePreferredUsername when unicoreAccountType is secondary + if (unicoreAccountType === "normal" || entitlementInfo.account === unicorePreferredUsername) { + projectsBySystemPartition[entitlementInfo.systempartition].add(entitlementInfo.project); + } + } + }); + + // Convert Set to Array for each systempartition in the output dictionary + Object.keys(projectsBySystemPartition).forEach(function(systempartition) { + projectsBySystemPartition[systempartition] = [...projectsBySystemPartition[systempartition]]; + }); + + return projectsBySystemPartition; + } + + function getUnicorePartitions() { + let partitions = new Set(); + + // Iterate over unicoreSystemPartitions and add the corresponding partition names to the set + unicoreSystemPartitions.forEach((partition) => { + const partitionName = unicoreMapPartitions[partition.toLowerCase()]; + if (partitionName) { + partitions.add(partitionName); + } + }); + + // Add default partitions to the set + Object.keys(unicoreDefaultPartitions).forEach((partition) => { + unicoreDefaultPartitions[partition].forEach((defaultPartition) => { + partitions.add(defaultPartition); + }); + }); + + // If the unicoreAccountType is "normal", return all partitions (no filtering) + if (unicoreAccountType === "normal") { + return [...partitions]; // Convert Set to Array (removes duplicates) + } + + // If the unicoreAccountType is "secondary", filter the partitions based on unicorePreferredUsername + if (unicoreAccountType === "secondary") { + return [...new Set( + [...partitions].filter(partition => { + // Check if the partition matches the preferred username from unicoreEntitlements + return unicoreEntitlements + .map(extractEntitlementResources) // Extract entitlement resources + .filter(Boolean) // Remove falsy values (null, undefined, etc.) + .some(tmp => tmp.systempartition && unicoreMapPartitions[tmp.systempartition.toLowerCase()] === partition && tmp.account === unicorePreferredUsername); + }) + )]; // Remove duplicates using Set + } + + // Return an empty array if unicoreAccountType is neither "normal" nor "secondary" + return []; + } + + function getUnicoreAccountsS(systems) { + const accounts = new Set(); // A Set to ensure accounts are unique + + // Iterate over the unicoreEntitlements to collect accounts related to the system + systems.forEach(system => { + unicoreEntitlements.forEach(function(entitlement) { + const entitlementInfo = extractEntitlementResources(entitlement); + + // Check if the entitlement is for the provided system + if (entitlementInfo && unicoreMapSystems[entitlementInfo.systempartition.toLowerCase()] === system) { + if (unicoreAccountType === "normal") { + // If unicoreAccountType is "normal", add all accounts for the system + accounts.add(entitlementInfo.account); + } else if (unicoreAccountType === "secondary") { + // If unicoreAccountType is "secondary", only add the account if it matches unicorePreferredUsername + if (entitlementInfo.account === unicorePreferredUsername) { + accounts.add(entitlementInfo.account); + } + } + } + }); + }); + + // Return the accounts as an array, since we're using a Set to avoid duplicates + return [...accounts]; + } + + function getUnicoreProjectsSA(systems, accounts) { + const projects = []; // Initialize an empty array to store the list of projects + + // Iterate through entitlements to find all projects for the system and account + systems.forEach(system => { + accounts.forEach(account => { + unicoreEntitlements.forEach(function(entitlement) { + const entitlementInfo = extractEntitlementResources(entitlement); + + // Check if entitlement matches the provided system + if (entitlementInfo) { + const mappedSystem = unicoreMapSystems[entitlementInfo.systempartition.toLowerCase()]; + if (mappedSystem === system) { + if (unicoreAccountType === "normal") { + // If unicoreAccountType is "normal", we check if the account matches + if (entitlementInfo.account === account) { + projects.push(entitlementInfo.project); // Add project to the list + } + } else if (unicoreAccountType === "secondary") { + // If unicoreAccountType is "secondary", only consider the entitlement if the account matches the preferred username + if (entitlementInfo.account === unicorePreferredUsername) { + if (entitlementInfo.account === account) { + projects.push(entitlementInfo.project); // Add project to the list + } + } + } + } + } + }); + }); + }); + return [...new Set(projects)]; + } + + function getUnicoreProjectsS(systems) { + const projects = []; // Initialize an empty array to store the list of projects + + // Iterate through entitlements to find all projects for the system and account + systems.forEach(system => { + unicoreEntitlements.forEach(function(entitlement) { + const entitlementInfo = extractEntitlementResources(entitlement); + + // Check if entitlement matches the provided system + if (entitlementInfo) { + const mappedSystem = unicoreMapSystems[entitlementInfo.systempartition.toLowerCase()]; + if (mappedSystem === system) { + projects.push(entitlementInfo.project); + } + } + }); + }); + return [...new Set(projects)]; + } + + function getUnicorePartitionsSAP(systems, accounts=[], projects=[]) { + // Initialize the result list of partitions + let partitions = []; + let interactivePartitions = []; + let allPartitions = []; + systems.forEach(system => { + accounts.forEach(account => { + projects.forEach(project => { + // 1. Add interactive partitions for the given system (if any) + + // 2. Get the system partitions for the given system and account/project (with entitlement checking) + const allPartitions_ = new Set(); // Using Set to ensure unique entries + + let interactiveAdded = false; + + // Iterate over the entitlements to get partitions for the specified system, account, and project + unicoreEntitlements.forEach(function(entitlement) { + const entitlementInfo = extractEntitlementResources(entitlement); + if (entitlementInfo && unicoreMapSystems[entitlementInfo.systempartition.toLowerCase()] === system && (entitlementInfo.project === project || project === "_all_")) { + // Apply unicoreAccountType logic + if (unicoreAccountType === "normal" || account === "_all_") { + if ( !interactiveAdded ){ + interactiveAdded = true; + const interactivePartitions_ = systemConfig[system]?.interactivePartitions || []; + interactivePartitions = [...new Set([...interactivePartitions, ...interactivePartitions_])]; // Start with interactive partitions + } + // For normal accounts, match the exact account + if (entitlementInfo.account === account || account === "_all_") { + allPartitions_.add(unicoreMapPartitions[entitlementInfo.systempartition.toLowerCase()]); + } + } else if (unicoreAccountType === "secondary") { + // For secondary accounts, only match if the account is the preferred username + if ( !interactiveAdded ){ + interactiveAdded = true; + const interactivePartitions_ = systemConfig[system]?.interactivePartitions || []; + interactivePartitions = [...new Set([...interactivePartitions, ...interactivePartitions_])]; // Start with interactive partitions + } + if (entitlementInfo.account === unicorePreferredUsername && entitlementInfo.account === account) { + allPartitions_.add(unicoreMapPartitions[entitlementInfo.systempartition.toLowerCase()]); + } + } + } + // 3. Add the partitions from entitlements to the list (remove duplicates automatically due to Set) + allPartitions = [...new Set([...allPartitions, ...allPartitions_])]; + }); + + // 4. Add default partitions for the given system + + Object.keys(unicoreDefaultPartitions).forEach(function(systempartition) { + let system_ = unicoreMapSystems[systempartition]; + if ( system_ === system ) { + // Check if the systempartition matches + if (unicoreDefaultPartitions[systempartition]) { + // Add the corresponding partition from the unicoreMapPartitions object + if (allPartitions.includes(unicoreMapPartitions[systempartition])) { + unicoreDefaultPartitions[systempartition].forEach(function(defaultPartition) { + allPartitions.push(unicoreMapPartitions[defaultPartition.toLowerCase()]); + }); + } + } + } + }); + }); + }); + }); + + // 5. Return the list of partitions (interactive first, then others, with defaults added) + return [...new Set([...interactivePartitions, ...allPartitions])]; + } + + function getAllUnicoreReservations() { + // Initialize an empty array to store reservation names + let reservations = []; + + // Iterate over each system in the reservations object + Object.keys(unicoreReservations).forEach(system => { + // For each system, iterate over the reservations array + unicoreReservations[system].forEach(reservation => { + // Add the entire reservation object to the list (instead of just ReservationName) + reservations.push(reservation); + }); + }); + + // Return the list of all reservations + return reservations; + } + + function getUnicoreReservationsS(systems) { + // Check if the system exists in the reservations object + let reservations = []; + systems.forEach(system => { + if (unicoreReservations[system]) { + // Map the reservations for the system and return an array of ReservationNames + reservations.push(unicoreReservations[system].filter(reservation => reservation).map(reservation => reservation)); + } + }); + return reservations; + } + + function getUnicoreReservationsSAPP(systems, accounts, projects, partitions) { + // Check if the system exists in the reservations object + let reservations = []; + + systems.forEach(system => { + accounts.forEach(account => { + projects.forEach(project => { + partitions.forEach(partition => { + if (!unicoreReservations[system]) { + return; + } + + // Check if the partition is interactive for the given system + const isInteractivePartition = systemConfig[system] && systemConfig[system].interactivePartitions.includes(partition); + + // If the partition is interactive, do not return any reservations for it + if (isInteractivePartition) { + return; + } + + // Filter the reservations for the given system based on the provided account, project, and partition + reservations.push( + ...unicoreReservations[system].filter(reservation => { + const partitionMatches = (reservation.PartitionName === "" || reservation.PartitionName === partition || partition === "_all_"); + const usersMatch = (reservation.Users === "" || reservation.Users.split(",").includes(account) || account === "_all_"); + const accountsMatch = (reservation.Accounts === "" || reservation.Accounts === project || project === "_all_"); + return partitionMatches && usersMatch && accountsMatch; + }) + ); + }); + }); + }); + }); + return [...new Set(reservations)]; + } + + + function getUnicoreValues(serviceId, rowId, elementId) { + const inputElement = getInputElement(serviceId, rowId, elementId); + const labelElementCB = getLabelCBElement(serviceId, rowId, elementId); + //if (inputElement.length == 0 || inputElement.attr("data-collect") === "false" ) { + if (inputElement.length == 0 || inputElement.is("[disabled]") ) { + // Input does not exist, or is disabled. Use the keyword _all_ instead. + return ["_all_"]; + } else { + return val(inputElement); + } + + } + + function getAccountOptions(serviceId, rowId) { + const systems = val(getInputElement(serviceId, rowId, "system")); + const accounts = getUnicoreAccountsS(systems); + if (accounts.includes(unicorePreferredUsername)) { + accounts.sort(account => account === unicorePreferredUsername ? -1 : 1); + } + return accounts.map(item => [item, item]); + } + + function getProjectOptions(serviceId, rowId) { + const systems = val(getInputElement(serviceId, rowId, "system")); + let projects = []; + const accountInput = getInputElement(serviceId, rowId, "account"); + const accounts = val(accountInput); + if ( accountInput.length > 0 && accounts.length > 0 && accounts[0] ){ + // Account Option exists, let's take it into account + const accounts = val(accountInput); + projects = getUnicoreProjectsSA(systems, accounts); + } else { + // Acount selection does not exists (e.g. in workshopManager) + projects = getUnicoreProjectsS(systems); + } + return projects.map(item => [item, item]); + } + + function getPartitionOptions(serviceId, rowId) { + const systems = val(getInputElement(serviceId, rowId, "system")); + const accounts = getUnicoreValues(serviceId, rowId, "account"); + const projects = getUnicoreValues(serviceId, rowId, "project"); + let partitions = getUnicorePartitionsSAP(systems, accounts, projects); + + return partitions.map(item => [item, item]); + } + + function getPartitionAndInteractivePartition(serviceId, rowId) { + const systems = val(getInputElement(serviceId, rowId, "system")); + const partitions = getPartitionOptions(serviceId, rowId); + let interactivePartitionsLength = 0; + let interactivePartitionAdded = []; + partitions.forEach(partition => { + let partition_ = partition[0]; + systems.forEach(system => { + if ( (systemConfig[system]?.interactivePartitions || []).includes(partition_) ) { + if ( !interactivePartitionAdded.includes(partition_) ) { + interactivePartitionsLength += 1; + interactivePartitionAdded.push(partition_); + } + } + }); + }); + return [partitions, interactivePartitionsLength]; + } + + function getReservationOptions(serviceId, rowId) { + const systems = val(getInputElement(serviceId, rowId, "system")); + const accounts = getUnicoreValues(serviceId, rowId, "account"); + const projects = getUnicoreValues(serviceId, rowId, "project"); + const partitions = getUnicoreValues(serviceId, rowId, "partition"); + + return getUnicoreReservationsSAPP(systems, accounts, projects, partitions); + } +{# <-- Unicore Systems #} + +{# All Systems --> #} + function _getAllSystems() { + // Combine both lists and remove duplicates using a Set + let allSystems = [...new Set([...unicoreSystems, ...kubeSystems])]; + + {%- if pagetype == vars.pagetype_workshop %} + const db_workshops = {{ db_workshops | tojson }}; + let allowedSystems = Object.values(db_workshops)[0]?.user_options?.system ?? false; + if ( allowedSystems ) { + allSystems = allSystems.filter(system => allowedSystems.includes(system)); + } + {%- endif %} + + return allSystems; + } + + const allSystems = _getAllSystems(); + + function getAvailableSystemOptions(serviceId, options) { + let ret = []; + options.forEach(option => { + if (getServiceConfig(serviceId)?.options) { + const subSystems1 = getServiceConfig(serviceId).options[option].allowedLists.systems; + ret.push(...allSystems.filter(system => subSystems1.includes(system))); + } else { + // return all systems, if it's not reduced by the option + ret.push(...allSystems); + } + }); + + const uniqueSystems = [...new Set(ret)]; + uniqueSystems.sort((a, b) => (systemConfig[a].weight || 0) - (systemConfig[b].weight || 0)); + + return uniqueSystems.map(item => [item, item]); + } + + function getMissingSystemOptions(serviceId, rowId, options) { + let availableSystems = getAvailableSystemOptions(serviceId, options); + let missingSystems = allSystems.filter(system => !availableSystems.map(([key, value]) => key).includes(system)); + + {%- if pagetype == vars.pagetype_workshop %} + const db_workshops = {{ db_workshops | tojson }}; + let allowedSystems = db_workshops[rowId]?.user_options?.system ?? false; + if ( allowedSystems ) { + missingSystems = missingSystems.filter(system => allowedSystems.includes(system)); + } + {%- endif %} + return missingSystems.map(item => [item, item]); + } + + + function getSystemValues(serviceId, rowId, element) { + let systems = val(getInputElement(serviceId, rowId, "system")); + let values = []; + if ( element === "system" ){ + values = systems; + } else { + let systemTypesChecked = []; + systems.forEach(system => { + const backendService = systemConfig[system]?.backendService; + const systemType = backendServicesConfig[backendService]?.type; + if ( !systemTypesChecked.includes(systemType) ) { + systemTypesChecked.push(systemType); + let value = $(`[id^='${serviceId}-${rowId}-'][id$='-${element}-input']`).val(); + if ( value ) { + if (!Array.isArray(value)) { + value = [value]; + } + values.push(...value); + } + } + }); + } + return values; + } + + + function getSystemTypes(serviceId, rowId) { + const systems = getSystemValues(serviceId, rowId, "system"); + let systemTypes = []; + systems.forEach(system => { + const systemType = mappingDict[serviceId]?.["system"]?.[system] ?? system; + if ( !systemTypes.includes(systemType) ){ + systemTypes.push(systemType); + } + }); + return systemTypes; + } +{# <-- All Systems #} \ No newline at end of file diff --git a/templates/macros/table/table.jinja b/templates/macros/table/table.jinja new file mode 100644 index 0000000..ec408b5 --- /dev/null +++ b/templates/macros/table/table.jinja @@ -0,0 +1,149 @@ +{%- macro tables( + frontend_config, + macro_description, + macro_headerlayout, + macro_defaultheader, + macro_firstheader, + macro_row_content, + header_button_functions={}, + sse_functions=false +)%} + <div id="global-content-div" class="container-fluid p-4"> + <input id="service-input" class="form-control" data-collect="true" data-group="default" data-type="input" name="service" data-element="service" value="{{ frontend_config.get("services", {}).get("default", "jupyterlab") }}" style="display: none"/> + {#- TABLE #} + {%- for service_id, service_options in frontend_config.get("services", {}).get("options", {}).items() %} + {%- set is_first_service = loop.first %} + <div id="{{ service_id }}-table-div" class="table-responsive-md"> + {%- if macro_description %} + {{ macro_description() }} + {%- endif %} + <table id="{{ service_id }}-table" class="table table-bordered table-striped table-hover table-light align-middle"> + {#- TABLE HEAD #} + <thead class="table-secondary"> + <tr> + {%- if macro_headerlayout %} + {{ macro_headerlayout() }} + {%- endif %} + </tr> + </thead> + {#- TABLE BODY #} + <tbody> + {# - List existing workshops #} + {%- for row_id, row_options in table_rows.items() %} + {%- set is_first_row = loop.first %} + <!-- summary of row --> + <tr id="{{ service_id }}-{{ row_id }}-summary-tr" data-server-id="{{ service_id }}-{{ row_id }}" class="summary-tr"> + <td class="details-td" data-bs-target="#{{ row_id }}-collapse"> + {%- if loop.first %} + <div class="d-flex mx-4"> + {{ svg.plus_svg | safe }} + </div> + {%- else %} + <div class="d-flex mx-auto accordion-icon collapsed mx-4"></div> + {%- endif %} + </td> + {%- if loop.index0 and macro_defaultheader %} + {{ macro_defaultheader(service_id, row_id, row_options) }} + {%- else %} + {%- if macro_firstheader %} + {{ macro_firstheader(service_id, row_id, row_options) }} + {%- endif %} + {%- endif %} + </tr> + + <!-- collapsible row --> + <tr data-server-id="{{ service_id }}-{{ row_id }}" class="collapsible-tr" style="--bs-table-accent-bg: transparent;"> + <td colspan="100%" class="p-0"> + <div class="collapse {%- if loop.first and (table_rows | length == 1) %} show {%- endif -%}" id="{{ service_id }}-{{row_id}}-collapse"> + <div class="d-flex align-items-start m-3"> + {%- if service_options.navbar | length > 0 %} + <div class="nav flex-column nav-pills p-3 ps-0" style="min-width: 15% !important" id="{{ service_id }}-{{ row_id }}-tab-button-div" role="tablist"> + {%- for button_id, button_options in service_options.navbar.items() %} + {%- if ( is_first_row and button_options.get("firstRow", true) ) or + ( (not is_first_row) and button_options.get("defaultRow", true) ) + %} + {%- set style_hide = 'height: 0 !important; overflow: hidden !important; padding-top: 0 !important; padding-bottom: 0 !important; border: none !important; margin: 0 !important;' %} + <button + class="nav-link {{ 'active' if show else '' }} {{ button_options.get("margins", "mb-3") }} {%- if loop.index0 == 0 %} active {%- endif -%}" + id="{{ service_id }}-{{ row_id }}-{{ button_id }}-navbar-button" + {%- if not button_options.get("show", false) %} + {# + Instead of just .hide() it, we want to keep the width of the buttons, + so the interface does not wabble around when showing / hiding buttons. + #} + style="{{ style_hide }}" + {%- endif %} + name="{{ button_id }}" + data-tab="{{ button_id }}" + data-service="{{ service_id }}" + data-row="{{ row_id }}" + data-bs-toggle="pill" + data-bs-target="#{{ service_id }}-{{ row_id }}-{{ button_id }}" + {%- if button_options.get("show", true) %} + data-show="true" + {%- endif %} + type="button" + {%- for specific_key, specific_values in button_options.get("dependency", {}).items() %} + data-dependency-{{ specific_key }}="true" + {%- for specific_value in specific_values %} + data-dependency-{{ specific_key }}-{{ specific_value }}="true" + {%- endfor %} + {%- endfor %} + role="tab"> + <span>{{ button_options.get("displayName", "Unknown Button") }}</span> + <span id="{{ service_id }}-{{ row_id }}-{{ button_id }}-tab-input-warning" class="d-flex invisible"> + {{ svg.warning_svg | safe }} + <span class="visually-hidden">settings changed</span> + </span> + </button> + {%- endif %} + {%- endfor %} + </div> + {%- endif %} + <div class="tab-content w-100" data-row="{{ row_id }}" data-service="{{ service_id }}" data-sse-progress id="{{ service_id }}-{{ row_id }}-tabContent-div"> + <form id="{{ service_id }}-{{ row_id }}-form"> + {%- for tab_id, tab_options in service_options.get("tabs", {}).items() %} + <div class="tab-pane fade {%- if loop.first or tab_id == "buttonrow" %} show active"{%- else -%}" style="display: none" {%- endif %} id="{{ service_id }}-{{ row_id }}-{{ tab_id }}-contenttab-div" role="tabpanel"> + <div class="row"> + {{ macro_row_content(service_id, service_options, row_id, tab_id) }} + </div> + </div> + {%- endfor %} + </form> + </div> + </div> + </div> + </td> + </tr> + {%- endfor %} + </tbody> + </table> + </div> {#- table responsive #} + {%- endfor %} + </div> {#- container fluid #} + <script> + require(["jquery", "jhapi", "utils"], function ( + $, + JHAPI, + utils + ) { + "use strict"; + + var base_url = window.jhdata.base_url; + var user = window.jhdata.user; + var api = new JHAPI(base_url); + + + + {%- for button_key, button_func in header_button_functions.items() %} + $(`button[id$='-{{ button_key }}-btn-header']`).on("click", function() { + const $this = $(this); + {{ button_func }}($this.attr('data-service'), $this.attr('data-row'), $this.attr('data-element'), {}, user, api, base_url, utils) + }); + {%- endfor %} + {%- if sse_functions %} + {{ sse_functions() }} + {%- endif %} + }); + </script> +{%- endmacro %} diff --git a/templates/macros/table/table_js.jinja b/templates/macros/table/table_js.jinja new file mode 100644 index 0000000..f71eec2 --- /dev/null +++ b/templates/macros/table/table_js.jinja @@ -0,0 +1,1884 @@ +{# + Different sites may use the functions slighty different +#} + +{%- import "macros/table/variables.jinja" as vars with context %} +{%- import "macros/svgs.jinja" as svg -%} + +<script type="text/javascript"> + + + + // table_js_start + + + // Define the regex pattern with named capture groups + const serviceConfig = {{ custom_config.services | tojson }}; + const userModulesConfig = {{ custom_config.userModules | tojson }}; + const systemConfig = {{ custom_config.systems | tojson }}; + const resourcesConfig = {{ custom_config.resources | tojson }}; + const backendServicesConfig = {{ custom_config.backendServices | tojson }}; + + const mappingDict = {} + + {% include 'macros/table/helpers/systems_js.jinja' with context %} + + Object.entries(serviceConfig) + .forEach(([key, value]) => { + const serviceId = value.serviceId ?? key; + if ( !Object.keys(mappingDict).includes(serviceId) ){ + mappingDict[serviceId] = { + "serviceKey": key, + "system": {}, + "option": {} + }; + } + Object.entries(value.options).forEach(([optionKey, optionValue]) => { + mappingDict[serviceId]["option"][optionKey] = optionValue.type ?? optionKey; + }); + allSystems.forEach(system => { + const backendService = systemConfig[system].backendService; + const systemType = backendServicesConfig[backendService]?.type ?? system; + if (!Object.keys(mappingDict[serviceId]["system"]).includes(systemType)) { + mappingDict[serviceId]["system"][system] = systemType; + } + }); + }); + + function getServiceConfig(serviceId) { + const key = mappingDict[serviceId]["serviceKey"]; + return serviceConfig[key]; + } + + function val(obj) { + let ret = ""; + if ( obj.is("input[type='checkbox']") ) { + ret = obj.prop('checked'); + } else if ( obj.is("select") ) { + ret = obj.val(); + if ( !Array.isArray(ret) ){ + ret = [ret]; + } + } else { + ret = obj.val(); + } + return ret; + } + + function getInputElement(serviceId, rowId, elementId) { + return $(`[id^='${serviceId}-${rowId}-'][id$='-${elementId}-input']`); + } + + function getLabelCBElement(serviceId, rowId, elementId) { + return $(`input[id^='${serviceId}-${rowId}-'][id$='-${elementId}-input-cb']`); + } + + function getInputDiv(serviceId, rowId, elementId) { + return $(`div[id^='${serviceId}-${rowId}-'][id$='-${elementId}-input-div']`); + } + + function getLabel(inputElement) { + return $(`label[for='${inputElement.prop("id")}']`); + } + + function getInvalidFeedback(inputDiv) { + return inputDiv.find(".invalid-feedback"); + } + + function getOptionTypes(serviceId, rowId) { + const options = val(getInputElement(serviceId, rowId, "option")); + let ret = []; + options.forEach(option => { + ret.push(mappingDict[serviceId]?.["option"]?.[option] ?? option); + }); + return ret; + } + + + + + {# Fill Input elements -> #} + + function fillSelect(elementId, select, values_, groups = {}, inactive_values = [], inactive_text = "N/A") { + let values = values_; + {%- if pagetype == vars.pagetype_workshop and db_workshops %} + const key = select.attr("name"); + const rowId = select.attr("data-row"); + const workshopValues = {{ db_workshops | tojson }}?.[rowId]?.user_options || {}; + if ( Object.keys(workshopValues).includes(key) ) { + let valueKeys = workshopValues[key]; + if ( !Array.isArray(valueKeys) ){ + valueKeys = [valueKeys]; + } + values = []; + values_.forEach( item => { + if ( valueKeys.includes(item[0]) ){ + values.push(item); + } + }); + groups = {}; + } + {%- endif %} + const labelElement = $(`label[for='${select.attr("id")}']`); + const checkBox = labelElement.find("input[type='checkbox']"); + let preValue = select.val(); + select.html(""); + let valueIndex = 0; + + for (const groupLabel in groups) { + if (groups.hasOwnProperty(groupLabel)) { + const groupSize = groups[groupLabel]; + select.append(`<optgroup label="${groupLabel}">`); + for (let i = 0; i < groupSize; i++) { + if (valueIndex < values.length) { + select.append(`<option value="${values[valueIndex][0]}">${values[valueIndex][1]}</option>`); + valueIndex++; + } + } + select.append(`</optgroup>`); + } + } + + while (valueIndex < values.length) { + select.append(`<option value="${values[valueIndex][0]}">${values[valueIndex][1]}</option>`); + valueIndex++; + } + + // Add a horizontal line if there are inactive options + if (inactive_values.length > 0) { + select.append('<hr>'); + } + + // Add inactive options at the end of the dropdown + inactive_values.forEach(([key, value]) => { + select.append(`<option value="${key}" disabled>${value} (${inactive_text})</option>`); + }); + + if ( preValue && select.find(`option[value="${preValue}"]:not(:disabled)`).length) { + select.val(preValue); + } else { + if ( select.prop("multiple") ) { + select.val(null); + } else { + if (values.length > 0){ + select.val(values[0][0]); + } else { + console.error(`Could not fill object. Check configuration.`); + + {%- if pagetype == vars.pagetype_workshop %} + workshopNotUsable(select); + {%- endif %} + } + } + } + } + + {# <- Fill Input elements #} + {%- if pagetype == vars.pagetype_workshop %} + function workshopNotUsable(element) { + const helpDiv = $('#workshopnotusable'); + if ( helpDiv.children().length === 0 ) { + const serviceId = element.attr("data-service"); + const rowId = element.attr("data-row"); + const elementName = element.attr("data-element"); + const workshop = {{ db_workshops | tojson }}?.[rowId]?.user_options || {}; + const workshopId = workshop.workshopid; + + const workshopSystems = workshop?.system || []; + let workshopProject = workshop?.project || []; + if ( !Array.isArray(workshopProject) ){ + workshopProject = [workshopProject]; + } + let workshopPartition = workshop?.partition || []; + if ( !Array.isArray(workshopPartition) ){ + workshopPartition = [workshopPartition]; + } + + var partitionLinkText = ""; + var partitionLinkText2 = ""; + var projectInviteText = ""; + if ( workshopProject.length === 0 ) { + projectInviteText = ` + <li style="color: #333;">Enter the Project id, that was handed out during the workshop invivation. If in doubt, ask the workshop instructor for the project id.</li> + ` + partitionLinkText = ` + <li style="color: #333;">Visit <a href="https://judoor.fz-juelich.de" target="_blank">JuDoor</a> and sign in with the credentials you've used to log into here.</li> + <li style="color: #333;">Click on the project of this workshop.</li> + <li style="color: #333;">Click on "Request access for resources".</li> + <img src="{{ static_url("images/workshop/partition_01.png") }}" alt="Login Procedure" style="width: 100%; max-width: 400px; margin-top: 10px; border: 1px solid #ddd; border-radius: 5px;"> + ` + } else { + workshopProject.forEach(project => { + projectInviteText += ` + <li style="color: #333;">Enter "${project}" into Project id, add some additional information and clickon "Join project".</li> + ` + }) + if ( workshopProject.length === 1 ) { + partitionLinkText = ` + <li style="color: #333;">Visit <a href="https://judoor.fz-juelich.de/projects/${workshopProject[0]}/request" target="_blank">JuDoor</a> and sign in with the credentials you've used to log into here.</li> + + ` + } else { + partitionLinkText = ` + <li style="color: #333;">Visit <a href="https://judoor.fz-juelich.de/" target="_blank">JuDoor</a> and sign in with the credentials you've used to log into here.</li> + <li style="color: #333;">Click on the projects of this workshop ( ${workshopProject} ). Repeat the "request access for resources" for each project.</li> + <li style="color: #333;">Click on "Request access for resources".</li> + <img src="{{ static_url("images/workshop/partition_01.png") }}" alt="Login Procedure" style="width: 100%; max-width: 400px; margin-top: 10px; border: 1px solid #ddd; border-radius: 5px;"> + ` + } + } + + if ( workshopPartition.length === 0 ) { + partitionLinkText2 = ` + <li style="color: #333;">Select all partitions.</li> + ` + } else { + partitionLinkText2 = ` + <li style="color: #333;">Select these partitions: ${workshopPartition}.</li> + ` + } + var missingSystems = allSystems.filter(key => workshopSystems.includes(key)); + var stepLogin = ""; + var stepSystem = ""; + var stepProject = ""; + var stepPartition = ""; + // User doesn't have a access to a single system in the workshop + // Maybe we can check this in the feature via auth_state entitlements + stepLogin = ` + <details style="margin-bottom: 15px;"> + <summary style="font-weight: bold; margin-left: 10px; font-size: 16px; color: #0056b3; cursor: pointer;"> + - Use the correct AAI during the Login process (click here for more information) + </summary> + <div style="margin-left: 20px; margin-top: 10px;"> + <p style="color: #333;">When using HPC resources, you have to use the JSC Account during the login process.</p> + <ul> + <li style="color: #333;">Click on <a href="https://{{ hostname }}{{ base_url }}logout" target="_blank">Logout</a></li> + <li style="color: #333;">Click on <a href="https://{{ hostname }}{{ base_url }}login?next=%2Fhub%2Fworkshops%2F${workshopId}" target="_blank">Login</a> (make sure to come back to this page ("/workshops/${workshopId}") after logging in).</li> + <ul> + <li style="color: #333;">Click on "Sign In"</li> + <li style="color: #333;">Click on "Show other sign in options"</li> + <img src="{{ static_url("images/workshop/login_01.png") }}" alt="Login Procedure" style="width: 100%; max-width: 400px; margin-top: 10px; border: 1px solid #ddd; border-radius: 5px;"> + <li style="color: #333;">Click on "Sign in with JSC Account"</li> + <li style="color: #333;">Enter your JSC Account credentials and click on Login. Don't have an account yet? Click on register and follow the process. For more information look into the <a href="https://www.fz-juelich.de/en/ias/jsc/services/user-support/how-to-get-access-to-systems/judoor" target="_blank"> JuDoor documentation</a>.</li> + </ul> + </ul> + </div> + </details> + ` + stepProject = ` + <details style="margin-bottom: 15px;"> + <summary style="font-weight: bold; margin-left: 10px; font-size: 16px; color: #0056b3; cursor: pointer;"> + - Join Projects + </summary> + <div style="margin-left: 20px; margin-top: 10px;"> + <p style="color: #333;">When using HPC resources, you have to join a project, before you're allowed to use resources.</p> + <ul> + <li style="color: #333;">Visit <a href="https://judoor.fz-juelich.de" target="_blank">JuDoor</a> and sign in with the credentials you've used to log into here.</li> + <li style="color: #333;">Click on "Join a project"</li> + <img src="{{ static_url("images/workshop/project_01.png") }}" alt="Join Project" style="width: 100%; max-width: 400px; margin-top: 10px; border: 1px solid #ddd; border-radius: 5px;"> + ${projectInviteText} + <li style="color: #333;">You will receive an email. Follow the steps in this mail.</li> + <li style="color: #333;">For more information about joining projects look into the <a href="https://www.fz-juelich.de/en/ias/jsc/services/user-support/how-to-get-access-to-systems/judoor" target="_blank">JuDoor documentation</a></li> + </ul> + </div> + </details> + ` + + stepSystem = ` + <details style="margin-bottom: 15px; "> + <summary style="font-weight: bold; margin-left: 10px; font-size: 16px; color: #0056b3; cursor: pointer;"> + - Accept System Usage Policy + </summary> + <div style="margin-left: 20px; margin-top: 10px;"> + <p style="color: #333;">When using HPC resources, you have to accept the usage policy of a system, before you're allowed to use resources.</p> + <ul> + <li style="color: #333;">Visit <a href="https://judoor.fz-juelich.de" target="_blank">JuDoor</a> and sign in with the credentials you've used to log into here.</li> + <li style="color: #333;">You have to "sign the usage agreement" for the systems you want to use.</li> + <li style="color: #333;">It may take up to 30 minutes for your account to be fully updated and ready on the system after completing the steps.</li> + <li style="color: #333;">For more information look into the <a href="https://www.fz-juelich.de/en/ias/jsc/services/user-support/how-to-get-access-to-systems/judoor" target="_blank">JuDoor documentation</a></li> + </ul> + </div> + </details> + ` + stepPartition = ` + <details style="margin-bottom: 15px; "> + <summary style="font-weight: bold; margin-left: 10px; font-size: 16px; color: #0056b3; cursor: pointer;"> + - Request access for resources + </summary> + <div style="margin-left: 20px; margin-top: 10px;"> + <p style="color: #333;">When using HPC resources, you have to accept the usage policy of a system, before you're allowed to use resources.</p> + <ul> + <li style="color: #333;">Visit <a href="https://judoor.fz-juelich.de" target="_blank">JuDoor</a> and sign in with the credentials you've used to log into here.</li> + ${partitionLinkText} + ${partitionLinkText2} + <li style="color: #333;">Click on "Inform PIs and PAs about your request.</li> + <li style="color: #333;">The PI or PA has to accept your request.</li> + <li style="color: #333;">It may take up to 30 minutes for your account to be fully updated and ready on the system after completing the steps.</li> + <li style="color: #333;">For more information look into the <a href="https://www.fz-juelich.de/en/ias/jsc/services/user-support/how-to-get-access-to-systems/judoor" target="_blank">JuDoor documentation</a></li> + </ul> + </div> + </details> + ` + + + var genericHtml = ` + <div style="width: 80%; margin: auto; margin-top: 20px; margin-bottom: 20px; padding: 20px; border: 1px solid #ccc; border-radius: 10px; background-color: #f9f9f9;"> + <h2 style="text-align: center; color: #333;">Workshop "${workshop.workshopid}" not available for you</h2> + <p style="text-align: center; color: #666;">Your account is not yet ready to access this workshop. Please complete the steps below to proceed.</p> + + <div style="margin-top: 20px;"> + ${stepLogin} + ${stepProject} + ${stepSystem} + ${stepPartition} + </div> + <p style="text-align: center; color: darkorange;">It may take up to 60 minutes for the systems to fully process account updates. Any start attempts during this time might fail.</p> + </div> + ` + helpDiv.append(genericHtml); + $(`#global-content-div`).hide(); + } + } + {%- endif %} + + {# Button Helper functions --> #} + + function dictHasKey(obj, key) { + // Check if the key exists at the current level + if (Object.hasOwn(obj, key)) { + return true; + } + + // Traverse through nested objects or arrays + for (const k in obj) { + if (typeof obj[k] === "object" && obj[k] !== null) { + if (dictHasKey(obj[k], key)) { + return true; + } + } + } + + // If the key is not found + return false; + } + + function validateInput(inputElement) { + const labelElement = $(`label[for='${inputElement.attr("id")}']`); + const checkBox = labelElement.find("input[type='checkbox']"); + if ( checkBox.length > 0 && !checkBox.prop("checked") ) { + return true; + } else if( !inputElement[0].checkValidity() ) { + inputElement.addClass('is-invalid'); + inputElement.siblings('.invalid-feedback').show(); + return false; + } else { + inputElement.removeClass('is-invalid'); + inputElement.siblings('.invalid-feedback').hide(); + return true; + } + } + + function validateSelect(selectElement) { + const labelElement = $(`label[for='${selectElement.attr("id")}']`); + const checkBox = labelElement.find("input[type='checkbox']"); + if ( checkBox.length > 0 && !checkBox.prop("checked") ) { + return true; + } else if (selectElement.val() === "" + || selectElement.val() === undefined) + { + selectElement.addClass('is-invalid'); + selectElement.siblings('.invalid-feedback').show(); + return false; + } else { + selectElement.removeClass('is-invalid'); + selectElement.siblings('.invalid-feedback').hide(); + return true; + } + } + + function validateForm(serviceId, rowId) { + const form = $(`form[id='${serviceId}-${rowId}-form']`); + let ret = true; + form.find(`[id$='-input']:not(:disabled):not([data-collect="false"])`).each(function () { + let $this = $(this); + const valid = $this.is("input") ? validateInput($this) : $this.is("select") ? validateSelect($this) : false; + if ( !valid ) { + console.error("The following element is invalid: "); + console.log($this); + // If the user is looking at a different tab, we should highlight the button in the navbar + const buttonDiv = $(`#${serviceId}-${rowId}-tab-button-div`); + const activeTab = buttonDiv.find('.active').attr('name'); + const inputTab = $this.attr('data-tab'); + if ( inputTab !== activeTab ){ + buttonDiv.find(`button[data-tab='${inputTab}']`).click(); + } + ret = false; + } + }); + if ( ret ) { + form.find(`[id$='-input'].is-invalid`).removeClass('is-invalid'); + form.find(`[id$='-input'].invalid-feedback`).hide(); + } + return ret; + } + + function homeFillExistingRow(serviceId, rowId, user_options, fillingOrder) { + const excludes = `:not(${fillingOrder.map(value => `[data-element='${value}']`).join(',')})` + let available = true; + let availableDescription = ""; + // It's important to fill the user options in the right order + fillingOrder.forEach(key => { + if ( available ) { + const inputElement = $(`[id^='${serviceId}-${rowId}-'][id$='-${key}-input']`); + const dataGroup = inputElement.attr("data-group"); + const dataType = inputElement.attr("data-type"); + let newValue = ""; + if ( ["none", "default"].includes(dataGroup) ) { + newValue = user_options?.[key] ?? ""; + } else { + newValue = user_options?.[dataGroup]?.[key] ?? ""; + } + if ( newValue ) { + if ( dataType == "select" ){ + if (inputElement.find(`option[value="${newValue}"]`).length > 0) { + inputElement.val(newValue); + inputElement.trigger("change"); + } else { + available = false; + availableDescription = `${key} ${newValue} is not available for your account. Please try re-logging in.`; + console.log(`${key} ${newValue} currently not available`); + } + } else if (dataType == "number" ) { + const min = inputElement.attr("min"); + const max = inputElement.attr("max"); + if (newValue && newValue >= min && newValue <= max) { + inputElement.val(newValue); + inputElement.trigger("change"); + } else { + available = false; + availableDescription = `${key} ${newValue} is not in allowed range [${min}, ${max}].`; + console.log(`${key} ${newValue} currently not available`); + } + } else { + inputElement.val(newValue); + inputElement.trigger("change"); + } + } else if (inputElement.is("input[type='checkbox']") ) { + inputElement.prop("checked", false); + inputElement.trigger("change"); + } + } + }); + + const unorderedElements = $(`[id^='${serviceId}-${rowId}-'][id$='-input']${excludes}`); + unorderedElements.each(function () { + if ( available ) { + const inputElement = $(this); + const key = inputElement.attr("data-element"); + const dataGroup = inputElement.attr("data-group"); + + let newValue = ""; + if ( ["none", "default"].includes(dataGroup) ) { + newValue = user_options?.[key] ?? ""; + } else { + newValue = user_options?.[dataGroup]?.[key] ?? ""; + } + if (inputElement.is("input[type='checkbox']") ) { + if ( newValue ) { + inputElement.prop("checked", true); + inputElement.trigger("change"); + } else { + inputElement.prop("checked", false); + inputElement.trigger("change"); + } + } else if ( newValue ) { + inputElement.val(newValue); + inputElement.trigger("change"); + } + } + }); + if ( !available ) { + console.log(`tr.collapsible-tr[data-server-id='${serviceId}-${rowId}']`); + $(`tr.collapsible-tr[data-server-id='${serviceId}-${rowId}']`).remove(); + console.log("Header NA"); + updateHeaderButtons(serviceId, rowId, "na"); + let description = ` + <div id="${serviceId}-${rowId}-config-td-nadescription-div" class="col text-lg-center col-12 col-lg-12"> + <span id="${serviceId}-${rowId}-config-td-nadescription">${availableDescription}</span> + </div> + `; + const headerDescription = $(`#${serviceId}-${rowId}-config-td-div`); + headerDescription.addClass("justify-content-center"); + $(`#${serviceId}-${rowId}-config-td-div`).html(description); + } + } + + function workshopManagerFillExistingRow(serviceId, rowId, workshopDict) { + const form = $(`form[id='${serviceId}-${rowId}-form']`); + + // We must run through the data groups in the correct order, to allow correct trigger behavior + const selectors = [ + "[id$='-input'][data-group='none']", + "[id$='-input'][data-group='default']", + "[id$='-input']:not([data-group='none']):not([data-group='default']):not([data-group='defaultvalues'])", + "[id$='-input'][data-group='defaultvalues']", + ] + selectors.forEach( selector => { + form.find(`${selector}`).each(function () { + const $this = $(this); + const id = $this.prop('id'); + let key = $this.attr('data-element'); + key = $this.attr('data-parent') || key; + const dataGroup = $this.attr('data-group'); + if ( dataGroup === "defaultvalues" ) { + $this.trigger(`trigger_${key}`); + } + let keys = ""; + let newValue = ""; + if ( ["none", "default"].includes(dataGroup) ) { + keys = Object.keys(workshopDict?.["user_options"]); + if ( keys.includes(key) ) { + newValue = workshopDict["user_options"][key]; + } + } + else { + keys = Object.keys(workshopDict?.["user_options"]?.[dataGroup] || {}); + if ( keys.includes(key) ) { + newValue = workshopDict["user_options"][dataGroup][key]; + } + } + + const parentInputDiv = $(`#${id}-div`); + const labelInput = $(`#${id}-cb`); + if ( newValue ) { + $this.attr("data-collect", true); + if ( $this.is("input[type='checkbox']") ) { + $this.prop('checked', !!newValue); + } else { + $this.val(newValue); + } + // enable, since it's part of the stored user_options + const alwaysDisabled = $this.attr('data-alwaysdisabled') || false; + if ( !alwaysDisabled ) { + $this.prop("disabled", false); + } + if ( labelInput && labelInput.length > 0 ) { + labelInput.prop("disable", false); + labelInput.prop("checked", "checked"); + } + parentInputDiv.show(); + } else { + // Set to default values + if ( $this.is("input[type='checkbox']") ) { + const checked = $this.attr("data-default"); + $this.prop('checked', !!$this.attr('data-checked')); + } + if ( $this.attr('data-enabled') != undefined ) { + if ( $this.attr('data-enabled') === "true" ) { + $this.prop('disabled', false); + } else { + $this.prop('disabled', true); + } + } + if ( labelInput && labelInput.length > 0 ) { + const labelInputEnable = labelInput.attr('data-enabled') === "true"; + const labelInputCheck = labelInput.attr('data-checked') === "true"; + labelInput.prop("disable", !labelInputEnable); + labelInput.prop("checked", labelInputCheck); + } + } + $this.trigger("change"); + }); + }); + + if ( !isWorkshopInstructor() ) { + console.log("No Instructor"); + // double check to hide / disable the instructor elements. + form.find(`input[data-instructor]`).prop("disabled", true); + form.find(`div[data-instructor="show"][id$="-input-div"]`).hide(); + } + } + + function collectWorkshopOptions(serviceId, rowId) { + const form = $(`form[id='${serviceId}-${rowId}-form']`); + const options = {}; + form.find(`input[data-group="none"][id$='-input'], input[data-group="none"][id$='-cb-input']`).each(function () { + const $this = $(this); + let value = ""; + if ( !$this.prop("disabled") || $this.attr('data-group') === "none" ) { + if ( $this.is("input[type='checkbox']") ){ + value = $this.prop('checked'); + } else { + if ( Array.isArray(value) && values.length == 1 ) { + value = value[0]; + } + value = $this.val(); + } + options[$this.attr('name')] = value; + } + }); + return options; + } + + function collectSelectedOptions(serviceId, rowId, allCheckboxes=false) { + const form = $(`form[id='${serviceId}-${rowId}-form']`); + let ret = {}; + // collect all inputs in default group + form.find(`[id^='${serviceId}-${rowId}-'][id$='-input'][data-collect="true"]:not([data-group="none"])`).each(function () { + let $this = $(this).first(); + let dataGroupValue = $this.attr('data-group'); + let value = ""; + let addValue = true; + let id = $this.prop("id"); + let labelInput = $(`#${id}-cb`); + let parent = $this.attr("data-parent"); + let name = parent || $this.attr("name"); + if ( parent ) { + let parentElement = $(`[id^='${serviceId}-${rowId}-'][id$='-${parent}-input']`); + addValue = parentElement.attr("data-collect") === "true"; + } + if ( addValue ) { + if ( labelInput.length > 0 && !labelInput.prop('checked') ) { + addValue = false; + } else { + if ( $this.is("input[type='checkbox']") ){ + value = $this.prop('checked'); + if ( !value && !allCheckboxes ) { + addValue = false; + } + } else { + if ( Array.isArray(value) && values.length == 1 ) { + value = value[0]; + } + value = $this.val(); + } + } + } + + if ( addValue ) { + if ( dataGroupValue === "default" ) { + ret[$this.attr('name')] = value; + } else if ( dataGroupValue != "none" ) { + if (!Object.keys(ret).includes(dataGroupValue)) { + ret[dataGroupValue] = {} + } + ret[dataGroupValue][name] = value; + } + } + }); + let profile = ""; + if ( Object.keys(ret).includes("option") ) profile = ret.option; + else profile = serviceId; + ret["profile"] = profile; + ret["service"] = serviceId; + + if ( !Object.keys(ret).includes("name") || !ret?.name ) { + ret["name"] = `Unnamed ${serviceId}`; + } + + console.log("Collected Options in frontend:"); + console.log(ret); + return ret; + } + + {# <-- Button Helper functions #} + + {# Workshop Manager --> #} + {# WorkshopManager.helper --> #} + function isWorkshopInstructor() { + {%- if is_instructor %} + return true; + {%- else %} + return false; + {%- endif %} + } + + function isFirstRow(rowId) { + return rowId === "{{ vars.first_row_id }}"; + } + {# <-- WorkshopManager.helper #} + + {# WorkshopManager.none.workshopid --> #} + function workshopManagerWorkshopId(trigger, serviceId, rowId, tabId, elementId, elementOptions) { + const $this = $(`input[id^="${serviceId}-${rowId}-"][id$="-${elementId}-input"]`); + if ( !isFirstRow(rowId) ){ + $this.val(rowId); + } else { + if ( isWorkshopInstructor() ) { + $this.prop("disabled", false); + $this.prop("placeholder", elementOptions?.["input"]?.["options"]?.["placeholderInstructor"] || "W"); + } + } + } + {# <-- WorkshopManager.none.workshopid #} + + {# WorkshopManager.default.option --> #} + function workshopManagerFillOptions(trigger, serviceId, rowId, tabId, elementId, elementOptions) { + {# I think that's not needed + let valueKeys = []; + if (getServiceConfig(serviceId)?.options) { + valueKeys = Object.keys(getServiceConfig(serviceId).options); + } + let values = Object.entries(getServiceConfig(serviceId).options) + .filter(([key, value]) => valueKeys.includes(key)) + .map(([key, value]) => [key, value.name]); + #} + let values = getServiceConfig(serviceId).options; + + {%- if pagetype == vars.pagetype_workshop %} + const db_workshops = {{ db_workshops | tojson }}; + {# Only allow options, which are available for the selected systems #} + let allowedSystems = db_workshops[rowId]?.user_options?.system ?? false; + if ( allowedSystems ) { + if ( !Array.isArray(allowedSystems) ){ + allowedSystems = [allowedSystems]; + } + let allowedOptions = {}; + for ( const [key, valueInformation] of Object.entries(values) ) { + allowedSystems.forEach(system => { + const systemsPerOption = getServiceConfig(serviceId)?.options?.[key]?.allowedLists?.systems ?? []; + if ( systemsPerOption.includes(system) && !allowedOptions.hasOwnProperty(key) ) { + allowedOptions[key] = valueInformation; + } + }); + } + values = allowedOptions; + } + {%- endif %} + + const optionInput = $(`#${serviceId}-${rowId}-${tabId}-option-input`); + + fillSelect("init", optionInput, Object.entries(values).map(([key, value]) => [key, value.name]), {}, [], "N/A"); + } + {# <-- WorkshopManager.default.option #} + + {# WorkshopManager.default.system --> #} + function workshopManagerUpdateSystem(trigger, serviceId, rowId, tabId, elementId, elementOptions) { + const optionInput = $(`#${serviceId}-${rowId}-${tabId}-option-input`); + const systemInput = $(`#${serviceId}-${rowId}-${tabId}-system-input`); + + const options = val(optionInput); + + if ( optionInput.prop("disabled") ){ + // If option is disabled -> make all systems available + fillSelect(elementId, systemInput, allSystems.map(item => [item, item])); + } else { + // Update available systems + let inactiveText = "N/A" + let displayNames = []; + options.forEach(option => { + if (getServiceConfig(serviceId)?.options) { + displayNames.push(getServiceConfig(serviceId).options[option].name); + } + }) + let displayName = displayNames.join(", "); + inactiveText = `N/A for ${displayName}` + + fillSelect(elementId, systemInput, getAvailableSystemOptions(serviceId, options), {}, getMissingSystemOptions(serviceId, rowId, options), inactiveText); + } + } + + {# <-- WorkshopManager.default.system #} + + {# WorkshopManager.default.unicore.project --> #} + function workshopManagerUpdateProject(trigger, serviceId, rowId, tabId, elementId, elementOptions) { + const values = getProjectOptions(serviceId, rowId); + const inputElement = getInputElement(serviceId, rowId, "project"); + fillSelect(elementId, inputElement, values); + // inputElement.trigger("change"); + } + {# WorkshopManager.default.unicore.project --> #} + + {# WorkshopManager.default.unicore.partition --> #} + function workshopManagerUpdatePartition(trigger, serviceId, rowId, tabId, elementId, elementOptions) { + const [partitions, interactivePartitionsLength] = getPartitionAndInteractivePartition(serviceId, rowId); + const inputElement = getInputElement(serviceId, rowId, "partition"); + fillSelect(elementId, inputElement, partitions, {"Login Nodes": interactivePartitionsLength, "Compute Nodes": -1}); + // inputElement.trigger("change"); + } + {# <-- WorkshopManager.default.unicore.partition #} + + {# WorkshopManager.default.unicore.reservation --> #} + function toggleCollectCB(trigger, serviceId, rowId, tabId, elementId, elementOptions) { + const labelChecked = val(getLabelCBElement(serviceId, rowId, elementId)); + const inputDiv = getInputDiv(serviceId, rowId, elementId); + const inputElement = getInputElement(serviceId, rowId, elementId); + if ( !inputElement.is(":visible") ) { + inputElement.attr("data-collect", false); + } else { + if ( labelChecked ) { + inputElement.attr("data-collect", true); + } else { + inputElement.attr("data-collect", false); + } + } + } + function workshopManagerUpdateReservation(trigger, serviceId, rowId, tabId, elementId, elementOptions) { + + const systemInput = getInputElement(serviceId, rowId, "system"); + const elementDiv = getInputDiv(serviceId, rowId, elementId); + const reservationInput = getInputElement(serviceId, rowId, elementId); + + let reservations = getReservationOptions(serviceId, rowId); + + {%- if pagetype == vars.pagetype_workshop and db_workshops %} + const db_workshops = {{ db_workshops | tojson }}; + {# Only allow options, which are available for the selected systems #} + let allowedReservations = db_workshops[rowId]?.user_options?.reservation ?? false; + if ( allowedReservations ) { + if ( !Array.isArray(allowedReservations) ){ + allowedReservations = [allowedReservations]; + } + let forcedreservations = []; + const currentSystem = val(getInputElement(serviceId, rowId, "system")); + if ( currentSystem.length === 1 && currentSystem[0] && unicoreReservations.hasOwnProperty(currentSystem[0]) ) { + allowedReservations.forEach(singleWorkshopReservation => { + let singleWorkshopToAdd = unicoreReservations[currentSystem[0]].filter(item => item.ReservationName == singleWorkshopReservation); + if ( singleWorkshopToAdd.length === 1 ) { + forcedreservations.push(singleWorkshopToAdd[0]); + } + }); + } + reservations = forcedreservations; + reservationInput.attr("data-collect", true); + } + {%- endif %} + if ( !systemInput.prop("disabled") && reservations.length > 0 ) { + + activeReservationNames = reservations + .filter(item => item.State === "ACTIVE") + .map(item => [item.ReservationName, item.ReservationName]); + inactiveReservationNames = reservations + .filter(item => item.State === "INACTIVE") + .map(item => [item.ReservationName, item.ReservationName]); + const allReservationsSorted = [ + ["None", "None"], + ...activeReservationNames, + ...inactiveReservationNames + ]; + + let groups = { + "No reservation": 1 + } + if ( activeReservationNames.length > 0 ) { + groups["Active"] = activeReservationNames.length; + } + if ( inactiveReservationNames.length > 0 ) { + groups["Inactive"] = inactiveReservationNames.length; + } + fillSelect(elementId, reservationInput, allReservationsSorted, groups); + const labelCB = getLabelCBElement(serviceId, rowId, "reservation"); + if ( labelCB.length ) { + if ( labelCB.prop("checked") ) { + reservationInput.attr("data-collect", true); + } else { + reservationInput.attr("data-collect", false); + } + } else { + reservationInput.attr("data-collect", true); + } + if ( val(reservationInput)[0] == "None" ) { + reservationInput.attr("data-collect", false); + } + elementDiv.show(); + } else { + reservationInput.attr("data-collect", false); + elementDiv.hide(); + $(`div[id^='${serviceId}-${rowId}-'][id$='-reservationinfo-input-div']`).hide(); + } + } + + function defaultValue(trigger, serviceId, rowId, tabId, elementId, elementOptions) { + const dependentElement = elementOptions?.input?.options?.parent || ""; + if ( !dependentElement ) { + console.log(`Custom Config not configured correctly for ${elementId}. Add "parent" to elementOptions.`); + } + + const selectedParentValues = $(`select[id^='${serviceId}-${rowId}-'][id$='-${dependentElement}-input']`).val(); + const parentLabelCB = $(`input[id^='${serviceId}-${rowId}-'][id$='-${dependentElement}-input-cb']`); + const inputParentElement = $(`select[id^='${serviceId}-${rowId}-'][id$='-${dependentElement}-input']`); + + const inputElement = $(`select[id^='${serviceId}-${rowId}-'][id$='-${elementId}-input']`); + const labelCB = $(`input[id^='${serviceId}-${rowId}-'][id$='-${elementId}-input-cb']`); + const inputDiv = $(`div[id^='${serviceId}-${rowId}-'][id$='-${elementId}-input-div']`); + + if ( parentLabelCB.prop("checked") && inputParentElement.attr("data-collect") && selectedParentValues.length > 1 ) { + fillSelect(elementId, inputElement, selectedParentValues.map(item => [item, item])); + inputDiv.show(); + if ( labelCB.prop("checked") && inputParentElement.attr("data-collect") === "true") { + inputElement.attr("data-collect", true); + } else { + inputElement.attr("data-collect", false); + } + } else { + inputDiv.hide(); + inputElement.attr("data-collect", false); + } + } + + function updateReservationInfo(trigger, serviceId, rowId, tabId, elementId, elementOptions) { + const reservation_values = val(getInputElement(serviceId, rowId, "reservation")); + let reservation = "None"; + if ( reservation_values.length > 0 ){ + reservation = reservation_values[0]; + } + const reservationInfoDiv = $(`[id^='${serviceId}-${rowId}-'][id$='-reservationinfo-input-div']`); + if ( !reservation || reservation === "None" ) { + reservationInfoDiv.hide(); + } else { + const currentSystem = val(getInputElement(serviceId, rowId, "system")); + if ( currentSystem.length === 1 && currentSystem[0] && unicoreReservations.hasOwnProperty(currentSystem[0])) { + const reservations = unicoreReservations[currentSystem[0]].filter(item => item.ReservationName == reservation); + for (const reservationInfo of reservations) { + if (reservationInfo.ReservationName == reservation) { + reservationInfoDiv.find(`span[id$="-start"]`).html(`${reservationInfo.StartTime} (Europe/Berlin)`); + reservationInfoDiv.find(`span[id$="-end"]`).html(`${reservationInfo.EndTime} (Europe/Berlin)`); + reservationInfoDiv.find(`span[id$="-state"]`).html(reservationInfo.State); + reservationInfoDiv.find(`pre[id$="-details"]`).html(JSON.stringify(reservationInfo, null, 2)); + } + } + reservationInfoDiv.show(); + } else { + reservationInfoDiv.hide(); + } + } + } + {# <-- WorkshopManager.default.unicore.reservation #} + + + {# WorkshopManager.default.unicore.nodesRuntimeGPUXservers --> #} + function workshopManagerUpdateResourcesElementTrigger(trigger, serviceId, rowId, tabId, elementId, elementOptions) { + const systems = getUnicoreValues(serviceId, rowId, "system"); + const partitions = getUnicoreValues(serviceId, rowId, "partition"); + let _partitions = []; + + const labelOptions = elementOptions.label ?? {}; + + const inputDiv = getInputDiv(serviceId, rowId, elementId); + const inputElement = getInputElement(serviceId, rowId, elementId); + const labelElement = getLabel(inputElement); + const labelElementCBValue = val(getLabelCBElement(serviceId, rowId, elementId)); + const invalidFeedback = getInvalidFeedback(inputDiv); + + let minmaxavail = false; + let min = -1; + let max = -1; + let label = (labelOptions.value === undefined || labelOptions.value === null) ? "No Label" : labelOptions.value; + let show = false; + let defaultValue = 1; + let collectInformation = true; + + if ( collectInformation ){ + systems.forEach(system => { + if (partitions.length === 1 && partitions[0] === "_all_") { + _partitions = Object.keys(resourcesConfig[system] ?? {}); + } else { + _partitions = partitions; + } + _partitions.forEach(partition => { + const elementOptions = resourcesConfig[system]?.[partition]?.[elementId] ?? {}; + if (Object.keys(elementOptions).length !== 0 ) { + show = true; + const minmax = elementOptions.minmax || false; + defaultValue = (elementOptions["default"] === undefined || elementOptions["default"] === null) ? defaultValue : elementOptions["default"]; + if ( minmax ) { + if ( !minmaxavail ) { + minmaxavail = true; + min = minmax[0]; + max = minmax[1]; + } else { + if ( minmax[0] < min ){ + min = minmax[0]; + } + if ( minmax[1] > max ){ + max = minmax[1]; + } + } + } + } + }); + }); + } + if ( show ) { + if ( !collectInformation ) { + label = `${label} [${defaultValue}]`; + invalidFeedback.html(`Value ${defaultValue} was chosen by workshop instructor.`) + inputElement.attr("min", min); + inputElement.attr("max", max); + } else if ( minmaxavail ){ + label = `${label} [${min}, ${max}]`; + invalidFeedback.html(`Please choose a number between ${min} and ${max}.`); + inputElement.attr("min", min); + inputElement.attr("max", max); + } else { + invalidFeedback.html("Please choose a valid number."); + inputElement.removeAttr("min"); + inputElement.removeAttr("max"); + } + if ( inputElement.attr("data-alwaysdisabled") != "true" ) { + inputElement.attr("value", defaultValue); + } + + labelElement.contents().filter(function () { + return this.nodeType === Node.TEXT_NODE; + }).first().replaceWith(label); + + // Checkbox logic + const checkBoxElement = labelElement.find("input[type='checkbox']"); + if ( checkBoxElement.length !== 0 ) { + const checkBoxDefault = labelOptions?.options?.default ?? false; + checkBoxElement.prop("checked", checkBoxDefault); + inputElement.prop("disabled", !checkBoxDefault); + } else { + if ( inputElement.attr("data-alwaysdisabled") != "true" ) { + inputElement.prop("disabled", false); + } + } + inputDiv.show(); + if ( labelElementCBValue !== undefined ) { + inputElement.attr("data-collect", labelElementCBValue); + } else { + inputElement.attr("data-collect", true); + } + {%- if pagetype == vars.pagetype_workshop %} + + const workshops = {{ db_workshops | tojson }} || {}; + const workshopValues = workshops?.[rowId]?.user_options; + if ( Object.keys(workshopValues).includes(elementId) ){ + console.log(`Yeah - ${elementId} is defined`); + } + {%- endif %} + } else { + inputDiv.hide(); + inputElement.attr("data-collect", false); + } + } + {# <-- WorkshopManager.default.unicore.nodesRuntimeGPUXservers #} + + + {# WorkshopManager.default.kube.flavor --> #} + function workshopManagerUpdateFlavor(trigger, serviceId, rowId, tabId, elementId, elementOptions) { + const systems = val(getInputElement(serviceId, rowId, "system")); + const systemTypes = getSystemTypes(serviceId, rowId); + if ( systemTypes.includes("kube") ){ + const selectInput = getInputElement(serviceId, rowId, "flavor"); + fillSelect(elementId, selectInput, getAvailableKubeFlavorsS(systems), {}, getUnavailableKubeFlavorsS(systems), "maximum reached"); + // selectInput.trigger("change"); + } + } + {# <-- WorkshopManager.default.kube.flavor #} + + + {# Workshop.labconfig.flavorinfo --> #} + function setFlavorInfo(serviceId, rowId, system, flavors={}) { + const inputDiv = $(`div[id^='${serviceId}-${rowId}-'][id$='-flavorinfo-input-div']`); + inputDiv.empty(); + if ( system ) { + let allFlavors = flavors; + if ( allFlavors != undefined ) { + allFlavors = kubeOutpostFlavors[system]; + } + if ( allFlavors ){ + for (const [_, description] of Object.entries(allFlavors) + .filter(([key, value]) => value.max != 0) + .sort(([, a], [, b]) => { + const weightA = a["weight"] || 99; + const weightB = b["weight"] || 99; + return weightA > weightB ? 1 : -1; + })) { + var current = description.current || 0; + var maxAllowed = description.max; + // Flavor not valid, so skip + if (maxAllowed == 0 || current < 0 || maxAllowed == null || current == null) continue; + + var bgColor = "bg-primary"; + // Infinite allowed + if (maxAllowed == -1) { + var progressTooltip = `${current} used`; + var maxAllowedLabel = '∞'; + if (current == 0) { + var currentWidth = 0; + var maxAllowedWidth = 100; + } + else { + var currentWidth = 20; + var maxAllowedWidth = 80; + } + } + else { + var progressTooltip = `${current} out of ${maxAllowed} used`; + var maxAllowedLabel = maxAllowed - current; + var currentWidth = current / maxAllowed * 100; + var maxAllowedWidth = maxAllowedLabel / maxAllowed * 100; + + if (maxAllowedLabel < 0) { + maxAllowedLabel = 0; + maxAllowedWidth = 0; + bgColor = "bg-danger"; + } + } + + var diagramHtml = ` + <div class="row align-items-center g-0 mt-4"> + <div class="col-4"> + <span>${description.display_name}</span> + <a class="lh-1 ms-3" style="padding-top: 1px;" + data-bs-toggle="tooltip" data-bs-placement="right" title="${description.description}"> + {{ svg.info_svg | safe }} + </a> + </div> + <div class="progress col ms-2 fw-bold" style="height: 20px;" + data-bs-toggle="tooltip" data-bs-placement="top" title="${progressTooltip}"> + <div class="progress-bar ${bgColor}" role="progressbar" style="width: ${currentWidth}%">${current}</div> + <div class="progress-bar bg-success" role="progressbar" style="width: ${maxAllowedWidth}%">${maxAllowedLabel}</div> + </div> + </div> + ` + inputDiv.append(diagramHtml); + } + } + } + + // The lab has a flavor configured or is a new lab, but we could not get any flavor information + {# + if (((window.userOptions[id] || {}).flavor || id == "new-jupyterlab") && $.isEmptyObject(systemFlavors)) { + var noFlavorsHtml = ` + <div class="row g-0 mt-3"> + <div class="col-4"></div> + <div class="col ms-2 fw-bold text-danger">No flavors could be fetched. Try logging out and back in to fix the issue.</div> + </div> + `; + $(`#${serviceId}-${rowId}-${tabId}-systemtype-kube-flavorinfo-info-div`).append(noFlavorsHtml); + } + #} + } + + function updateFlavorInfo(trigger, serviceId, rowId, tabId, elementId, elementOptions) { + const systems = val(getInputElement(serviceId, rowId, "system")); + const systemTypes = getSystemTypes(serviceId, rowId); + if ( systemTypes.includes("kube") && systems.length == 1 ){ + setFlavorInfo(serviceId, rowId, systems[0]); + // $(`[id^='${serviceId}-${rowId}-'][id$='-flavorinfo-info-div']`).show(); + } + } + {# <-- Workshop.labconfig.flavorinfo #} + + {# WorkshopManager.default.lmod.modules --> #} + function workshopManagerUpdateModuleWorkshop(trigger, serviceId, rowId, tabId, elementId, elementOptions) { + const optiontypes = getOptionTypes(serviceId, rowId); + if ( optiontypes.includes("lmod") ) { + const values = getModuleValues(serviceId, rowId, elementId, elementOptions.input.options.setName); + const elementSelect = $(`select[id^='${serviceId}-${rowId}-'][id$='-${elementId}-input']`); + fillSelect(elementId, elementSelect, values); + const activeValues = values.filter(item => item[2]).map(item => item[0]); + // elementSelect.val(activeValues).trigger("change"); + } + } + {# <-- WorkshopManager.default.lmod.modules #} + + {# WorkshopManager.default.repo2docker.repopathtype --> #} + function R2DgetRepoPathType(serviceId, rowId, tabId, elementId) { + return {{ custom_config.get("binderRepos", {}).get("notebookTypes", ["File", "URL"]) | tojson }}.map(item => [item, item]); + } + + function setR2DPathType(trigger, serviceId, rowId, tabId, elementId, elementOptions) { + const inputElement = getInputElement(serviceId, rowId, "repopathtype"); + fillSelect(elementId, inputElement, R2DgetRepoPathType()); + } + + function workshopManagerRepoPathType(trigger, serviceId, rowId, tabId, elementId, elementOptions) { + const repoPathChecked = val(getLabelCBElement(serviceId, rowId, "repopath")); + + const repoPathTypeDiv = getInputDiv(serviceId, rowId, "repopathtype"); + const repoPathTypeInput = getInputElement(serviceId, rowId, "repopathtype"); + const repoPathTypeLabel = getLabelCBElement(serviceId, rowId, "repopathtype"); + if ( !repoPathChecked ) { + repoPathTypeInput.prop("disabled", true); + repoPathTypeLabel.prop("checked", false); + repoPathTypeLabel.prop("disabled", true); + repoPathTypeDiv.hide(); + } else { + repoPathTypeDiv.show(); + repoPathTypeLabel.prop("disabled", false); + const repoPathTypeChecked = repoPathTypeLabel.prop("checked"); + repoPathTypeInput.prop("disabled", !repoPathTypeChecked); + } + } + {# <-- WorkshopManager.default.repo2docker.repopathtype #} + + {# WorkshopManager.default.repo2docker.repotype --> #} + function R2DgetRepoType() { + return {{ custom_config.get("binderRepos", {}).get("repos", ["GitHub"]) | tojson }}.map(item => [item, item]); + } + + function setR2DType(trigger, serviceId, rowId, tabId, elementId, elementOptions) { + const repo2dockerSelect = $(`[id^="${serviceId}-${rowId}-"][id$="-${elementId}-input"]`); + fillSelect(elementId, repo2dockerSelect, R2DgetRepoType()); + } + {# <-- WorkshopManager.default.repo2docker.repotype #} + + {# WorkshopManager.default.expertmode --> #} + function workshopManagerToggleExpertMode(trigger, serviceId, rowId, tabId, elementId, elementOptions) { + const optionInput = $(`[id^="${serviceId}-${rowId}-"][id$="-option-input"]`); + const systemInput = $(`[id^="${serviceId}-${rowId}-"][id$="-system-input"]`); + + if ( val(getInputElement(serviceId, rowId, elementId)) ){ + // if checked: set systems + options to multiple + [optionInput, systemInput].forEach(input => { + input.prop("size", 4); + input.prop("multiple", true); + }) + } else { + [optionInput, systemInput].forEach(input => { + input.prop("size", 1); + input.prop("multiple", false); + }) + } + } + {# <-- WorkshopManager.default.expertmode #} + + {# WorkshopManager.button.helper --> #} + + function showToast(message, type = "danger") { + const toast = $(` + <div class="toast align-items-center text-white bg-${type} border-0" role="alert" aria-live="assertive" style="opacity: 0.9 !important" aria-atomic="true"> + <div class="d-flex"> + <div class="toast-body"> + ${message} + </div> + <button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button> + </div> + </div> + `); + $("#toastContainer").append(toast); + const bsToast = new bootstrap.Toast(toast[0]); + bsToast.show(); + } + + function getAPIOptions() { + return { + dataType: null, + tryCount: 5, + error: function (jqXHR, textStatus, errorThrown) { + if (jqXHR.status == 503) { + this.tryCount--; + if (this.tryCount >= 0) { + $.ajax(this); + return; + } + return; + } + if (jqXHR.status == 403) { + return; + } + showToast("Request to Server failed. Try refreshing website"); + console.error("API Request failed:", textStatus, errorThrown); + } + } + } + + function getWorkshopManagerAPIUrl(serviceId, rowId, utils, base_url) { + if ( isFirstRow(rowId) ) { + const workshopId = $(`input[id^='${serviceId}-${rowId}-'][id$='-workshopid-input']`).val(); + if ( workshopId ) { + return utils.url_path_join("workshops", workshopId); + } else { + return "workshops"; + } + } else { + return utils.url_path_join("workshops", rowId); + } + } + {# <-- WorkshopManager.button.helper #} + + {# WorkshopManager.button.new --> #} + function workshopManagerButtonNewSave(serviceId, rowId, buttonId, button_options, user, api, base_url, utils, show_modal=false) { + const options = getAPIOptions(); + const form = $(`form[id^='${serviceId}-${rowId}-form']`); + const valid = validateForm(serviceId, rowId); + if ( !valid ) { + console.log(`Invalid Form for ${serviceId}-${rowId}`); + return; + } + let userOptions = collectSelectedOptions(serviceId, rowId, allCheckboxes=true); + let workshopData = collectWorkshopOptions(serviceId, rowId); + + options["data"] = JSON.stringify({ + ...userOptions, + ...workshopData + }); + options["success"] = function (resp) { + if ( show_modal ) { + workshopManagerShowModal(serviceId, rowId, resp); + } + }; + options["type"] = "POST"; + + api.api_request( + getWorkshopManagerAPIUrl(serviceId, rowId, utils, base_url), + options + ); + } + function workshopManagerButtonNew(serviceId, rowId, buttonId, button_options, user, api, base_url, utils) { + workshopManagerButtonNewSave(serviceId, rowId, buttonId, button_options, user, api, base_url, utils, true); + } + {# <-- WorkshopManager.button.new #} + + {# WorkshopManager.button.reset --> #} + function workshopManagerButtonReset(serviceId, rowId, buttonId, button_options, user, api, base_url, utils) { + const options = getAPIOptions(); + options["type"] = "GET"; + options["success"] = function (resp) { + workshopManagerFillExistingRow(serviceId, rowId, resp); + } + api.api_request( + getWorkshopManagerAPIUrl(serviceId, rowId, utils, base_url), + options + ); + } + {# <-- WorkshopManager.button.reset #} + + {# WorkshopManager.button.share --> #} + function workshopManagerShowLink(serviceId, rowId, tabId, buttonId, button_options, user, api, base_url, utils) { + workshopManagerShowModal($this.attr("data-service"), $this.attr("data-row"), rowId); + } + {# <-- WorkshopManager.button.share #} + + {# WorkshopManager.button.delete --> #} + function workshopManagerButtonDelete(serviceId, rowId, buttonId, button_options, user, api, base_url, utils) { + const options = getAPIOptions(); + options["type"] = "DELETE"; + options["success"] = function () { + $(`tr[data-server-id=${serviceId}-${rowId}]`).each(function () { + $(this).remove(); + }); + console.log(`Delete of ${serviceId}-${rowId} successful`); + }; + api.api_request( + getWorkshopManagerAPIUrl(serviceId, rowId, utils, base_url), + options + ); + } + {# <-- WorkshopManager.button.delete #} + + {# WorkshopManager.button.stop --> #} + function updateHeaderButtons(serviceId, rowId, status) { + // status: ["running", "starting", "na", "stopping", "cancelling", "stopped", "waiting"] + let toShow = []; + let toDisable = []; + if ( status == "running" ) { + toShow = ["open", "stop"]; + } else if ( status == "waiting" ) { + toShow = ["open", "stop"]; + toDisable = ["open"]; + } else if ( status == "starting" ) { + toShow = ["cancel"]; + } else if ( status == "na" ) { + toShow = ["na", "del"]; + toDisable = ["na"]; + } else if ( status == "stopping" ) { + toShow = ["open", "stop"]; + toDisable = ["open", "stop"]; + } else if ( status == "cancelling" ) { + toShow = ["cancel"]; + toDisable = ["cancel"]; + } else if ( status == "stopped" ) { + toShow = ["start"]; + toDisable = []; + } else if ( status == "disable" ) { + toDisable = ["open", "stop", "cancel", "start", "del"]; + } + const baseSelector = `button[id^="${serviceId}-${rowId}"][id$="-btn-header"]`; + + // Enable buttons + const toDisableExcludeSelector = toDisable + .map(item => `:not([id$="-${item}-btn-header"])`) + .join(""); + $(`${baseSelector}${toDisableExcludeSelector}`).prop("disabled", false); + + // Disable buttons + toDisable.forEach(item => { + $(`button[id^="${serviceId}-${rowId}"][id$="-${item}-btn-header"]`).prop("disabled", true); + }); + + if ( status != "disable" ) { + // Hide buttons + const toShowExcludeSelector = toShow + .map(item => `:not([id$="-${item}-btn-header"])`) + .join(""); + $(`${baseSelector}${toShowExcludeSelector}`).hide(); + + // Show buttons + toShow.forEach(item => { + $(`button[id^="${serviceId}-${rowId}"][id$="-${item}-btn-header"]`).show(); + }); + } + } + + function getCurrentTimestamp() { + const now = new Date(); + + const berlinTime = new Date( + now.toLocaleString('en-US', { timeZone: 'Europe/Berlin' }) + ); + + const year = berlinTime.getFullYear(); + const month = String(berlinTime.getMonth() + 1).padStart(2, '0'); + const day = String(berlinTime.getDate()).padStart(2, '0'); + const hours = String(berlinTime.getHours()).padStart(2, '0'); + const minutes = String(berlinTime.getMinutes()).padStart(2, '0'); + const seconds = String(berlinTime.getSeconds()).padStart(2, '0'); + const milliseconds = String(now.getMilliseconds()).padStart(3, '0'); + + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`; + } + + function getStopEvent(buttonId) { + const event = { + "progress": 100, + "failed": true, + "ready": false, + "html_message": `<details><summary>${getCurrentTimestamp()}: Start cancelled by user.</summary>${buttonId} button was triggered.</details>` + } + return event; + } + + function workshopButtonStop(serviceId, rowId, buttonId, button_options, user, api, base_url, utils) { + const options = getAPIOptions(); + options["success"] = function (data, textStatus, jqXHR) { + updateHeaderButtons(serviceId, rowId, "stopped"); + progressBarUpdate(serviceId, rowId, "", 0); + appendToLog(serviceId, rowId, getStopEvent(buttonId)); + } + updateHeaderButtons(serviceId, rowId, "stopping"); + progressBarUpdate(serviceId, rowId, "stopping", 100); + api.stop_named_server(user, rowId, options); + } + {# <-- WorkshopManager.button.stop #} + {# WorkshopManager.button.cancel --> #} + function workshopButtonCancel(serviceId, rowId, buttonId, button_options, user, api, base_url, utils) { + const options = getAPIOptions(); + options["success"] = function (data, textStatus, jqXHR) { + console.log("Stopped"); + console.log(serviceId); + console.log(rowId); + updateHeaderButtons(serviceId, rowId, "stopped"); + progressBarUpdate(serviceId, rowId, "", 0); + appendToLog(serviceId, rowId, getStopEvent(buttonId)); + } + updateHeaderButtons(serviceId, rowId, "cancelling"); + progressBarUpdate(serviceId, rowId, "cancelling", 99); + api.cancel_named_server(user, rowId, options); + } + {# <-- WorkshopManager.button.cancel #} + + {# WorkshopManager.button.start --> #} + function workshopButtonStart(serviceId, rowId, buttonId, button_options, user, api, base_url, utils) { + homeButtonStart(serviceId, rowId, buttonId, button_options, user, api, base_url, utils); + } + {# <-- WorkshopManager.button.start #} + + {# WorkshopManager.button.open --> #} + async function checkAndOpenUrl(serviceId, rowId, url, retries = 50, delay = 500) { + // wait for 3 successful responses + let successCounter = 0; + for (let attempt = 1; attempt <= retries; attempt++) { + try { + const response = await fetch(`${url}/api`, { + method: 'GET', + mode: 'no-cors', + }); + if (response.ok || response.status == 405) { + successCounter += 1; + if ( successCounter > 8 ) { + window.open(url, "_blank"); + updateHeaderButtons(serviceId, rowId, "running"); + progressBarUpdate(serviceId, rowId, "running", 100); + $(`button[id^='${serviceId}-${rowId}-'][id$='-btn']`).prop("disabled", false); + return; + } + } + } catch (error) { + showToast(`Exception while sending request to ${url}.`, type="warning"); + console.error(`Attempt ${attempt}: Network error or invalid URL -`, error); + } + + if (attempt < retries) { + // Wait for the specified delay before retrying + await new Promise(resolve => setTimeout(resolve, delay)); + } else { + updateHeaderButtons(serviceId, rowId, "running"); + progressBarUpdate(serviceId, rowId, "running", 100); + showToast(`Cannot connect to started Server. Try to open manually. If this does not work try restarting the Server.`); + console.error("Maximum retries reached. Unable to access the website."); + } + } + } + + function homeOpen(serviceId, rowId, buttonId, button_options, user, api, base_url, utils) { + window.open(`/user/{{ user.name}}/${rowId}`, "_blank"); + } + + function workshopButtonOpen(serviceId, rowId, buttonId, button_options, user, api, base_url, utils) { + window.open("{{ url }}", "_blank"); + } + {# <-- WorkshopManager.button.open #} + + + {# WorkshopManager.button.save --> #} + function workshopManagerButtonSave(serviceId, rowId, buttonId, button_options, user, api, base_url, utils) { + workshopManagerButtonNewSave(serviceId, rowId, buttonId, button_options, user, api, base_url, utils, false); + } + {# <-- WorkshopManager.button.save #} + + {# <-- Workshop Manager #} + + {# Workshop --> #} + {# Workshop.labconfig.custom.username --> #} + function toggleExternalCB(trigger, serviceId, rowId, tabId, elementId, elementOptions) { + const labelChecked = val(getLabelCBElement(serviceId, rowId, trigger)); + const inputDiv = getInputDiv(serviceId, rowId, elementId); + const inputElement = getInputElement(serviceId, rowId, elementId); + if ( labelChecked ) { + inputDiv.show(); + inputElement.attr("data-collect", true); + } else { + inputDiv.hide(); + inputElement.attr("data-collect", false); + } + } + {# <-- Workshop.labconfig.custom.username #} + + {# Workshop.labconfig.unicore.account --> #} + function workshopUpdateAccount(trigger, serviceId, rowId, tabId, elementId, elementOptions) { + const values = getAccountOptions(serviceId, rowId); + const inputElement = getInputElement(serviceId, rowId, "account"); + fillSelect(elementId, inputElement, values); + // inputElement.trigger("change"); + } + {# Workshop.labconfig.unicore.account --> #} + + {# Workshop.modules --> #} + function getModuleValues(serviceId, rowId, name, setName) { + const options = val(getInputElement(serviceId, rowId, "option")); + let values = []; + let keys = new Set(); + options.forEach(option => { + if (getServiceConfig(serviceId)?.options?.[option]?.[setName]) { + const nameSet = getServiceConfig(serviceId)?.options[option]?.[setName]; + Object.entries(userModulesConfig[name]) + .filter(([key, value]) => value.sets && value.sets.includes(nameSet)) + .forEach( ([key, value]) => { + if ( !keys.has(key) ) { + keys.add(key); + values.push([ + key, + value.displayName, + typeof value.default === 'object' && value.default !== null ? value.default.default : value.default, + value.href + ]); + } + }); + } + }); + return values; + } + + function updateMultipleCheckboxes(trigger, serviceId, rowId, tabId, elementId, elementOptions) { + $(`input[id^='${serviceId}-${rowId}-${tabId}-'][id$='-select-all']`).prop("checked", false); + $(`input[id^='${serviceId}-${rowId}-${tabId}-'][id$='-select-none']`).prop("checked", false); + + const containerDiv = $(`div[id^='${serviceId}-${rowId}-'][id$='${elementId}-checkboxes-div']`); + const inputDiv = $(`div[id^='${serviceId}-${rowId}-'][id$='${elementId}-input-div']`); + const values = getModuleValues(serviceId, rowId, elementId, elementOptions.options.setName); + + let workshopPreset = false; + let workshopPresetChecked = []; + const group = elementOptions.options.group || tabId; + const name = elementOptions.options.name || elementId; + {%- if pagetype == vars.pagetype_workshop and db_workshops %} + const workshops = {{ db_workshops | tojson }} || {}; + const workshopValues = workshops?.[rowId]?.user_options; + if ( Object.keys(workshopValues).includes(group) && Object.keys(workshopValues[group]).includes(name) ){ + workshopPreset = true; + const modules = workshopValues[group][name]; + if ( modules.length > 0 ) { + workshopPresetChecked = modules; + } + } + {%- endif %} + // Ensure the container exists + if (containerDiv.length > 0 && values.length > 0) { + const idPrefix = containerDiv.attr('id').replace(/-checkboxes-div$/, ""); + containerDiv.html(''); + values.forEach(function (item) { + let isChecked = ''; + let isDisabled = ''; + if ( workshopPreset ) { + if ( workshopPresetChecked.includes(item[0]) ){ + isChecked = 'checked'; + } + isDisabled = 'disabled="true"'; + } else { + isChecked = item[2] ? 'checked' : ''; + } + let dependencies = ''; + if ( elementOptions.dependency ){ + for (const [specificKey, specificValues] of Object.entries(elementOptions.dependency)) { + dependencies += ` data-dependency-${specificKey}="true"`; + specificValues.forEach(specificValue => { + dependencies += ` data-dependency-${specificKey}-${specificValue}="true"`; + }); + } + } + + // Create the new div block + const newDiv = $(` + <div id="${idPrefix}-${item[0]}-input-div" class="form-check col-sm-6 col-md-4 col-lg-3"> + <input type="checkbox" name="${item[0]}" data-collect="true" ${dependencies} + data-checked="${isChecked}" data-group="${group}" data-element="${item[0]}" data-type="checkbox" data-row="${rowId}" data-tab="${tabId}" class="form-check-input" id="${idPrefix}-${item[0]}-input" value="${item[0]}" ${isChecked} ${isDisabled}/> + <label class="form-check-label" for="${idPrefix}-${item[0]}-input"> + <span class="align-middle">${item[1]}</span> + <a href="${item[3]}" target="_blank" class="module-info text-muted ms-3"> + <span>{{ svg.info_svg | safe }}</span> + <div class="module-info-link-div d-inline-block"> + <span class="module-info-link" id="nbdev-info-link"> {{ svg.link_svg | safe }}</span> + </div> + </a> + </label> + </div> + `); + // Append the new div to the container + containerDiv.append(newDiv); + // Add toggle function to each checkbox + $(`#${idPrefix}-${item[0]}-input`).on("click", function (event) { + $(`input[id^='${serviceId}-${rowId}-'][id$='-select-all']`).prop("checked", false); + $(`input[id^='${serviceId}-${rowId}-'][id$='-select-none']`).prop("checked", false); + }); + }); + } + inputDiv.show(); + } + {# <-- Workshop.modules #} + {# Workshop.navbar.resources --> #} + function resourceButton(trigger, serviceId, rowId) { + const systems = getUnicoreValues(serviceId, rowId, "system"); + const partitions = getUnicoreValues(serviceId, rowId, "partition"); + let showResources = false; + systems.forEach( (system) => { + if ( !showResources ) { + partitions.forEach( (partition) => { + if ( !showResources && (Object.keys(resourcesConfig[system])).includes(partition) ) { + if ( !(systemConfig[system]?.interactivePartitions || []).includes(partition) ) { + showResources = true; + } + } + }); + } + }); + if ( showResources ) { + $(`button[id^="${serviceId}-${rowId}-${trigger}-navbar-button"]`).trigger("show"); + } else { + $(`button[id^="${serviceId}-${rowId}-${trigger}-navbar-button"]`).trigger("hide"); + } + } + {# <-- Workshop.navbar.resources #} + + {# Workshop.labconfig.name --> #} + {%- if pagetype == vars.pagetype_workshop %} + function workshopLabName(trigger, serviceId, rowId, tabId, elementId, elementOptions) { + const inputName = getInputElement(serviceId, rowId, elementId); + const user_options = {{ spawner.user_options | tojson }} || {}; + const displayName = user_options.name || "Workshop {{ workshop_id }}"; + inputName.val(displayName); + } + {%- endif %} + {# <-- Workshop.labconfig.name #} + + {# Workshop.logs.logcontainer --> #} + + function fillLogContainer(serviceId, rowId, events) { + clearLogs(serviceId, rowId); + events.forEach(event => { + appendToLog(serviceId, rowId, event); + }) + } + + function clearLogs(serviceId, rowId) { + const logInputElement = $(`[id^='${serviceId}-${rowId}-logs'][id$='-logcontainer-input']`); + logInputElement.html(""); + } + + function defaultLogs(serviceId, rowId) { + const logInputElement = $(`[id^='${serviceId}-${rowId}-logs'][id$='-logcontainer-input']`); + logInputElement.html("Logs collected during the Start process will be shown here."); + } + + function appendToLog(serviceId, rowId, event) { + const logInputElement = $(`[id^='${serviceId}-${rowId}-logs'][id$='-logcontainer-input']`); + let htmlMsg = ""; + if (event.html_message !== undefined) { + htmlMsg = event.html_message + } else if (event.message !== undefined) { + htmlMsg = event.message; + } + if ( !htmlMsg && event.failed ) { + htmlMsg = "Server stopped"; + } + if ( htmlMsg ) { + try { + htmlMsg = htmlMsg.replace(/ /g, ' '); + } catch (e) { + console.log("Could not append Log Message"); + console.log(e); + return; + } + let exists = false; + const childCount = logInputElement.children().length; + logInputElement.children().each(function (i, e) { + let logMsg = $(e).html(); + if (htmlMsg == logMsg) exists = true; + }) + if (!exists) + logInputElement.append($(`<div id="${serviceId}-${rowId}-logs-logcontainer-element${childCount}" class="log-div">`).html(htmlMsg)); + let element = $(`#${serviceId}-${rowId}-logs-logcontainer-element${childCount}`); + + if ( event.progress === 100 && element.find("details") ) { + element.find("details").attr("open", true); + } + + } + } + {# <-- Workshop.logs.logcontainer #} + + {# Workshop.header.progressBar --> #} + function progressBarUpdate(serviceId, rowId, status, progress) { + const progressBarElement = $(`#${serviceId}-${rowId}-progress-bar`); + const progressTextElement = $(`#${serviceId}-${rowId}-progress-text`); + const progressTextInfoElement = $(`#${serviceId}-${rowId}-progress-info-text`); + let background = ""; + let text = ""; + let color = "black"; + if ( progress >= 60 ) { + color = "white"; + } + if ( status == "connecting" ) { + text = "connecting"; + background = "bg-success"; + } else if ( status == "running" ) { + text = "running"; + background = "bg-success"; + } else if ( status == "stopped" ) { + text = "stopped"; + background = "bg-danger"; + } else if ( status == "cancelling" ) { + text = "cancelling"; + background = "bg-danger"; + } else if ( status == "stopping" ) { + text = "stopping"; + background = "bg-danger"; + } else if ( status == "starting" ) { + text = "starting"; + } else if ( progress == 0 ){ + text = ""; + } + progressBarElement.width(progress).removeClass("bg-success bg-danger bg-primary").addClass(background).html(""); + progressTextElement.css('color', color); + progressTextElement.html(`${progress}%`); + progressTextInfoElement.html(text); + } + {# <-- Workshop.header.progressBar #} + + {# <-- Workshop #} + + {# Home --> #} + function _uuidv4hex() { + return ([1e7, 1e3, 4e3, 8e3, 1e11].join('')).replace(/[018]/g, c => + (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)); + } + function _uuidWithLetterStart() { + let uuid = _uuidv4hex(); + let char = Math.random().toString(36).match(/[a-zA-Z]/)[0]; + return char + uuid.substring(1); + } + + function homeButtonNew(serviceId, rowId, buttonId, button_options, user, api, base_url, utils) { + const newId = _uuidWithLetterStart(); + const options = getAPIOptions(); + + const form = $(`form[id^='${serviceId}-${rowId}-form']`); + const valid = validateForm(serviceId, rowId); + if ( !valid ) { + console.log(`Invalid Form for ${serviceId}-${rowId}`); + return; + } + + let userOptions = collectSelectedOptions(serviceId, rowId); + options["data"] = JSON.stringify(userOptions); + + options["success"] = function (data, textStatus, jqXHR) { + updateHeaderButtons(serviceId, rowId, "starting"); + + const url = new URL(window.location.href); + url.searchParams.set('service', serviceId); + url.searchParams.set('row', newId); + url.searchParams.set('showlogs', true); + window.location.href = url.toString(); + } + api.start_named_server(user, newId, options); + } + + function homeButtonStart(serviceId, rowId, buttonId, button_options, user, api, base_url, utils) { + const options = getAPIOptions(); + + const form = $(`form[id^='${serviceId}-${rowId}-form']`); + const valid = validateForm(serviceId, rowId); + if ( !valid ) { + console.log(`Invalid Form for ${serviceId}-${rowId}`); + return; + } + + let userOptions = collectSelectedOptions(serviceId, rowId); + options["data"] = JSON.stringify(userOptions); + // Ensure SSE is connected to receive all status updates + // sseInit(); + clearLogs(serviceId, rowId); + + options["success"] = function (data, textStatus, jqXHR) { + updateHeaderButtons(serviceId, rowId, "starting"); + } + api.start_named_server(user, rowId, options); + + const toView = document.getElementById(`${serviceId}-${rowId}-summary-tr`) + if ( toView ) toView.scrollIntoView(); + + // show summary-tr + const summaryTr = $(`tr[id^='${serviceId}-${rowId}-summary-tr']`); + const accordionIcon = summaryTr.find(".accordion-icon"); + const collapse = $(`.collapse[id^='${serviceId}-${rowId}-collapse']`); + const shown = collapse.hasClass("show"); + if ( ! shown ) { + accordionIcon.removeClass("collapsed"); + new bootstrap.Collapse(collapse); + } + + const navbarLogsButton = $(`[id^='${serviceId}-${rowId}-'][id$='-logs-navbar-button']`); + if ( navbarLogsButton ) { + navbarLogsButton.trigger("click"); + } + } + + function homeButtonDelete(serviceId, rowId, buttonId, button_options, user, api, base_url, utils) { + updateHeaderButtons(serviceId, rowId, "disable"); + const options = getAPIOptions(); + options["success"] = function () { + $(`tr[data-server-id='${serviceId}-${rowId}']`).each(function () { + $(this).remove(); + }); + console.log(`Delete of ${serviceId}-${rowId} successful`); + } + api.delete_named_server(user, rowId, options); + } + {# <-- Home #} + +</script> diff --git a/templates/macros/table/variables.jinja b/templates/macros/table/variables.jinja new file mode 100644 index 0000000..fc476df --- /dev/null +++ b/templates/macros/table/variables.jinja @@ -0,0 +1,5 @@ +{%- set first_row_id = "__new__" %} +{%- set pagetype_workshop = "workshop" %} +{%- set pagetype_workshopmanager = "workshopmanager" %} +{%- set pagetype_home = "home" %} +{%- set pagetype_share = "share" %} \ No newline at end of file diff --git a/templates/page.html b/templates/page.html index 93eb3a0..6a3a412 100644 --- a/templates/page.html +++ b/templates/page.html @@ -36,7 +36,36 @@ {% block scripts -%} {%- endblock %} <script> - var evtSourcesGlobal = {}; + var evtSource = undefined; + var testCounter = 0; + + function sseInit() { + let sseUrl = `${jhdata.base_url}api/sse` + if ( jhdata.user ) { + sseUrl = `${jhdata.base_url}api/sse/${jhdata.user}?_xsrf=${window.jhdata.xsrf_token}`; + } + if ( evtSource ) { + evtSource.close(); + } + evtSource = new EventSource(sseUrl); + evtSource.onmessage = (e) => { + try { + const jsonData = JSON.parse(event.data); + console.log(jsonData); + for (const [key, value] of Object.entries(jsonData)) { + console.log(`Trigger ${key}`); + $(`[data-sse-${key}]`).trigger("sse", value); + } + } catch (error) { + console.error("Failed to parse SSE data:", error); + } + }; + evtSource.onerror = (e) => { + console.log("Reconnect EventSource"); + // Reconnect + } + } + require.config({ {%- if version_hash -%} urlArgs: "v={{version_hash}}", @@ -123,11 +152,12 @@ {% block script -%} {%- endblock %} <script> + $(document).ready(function() { + sseInit(); + }); window.onbeforeunload = function() { - if (typeof evtSourcesGlobal !== 'undefined') { - for (const [key, value] of Object.entries(evtSourcesGlobal)) { - value.close(); - } + if (typeof evtSource !== 'undefined') { + evtSource.close(); } } </script> diff --git a/templates/spawn_pending.html b/templates/spawn_pending.html index 3d0bb64..7dce663 100644 --- a/templates/spawn_pending.html +++ b/templates/spawn_pending.html @@ -1,310 +1,8 @@ {%- extends "page.html" -%} -{%- import "macros/svgs.jinja" as svg -%} -{%- block stylesheet -%} - <link rel="stylesheet" href='{{ static_url("css/home.css", include_version=True) }}' type="text/css"/> - <link rel="stylesheet" href='{{ static_url("css/spawn.css", include_version=True) }}' type="text/css"/> -{%- endblock -%} +{%- block meta -%} +{% set service = spawner.user_options.get("service", "jupyterlab") +<meta http-equiv="refresh" content="0; url=https://{{hostname}}{{ base_url }}home?service={{ service }}&row={{ spawner.name }}&showlogs" /> +{%- endblock %} -{%- macro create_text_input(label, value) -%} -{%- if value -%} -{%- set key = label.lower() %} -<div id="{{key}}-input-div" class="row mb-3"> - <label for="{{ key }}-input" class="col-4 col-form-label">{{ label }}</label> - <div class="col-8"> - <input type="text" class="form-control" id="{{ key }}-input" value="{{value}}" disabled> - </div> -</div> -{%- endif -%} -{%- endmacro -%} - -{%- macro create_number_input(label, value) -%} -{%- set key = label.lower() -%} -<div id="{{key}}-input-div" class="row mb-3"> - <label for="{{key}}-input" class="col-4 col-form-label">{{ label }}</label> - <div class="col-8"> - <input type="number" id="{{key}}-input" class="form-control" value="{{value}}" disabled> - </div> -</div> -{%- endmacro -%} - -{%- macro create_checkbox_input(label, checked) -%} -{%- set key = label.lower() -%} -<div id="{{key}}-input-div" class="row mb-3 align-items-center"> - <label for="{{key}}-input" class="col-4 col-form-label">{{ label }}</label> - <div class="col-8"> - <input type="checkbox" class="form-check-input" id="{{key}}-input" {%- if checked %} checked {%- endif %} disabled> - </div> -</div> -{%- endmacro -%} - - -{%- set user_options = spawner.user_options -%} -{%- set name = user_options.get("name") -%} -{%- if "profile" in user_options -%} -{%- set service = user_options.get("profile", "JupyterLab/3.6").split('/')[0] -%} -{%- set option = user_options.get("profile", "JupyterLab/3.6").split('/')[1] -%} -{%- else -%} -{%- set service = user_options.get("service", "JupyterLab/3.6").split('/')[0] -%} -{%- set option = user_options.get("service", "JupyterLab/3.6").split('/')[1] -%} -{%- endif -%} -{%- set service_name = custom_config.get("services").get(service).get("options").get(option).get("name") -%} -{%- set version = user_options.get("options", "JupyterLab") -%} -{%- set system = user_options.get("system", None) -%} -{%- set image = user_options.get("image", None) -%} -{%- set flavor = user_options.get("flavor", None) -%} -{%- set account = user_options.get("account", None) -%} -{%- set project = user_options.get("project", None) -%} -{%- set partition = user_options.get("partition", None) -%} -{%- set reservation = user_options.get("reservation", None) -%} -{%- set nodes = user_options.get("nodes", None) -%} -{%- set runtime = user_options.get("runtime", None) -%} -{%- set xserver = user_options.get("xserver", None) -%} -{%- set gpus = user_options.get("gpus", None) -%} -{%- set userModules = user_options.get("userModules", {}) -%} - -{#- Check if we should disable any tabs -#} -{%- set no_resources = True -%} -{%- if nodes != None or gpus != None or runtime != None or xserver != None -%} - {%- set no_resources = False -%} -{%- endif -%} - - -{%- block main -%} -<div class="container-fluid p-4"> - <h1>Your server is starting up...</h1> - <p>You will be redirected automatically when it's ready for you.</p> - - <div class="accordion" id="labInfoAccordion"> - <div class="accordion-item" style="border-bottom-right-radius: .25rem;border-bottom-left-radius: .25rem;"> - <h2 class="accordion-header" id="labInfo"> - <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#labInfoCollapse"> - Lab Info (click to expand) - </button> - </h2> - <div id="labInfoCollapse" class="accordion-collapse collapse" data-bs-parent="#labInfoAccordion"> - <div class="accordion-body text-black"> - <div class="d-flex align-items-start m-3"> - {#- TAB NAV PILLS -#} - {%- set nav_tab_margins = "mb-3" %} - <div class="nav flex-column nav-pills p-3 ps-0" id="tab" role="tablist"> - <button class="nav-link active {{ nav_tab_margins }}" id="config-info-tab" data-bs-toggle="pill" data-bs-target="#config-info" type="button" role="tab">Lab Config</button> - <button class="nav-link {{ nav_tab_margins }} {%- if no_resources %} disabled {%- endif %}" id="resources-info-tab" data-bs-toggle="pill" data-bs-target="#resources-info" type="button" role="tab" >Resources</button> - <button class="nav-link {{ nav_tab_margins }} {%- if not userModules %} disabled {%- endif %}" id="modules-info-tab" data-bs-toggle="pill" data-bs-target="#modules-info" type="button" role="tab" >Kernels and Extensions</span></button> - </div> - {#- TAB NAV CONTENT -#} - <div class="tab-content w-100" id="tabContent"> - <div class="tab-pane fade show active" id="config-info" role="tabpanel"> - {{ create_text_input("Name", name) }} - {{ create_text_input("Version", service_name) }} - {{ create_text_input("Image", image) }} - <hr> - {{ create_text_input("System", system) }} - {{ create_text_input("Flavor", flavor) }} - {{ create_text_input("Account", account) }} - {{ create_text_input("Project", project) }} - {{ create_text_input("Partition", partition) }} - {%- if reservation != None %} - <hr id="reservation-hr"> - {{ create_text_input("Reservation", reservation) }} - {%- endif %} - </div> - <div class="tab-pane fade" id="resources-info" role="tabpanel"> - {%- if not no_resources -%} - {%- if nodes -%} {{ create_number_input("Nodes", nodes) }} {%- endif %} - {%- if gpus -%} {{ create_number_input("GPUs", gpus) }} {%- endif %} - {%- if runtime -%} {{ create_number_input("Runtime", (runtime)|int) }} {%- endif %} - {%- set resources = auth_state.get("options_form", {}).get('resources', {}) -%} - {%- set xserver_options = resources.get(option, {}).get(system, {}).get(partition, {}).get("xserver", {}) -%} - {%- set show_checkbox = xserver_options.get("checkbox", False) -%} - {%- if show_checkbox -%} - {%- set cb_label = xserver_options.get("checkbox_label", "XServer") %} - {{ create_checkbox_input(cb_label, xserver) }} - {%- endif %} - {%- if xserver -%} - {%- set label = xserver_options.get("label", "XServer") %} - {{ create_number_input(label, xserver) }} - {%- endif %} - - {%- endif %} - </div> - <div class="tab-pane fade" id="modules-info" role="tabpanel"> - {%- if userModules -%} - {%- set module_sets = custom_config.get("userModules", {}) %} - {%- for set, modules in module_sets.items() -%} - {% set ns = namespace(first = true) -%} - {%- for module, module_info in modules.items() -%} - {%- if ns.first %} - <h4>{{ set | title}}</h4> - <div class="row g-0"> - {%- endif %} - <div class="form-check col-sm-6 col-md-4 col-lg-3"> - <input type="checkbox" class="form-check-input" id="{{ module }}-check" disabled {%- if module in userModules %} checked {%- endif %}> - <label class="form-check-label" for="{{ module }}-check"> - <span class="align-middle">{{ module_info['displayName'] }}</span> - <a href="{{ module_info['href'] }}" target="_blank" class="text-muted">{{ svg.info_svg | safe }}</a> - </label> - </input> - </div> - {%- set ns.first = false -%} - {%- endfor -%} - </div> - {%- endfor -%} - {%- endif %} - </div> - </div> {#- tab content #} - </div> {#- flex div #} - </div> {#- accordion body #} - </div> {#- accordion collapse #} - </div> {#- accordion item #} - </div> {#- accordion #} - - <div class="card mt-4"> - <div class="card-header d-flex"> - <div class="flex-grow-1"> - <div class="progress" style="height: 20px;"> - <div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%;"></div> - </div> - <div id="progress-info-text" class="text-center text-muted my-auto w-100" style="font-size: smaller;">spawning...</div> - </div> - <button id="cancel" type="button" class="btn btn-danger ms-4" disabled>{{ svg.stop_svg | safe }} Cancel </button> - <button id="retry" type="button" class="btn btn-primary ms-4" style="display: none;" disabled>{{ svg.retry_svg | safe }} Retry </button> - </div> - <div class="card-body text-black"> - <div id="log"></div> - </div> - </div> - -</div> -{%- endblock -%} - -{%- block script -%} -{%- set cancel_progress_refresh_rate = 1000 -%} -{%- set cancel_progress_activation = 0 -%} -{%- set cancel_progress_deactivation = 99 -%} - -<script> -require(["jquery", "jhapi", "home/utils"], function ( - $, - JHAPI, - utils -) { - var base_url = window.jhdata.base_url; - var user = window.jhdata.user; - var api = new JHAPI(base_url); - var timeout; - - // Cancel server spawn on click - $("#cancel").click(function (event) { - $("#cancel").attr("disabled", true); - api.cancel_named_server(user, "{{spawner.name}}"); - clearTimeout(timeout); - }); - - // Retry server spawn on click - $("#retry").click(function (event) { - $("#retry").attr("disabled", true); - - api.start_named_server(user, "{{spawner.name}}", { - data: JSON.stringify({{spawner.user_options | safe}}), - success: function () { - $("#retry").hide(); - $("#cancel").attr("disabled", true).show(); - $("#progress-bar").removeClass("bg-danger"); - $("#progress-info-text").html("spawning..."); - $("#log").html(""); - updateStatus(); - }, - error: function (xhr, textStatus, errorThrown) { - $("#progress-bar").addClass("bg-danger"); - $("#progress-info-text").html("last spawn failed"); - let details = $("<details>") - .append($("<summary>").html(`Could not request spawn. Error: ${xhr.status} ${errorThrown}`)) - .append($("<pre>").html(JSON.stringify(JSON.parse(xhr.responseText), null, 2))); - let div = $("<div>").addClass("log-div").html(details); - $("#log").html("").append(div); - $("#retry").removeAttr("disabled"); - } - }) - }); - - function updateStatus() { - let id = "{{ spawner.name }}"; - let progressUrl = `${window.jhdata.base_url}api/users/${window.jhdata.user}/servers/${id}/progress?_xsrf=${window.jhdata.xsrf_token}`; - let evtSource = new EventSource(progressUrl); - evtSource.onmessage = (e) => { - const evt = JSON.parse(e.data); - if (evt.progress !== undefined && evt.progress != 0) { - if (evt.progress == 100) { - evtSource.close(); - delete evtSource; - - if (evt.failed) { - // Update UI via spawnStatusChangedEvtSource so that - // it happens after the stop has finished in the backend - clearTimeout(timeout); - } - else { - $(`#progress-bar`).removeClass("bg-danger").addClass("bg-success"); - $("#progress-info-text").html("redirecting..."); - window.location.reload(); - } - } - else { - $("#progress-bar").html('<b>' + evt.progress + '%</b>'); - if (evt.progress >= {{ cancel_progress_activation }}) { - $("#cancel").removeAttr("disabled"); - } - if (evt.progress == {{ cancel_progress_deactivation }}) { - $("#cancel").attr("disabled", true); - $(`#progress-bar`).addClass("bg-danger"); - $("#progress-info-text").html("cancelling..."); - } - if (evt.progress == 95) { - // Refresh if stuck on 95% - timeout = setTimeout(() => window.location.reload(), 120000); - } - } - } - - if (evt.html_message !== undefined) { - var htmlMsg = evt.html_message - } else if (evt.message !== undefined) { - var htmlMsg = evt.message; - } - if (htmlMsg) { - try { htmlMsg = htmlMsg.replace(/ /g, ' '); } - catch (e) { return; } - // Only append if a log message has not been appended yet - var exists = false; - $("#log").children().each(function (i, e) { - let logMsg = $(e).html(); - if (htmlMsg == logMsg) exists = true; - }) - if (!exists) - $("#log").append($('<div class="log-div">').html(htmlMsg)); - } - } - } - - $( document ).ready(function() { - updateStatus(); - - let userSpawnerNotificationUrl = `${jhdata.base_url}api/users/${jhdata.user}/notifications/spawners?_xsrf=${window.jhdata.xsrf_token}`; - evtSourcesGlobal["pending"] = new EventSource(userSpawnerNotificationUrl); - evtSourcesGlobal["pending"].onmessage = (e) => { - const data = JSON.parse(e.data); - utils.updateNumberOfUsers(); - for (const id of data.stopped || []) { - if (id == "{{spawner.name}}") { - $(`#progress-bar`).html("").addClass("bg-danger"); - $("#progress-info-text").html("last spawn failed"); - $("#retry").removeAttr("disabled").show(); - $("#cancel").hide(); - } - } - } - }); -}); -</script> -{%- endblock -%} +{%- block title -%}Jupyter-JSC redirect{%- endblock %} diff --git a/templates/spawn_pending_prev.html b/templates/spawn_pending_prev.html new file mode 100644 index 0000000..3d0bb64 --- /dev/null +++ b/templates/spawn_pending_prev.html @@ -0,0 +1,310 @@ +{%- extends "page.html" -%} +{%- import "macros/svgs.jinja" as svg -%} + +{%- block stylesheet -%} + <link rel="stylesheet" href='{{ static_url("css/home.css", include_version=True) }}' type="text/css"/> + <link rel="stylesheet" href='{{ static_url("css/spawn.css", include_version=True) }}' type="text/css"/> +{%- endblock -%} + +{%- macro create_text_input(label, value) -%} +{%- if value -%} +{%- set key = label.lower() %} +<div id="{{key}}-input-div" class="row mb-3"> + <label for="{{ key }}-input" class="col-4 col-form-label">{{ label }}</label> + <div class="col-8"> + <input type="text" class="form-control" id="{{ key }}-input" value="{{value}}" disabled> + </div> +</div> +{%- endif -%} +{%- endmacro -%} + +{%- macro create_number_input(label, value) -%} +{%- set key = label.lower() -%} +<div id="{{key}}-input-div" class="row mb-3"> + <label for="{{key}}-input" class="col-4 col-form-label">{{ label }}</label> + <div class="col-8"> + <input type="number" id="{{key}}-input" class="form-control" value="{{value}}" disabled> + </div> +</div> +{%- endmacro -%} + +{%- macro create_checkbox_input(label, checked) -%} +{%- set key = label.lower() -%} +<div id="{{key}}-input-div" class="row mb-3 align-items-center"> + <label for="{{key}}-input" class="col-4 col-form-label">{{ label }}</label> + <div class="col-8"> + <input type="checkbox" class="form-check-input" id="{{key}}-input" {%- if checked %} checked {%- endif %} disabled> + </div> +</div> +{%- endmacro -%} + + +{%- set user_options = spawner.user_options -%} +{%- set name = user_options.get("name") -%} +{%- if "profile" in user_options -%} +{%- set service = user_options.get("profile", "JupyterLab/3.6").split('/')[0] -%} +{%- set option = user_options.get("profile", "JupyterLab/3.6").split('/')[1] -%} +{%- else -%} +{%- set service = user_options.get("service", "JupyterLab/3.6").split('/')[0] -%} +{%- set option = user_options.get("service", "JupyterLab/3.6").split('/')[1] -%} +{%- endif -%} +{%- set service_name = custom_config.get("services").get(service).get("options").get(option).get("name") -%} +{%- set version = user_options.get("options", "JupyterLab") -%} +{%- set system = user_options.get("system", None) -%} +{%- set image = user_options.get("image", None) -%} +{%- set flavor = user_options.get("flavor", None) -%} +{%- set account = user_options.get("account", None) -%} +{%- set project = user_options.get("project", None) -%} +{%- set partition = user_options.get("partition", None) -%} +{%- set reservation = user_options.get("reservation", None) -%} +{%- set nodes = user_options.get("nodes", None) -%} +{%- set runtime = user_options.get("runtime", None) -%} +{%- set xserver = user_options.get("xserver", None) -%} +{%- set gpus = user_options.get("gpus", None) -%} +{%- set userModules = user_options.get("userModules", {}) -%} + +{#- Check if we should disable any tabs -#} +{%- set no_resources = True -%} +{%- if nodes != None or gpus != None or runtime != None or xserver != None -%} + {%- set no_resources = False -%} +{%- endif -%} + + +{%- block main -%} +<div class="container-fluid p-4"> + <h1>Your server is starting up...</h1> + <p>You will be redirected automatically when it's ready for you.</p> + + <div class="accordion" id="labInfoAccordion"> + <div class="accordion-item" style="border-bottom-right-radius: .25rem;border-bottom-left-radius: .25rem;"> + <h2 class="accordion-header" id="labInfo"> + <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#labInfoCollapse"> + Lab Info (click to expand) + </button> + </h2> + <div id="labInfoCollapse" class="accordion-collapse collapse" data-bs-parent="#labInfoAccordion"> + <div class="accordion-body text-black"> + <div class="d-flex align-items-start m-3"> + {#- TAB NAV PILLS -#} + {%- set nav_tab_margins = "mb-3" %} + <div class="nav flex-column nav-pills p-3 ps-0" id="tab" role="tablist"> + <button class="nav-link active {{ nav_tab_margins }}" id="config-info-tab" data-bs-toggle="pill" data-bs-target="#config-info" type="button" role="tab">Lab Config</button> + <button class="nav-link {{ nav_tab_margins }} {%- if no_resources %} disabled {%- endif %}" id="resources-info-tab" data-bs-toggle="pill" data-bs-target="#resources-info" type="button" role="tab" >Resources</button> + <button class="nav-link {{ nav_tab_margins }} {%- if not userModules %} disabled {%- endif %}" id="modules-info-tab" data-bs-toggle="pill" data-bs-target="#modules-info" type="button" role="tab" >Kernels and Extensions</span></button> + </div> + {#- TAB NAV CONTENT -#} + <div class="tab-content w-100" id="tabContent"> + <div class="tab-pane fade show active" id="config-info" role="tabpanel"> + {{ create_text_input("Name", name) }} + {{ create_text_input("Version", service_name) }} + {{ create_text_input("Image", image) }} + <hr> + {{ create_text_input("System", system) }} + {{ create_text_input("Flavor", flavor) }} + {{ create_text_input("Account", account) }} + {{ create_text_input("Project", project) }} + {{ create_text_input("Partition", partition) }} + {%- if reservation != None %} + <hr id="reservation-hr"> + {{ create_text_input("Reservation", reservation) }} + {%- endif %} + </div> + <div class="tab-pane fade" id="resources-info" role="tabpanel"> + {%- if not no_resources -%} + {%- if nodes -%} {{ create_number_input("Nodes", nodes) }} {%- endif %} + {%- if gpus -%} {{ create_number_input("GPUs", gpus) }} {%- endif %} + {%- if runtime -%} {{ create_number_input("Runtime", (runtime)|int) }} {%- endif %} + {%- set resources = auth_state.get("options_form", {}).get('resources', {}) -%} + {%- set xserver_options = resources.get(option, {}).get(system, {}).get(partition, {}).get("xserver", {}) -%} + {%- set show_checkbox = xserver_options.get("checkbox", False) -%} + {%- if show_checkbox -%} + {%- set cb_label = xserver_options.get("checkbox_label", "XServer") %} + {{ create_checkbox_input(cb_label, xserver) }} + {%- endif %} + {%- if xserver -%} + {%- set label = xserver_options.get("label", "XServer") %} + {{ create_number_input(label, xserver) }} + {%- endif %} + + {%- endif %} + </div> + <div class="tab-pane fade" id="modules-info" role="tabpanel"> + {%- if userModules -%} + {%- set module_sets = custom_config.get("userModules", {}) %} + {%- for set, modules in module_sets.items() -%} + {% set ns = namespace(first = true) -%} + {%- for module, module_info in modules.items() -%} + {%- if ns.first %} + <h4>{{ set | title}}</h4> + <div class="row g-0"> + {%- endif %} + <div class="form-check col-sm-6 col-md-4 col-lg-3"> + <input type="checkbox" class="form-check-input" id="{{ module }}-check" disabled {%- if module in userModules %} checked {%- endif %}> + <label class="form-check-label" for="{{ module }}-check"> + <span class="align-middle">{{ module_info['displayName'] }}</span> + <a href="{{ module_info['href'] }}" target="_blank" class="text-muted">{{ svg.info_svg | safe }}</a> + </label> + </input> + </div> + {%- set ns.first = false -%} + {%- endfor -%} + </div> + {%- endfor -%} + {%- endif %} + </div> + </div> {#- tab content #} + </div> {#- flex div #} + </div> {#- accordion body #} + </div> {#- accordion collapse #} + </div> {#- accordion item #} + </div> {#- accordion #} + + <div class="card mt-4"> + <div class="card-header d-flex"> + <div class="flex-grow-1"> + <div class="progress" style="height: 20px;"> + <div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%;"></div> + </div> + <div id="progress-info-text" class="text-center text-muted my-auto w-100" style="font-size: smaller;">spawning...</div> + </div> + <button id="cancel" type="button" class="btn btn-danger ms-4" disabled>{{ svg.stop_svg | safe }} Cancel </button> + <button id="retry" type="button" class="btn btn-primary ms-4" style="display: none;" disabled>{{ svg.retry_svg | safe }} Retry </button> + </div> + <div class="card-body text-black"> + <div id="log"></div> + </div> + </div> + +</div> +{%- endblock -%} + +{%- block script -%} +{%- set cancel_progress_refresh_rate = 1000 -%} +{%- set cancel_progress_activation = 0 -%} +{%- set cancel_progress_deactivation = 99 -%} + +<script> +require(["jquery", "jhapi", "home/utils"], function ( + $, + JHAPI, + utils +) { + var base_url = window.jhdata.base_url; + var user = window.jhdata.user; + var api = new JHAPI(base_url); + var timeout; + + // Cancel server spawn on click + $("#cancel").click(function (event) { + $("#cancel").attr("disabled", true); + api.cancel_named_server(user, "{{spawner.name}}"); + clearTimeout(timeout); + }); + + // Retry server spawn on click + $("#retry").click(function (event) { + $("#retry").attr("disabled", true); + + api.start_named_server(user, "{{spawner.name}}", { + data: JSON.stringify({{spawner.user_options | safe}}), + success: function () { + $("#retry").hide(); + $("#cancel").attr("disabled", true).show(); + $("#progress-bar").removeClass("bg-danger"); + $("#progress-info-text").html("spawning..."); + $("#log").html(""); + updateStatus(); + }, + error: function (xhr, textStatus, errorThrown) { + $("#progress-bar").addClass("bg-danger"); + $("#progress-info-text").html("last spawn failed"); + let details = $("<details>") + .append($("<summary>").html(`Could not request spawn. Error: ${xhr.status} ${errorThrown}`)) + .append($("<pre>").html(JSON.stringify(JSON.parse(xhr.responseText), null, 2))); + let div = $("<div>").addClass("log-div").html(details); + $("#log").html("").append(div); + $("#retry").removeAttr("disabled"); + } + }) + }); + + function updateStatus() { + let id = "{{ spawner.name }}"; + let progressUrl = `${window.jhdata.base_url}api/users/${window.jhdata.user}/servers/${id}/progress?_xsrf=${window.jhdata.xsrf_token}`; + let evtSource = new EventSource(progressUrl); + evtSource.onmessage = (e) => { + const evt = JSON.parse(e.data); + if (evt.progress !== undefined && evt.progress != 0) { + if (evt.progress == 100) { + evtSource.close(); + delete evtSource; + + if (evt.failed) { + // Update UI via spawnStatusChangedEvtSource so that + // it happens after the stop has finished in the backend + clearTimeout(timeout); + } + else { + $(`#progress-bar`).removeClass("bg-danger").addClass("bg-success"); + $("#progress-info-text").html("redirecting..."); + window.location.reload(); + } + } + else { + $("#progress-bar").html('<b>' + evt.progress + '%</b>'); + if (evt.progress >= {{ cancel_progress_activation }}) { + $("#cancel").removeAttr("disabled"); + } + if (evt.progress == {{ cancel_progress_deactivation }}) { + $("#cancel").attr("disabled", true); + $(`#progress-bar`).addClass("bg-danger"); + $("#progress-info-text").html("cancelling..."); + } + if (evt.progress == 95) { + // Refresh if stuck on 95% + timeout = setTimeout(() => window.location.reload(), 120000); + } + } + } + + if (evt.html_message !== undefined) { + var htmlMsg = evt.html_message + } else if (evt.message !== undefined) { + var htmlMsg = evt.message; + } + if (htmlMsg) { + try { htmlMsg = htmlMsg.replace(/ /g, ' '); } + catch (e) { return; } + // Only append if a log message has not been appended yet + var exists = false; + $("#log").children().each(function (i, e) { + let logMsg = $(e).html(); + if (htmlMsg == logMsg) exists = true; + }) + if (!exists) + $("#log").append($('<div class="log-div">').html(htmlMsg)); + } + } + } + + $( document ).ready(function() { + updateStatus(); + + let userSpawnerNotificationUrl = `${jhdata.base_url}api/users/${jhdata.user}/notifications/spawners?_xsrf=${window.jhdata.xsrf_token}`; + evtSourcesGlobal["pending"] = new EventSource(userSpawnerNotificationUrl); + evtSourcesGlobal["pending"].onmessage = (e) => { + const data = JSON.parse(e.data); + utils.updateNumberOfUsers(); + for (const id of data.stopped || []) { + if (id == "{{spawner.name}}") { + $(`#progress-bar`).html("").addClass("bg-danger"); + $("#progress-info-text").html("last spawn failed"); + $("#retry").removeAttr("disabled").show(); + $("#cancel").hide(); + } + } + } + }); +}); +</script> +{%- endblock -%} diff --git a/templates/token.html b/templates/token.html index 736ab47..fcf01d1 100644 --- a/templates/token.html +++ b/templates/token.html @@ -1,3 +1,4 @@ + {%- extends "page.html" -%} diff --git a/templates/workshop.html b/templates/workshop.html new file mode 100644 index 0000000..7ca70f1 --- /dev/null +++ b/templates/workshop.html @@ -0,0 +1,44 @@ +{%- extends "page.html" -%} + +{%- block stylesheet -%} + <link rel="stylesheet" href='{{static_url("css/home.css")}}' type="text/css"/> +{%- endblock -%} + +{%- block main -%} +{%- import "macros/table/config/workshop.jinja" as config %} +{%- import "macros/svgs.jinja" as svg -%} +{%- import "macros/table/variables.jinja" as vars with context %} + +{%- set pagetype = vars.pagetype_workshop %} + +{%- set table_rows = db_workshops %} + +{%- from "macros/table/table.jinja" import tables with context %} +{%- import "macros/table/content.jinja" as functions with context %} + +<div id="workshopnotusable"></div> +{{ tables( + config.frontend_config, + functions.workshop_description, + functions.workshop_headerlayout, + false, + functions.workshop_firstheader, + functions.row_content, + { + "stop": "workshopButtonStop", + "start": "workshopButtonStart", + "open": "workshopButtonOpen", + "cancel": "workshopButtonCancel" + }, + functions.sse_functions +) }} + +{%- endblock -%} + + +{%- block script -%} +{%- import "macros/table/variables.jinja" as vars with context %} +{%- set pagetype = vars.pagetype_workshop %} +{%- import "macros/table/config/workshop.jinja" as config with context %} +{%- include "macros/table/elements_js.jinja" with context %} +{%- endblock %} diff --git a/templates/workshop_list.html b/templates/workshop_list.html new file mode 100644 index 0000000..a6946a9 --- /dev/null +++ b/templates/workshop_list.html @@ -0,0 +1,239 @@ +{%- extends "home.html" -%} +{%- import "macros/home.jinja" as home -%} +{%- import "macros/svgs.jinja" as svg -%} + + +{% block main %} + +{%- macro _create_button(workshop_id, key, btn_class, btn_svg, btn_text, type, align_right=false) %} + <button type="button" id="{{ workshop_id }}-{{ key }}-workshop-btn" class="btn btn-{{ key }}-workshop {{ btn_class }} {% if align_right -%} ms-auto {%- else -%} me-2 {%- endif %}">{{ btn_svg }} {{ btn_text }}</button> + <script> + {# + $(document).ready(function() { + #} + require(["jquery", "jhapi", "utils"], function ( + $, + JHAPI, + utils + ) { + "use strict"; + + var base_url = window.jhdata.base_url; + var api = new JHAPI(base_url); + + $("#{{ workshop_id }}-{{ key }}-workshop-btn").click(function() { + var options = { + } + {%- if key == "reset" %} + {{ fill_row(workshop_id, db_workshops[workshop_id]) }} + {%- elif key == "delete" %} + options["type"] = "DELETE"; + console.log("Delete of {{ workshop_id }} initiated"); + options["success"] = function () { + $("tr[data-server-id={{ workshop_id }}]").each(function () { + $(this).remove(); + }); + console.log("Delete of {{ workshop_id }} successful"); + }; + options["error"] = function (jqXHR, textStatus, errorThrown) { + + console.error("API Request failed:", textStatus, errorThrown); + }; + api.api_request( + utils.url_path_join("workshops", "{{ workshop_id }}"), + options + ) + {%- elif key in ["create", "save"] %} + var form = $("#{{ workshop_id }}-form"); + var valid = validateFormNow(form); + if ( !valid ) { + console.error("Form {{ workshop_id }} is not valid"); + return; + } + + options["type"] = "POST"; + var options_data = {}; + var keywords = {{ options | tojson }}; + var value_names; + var value_displayname; + var select_element; + var option_element; + Object.entries(keywords.select).forEach(function([key, value]) { + if ( $(`#{{ workshop_id }}-${key}-workshop-cb-input`).prop('checked') ) { + select_element = $(`#{{ workshop_id }}-${key}-workshop-select`); + options_data[key] = {}; + value_names = select_element.val(); + if (!Array.isArray(value_names)) { + value_names = [value_names]; + } + value_names.forEach(function(value_name) { + option_element = select_element.find(`option[value="${value_name}"]`); + value_displayname = option_element.html(); + options_data[key][value_name] = value_displayname; + }); + } + }); + keywords.input.forEach(function(key) { + if ( $(`#{{ workshop_id }}-${key}-workshop-cb-input`).prop('checked') ) { + options_data[key] = $(`#{{ workshop_id }}-${key}-workshop-input`).val(); + } + }); + var workshop_id = $("#{{ workshop_id }}-{{ keyname_id }}-workshop-input").val(); + options_data["{{ keyname_description }}"] = $("#{{ workshop_id }}-{{ keyname_description }}-workshop-input").val(); + options_data["{{ keyname_show_public }}"] = $("#{{ workshop_id }}-{{ keyname_show_public }}-workshop-cb-input").prop('checked'); + options_data["{{ keyname_enddate }}"] = $("#{{ workshop_id }}-{{ keyname_enddate }}-workshop-input").val(); + options["data"] = JSON.stringify(options_data); + options["success"] = function () { + form.submit(); + {%- if key == "create" %} + {%- endif %} + }; + options["error"] = function (jqXHR, textStatus, errorThrown) { + console.error("API Request failed:", textStatus, errorThrown); + }; + api.api_request( + utils.url_path_join("workshops", workshop_id), + options + ) + {%- endif %} + }); + }); + {# + }); + #} + </script> +{%- endmacro %} + +{%- macro create_buttons(workshop_id, new_workshop_row) %} + + <div class="d-flex"> + {%- if new_workshop_row %} + {{ _create_button(workshop_id, "create", "btn-primary", svg.plus_svg | safe, "Create") }} + {%- else %} + {{ create_modal(workshop_id) }} + {# Create JavaScript function for Share Button to update + show modal for this workshop_id #} + <script> + require(["jquery", "jhapi", "utils"], function ( + $, + JHAPI, + utils + ) { + "use strict"; + + var base_url = window.jhdata.base_url; + var api = new JHAPI(base_url); + {# Show Modal with workshop URL #} + {%- set sanitizedShareId = workshop_id.replace("-", "_") %} + function showShareDialogue{{ sanitizedShareId }}() { + $("#{{ workshop_id }}-share-workshop-copy-btn").click(function() { + const shareUrl = $("#{{ workshop_id }}-share-workshop-link .modal-body a").attr('href'); + navigator.clipboard.writeText(shareUrl).then(function() { + $("#{{ workshop_id }}-share-workshop-copy-btn").tooltip('dispose').attr('title', 'Copied'); + $("#{{ workshop_id }}-share-workshop-copy-btn").tooltip('show'); + }, function(err) { + console.error('Could not copy text: ', err); + }); + }); + + let workshop_url = utils.url_path_join(window.origin, base_url, "workshops", "{{ workshop_id }}").replace("//", "/"); + $("#{{ workshop_id }}-share-workshop-link .modal-title").text("Share Workshop {{ workshop_id }}"); + $("#{{ workshop_id }}-share-workshop-link .modal-body a").text(`${workshop_url}`); + + let shareableURL = new URL(workshop_url); + $("#{{ workshop_id }}-share-workshop-link .modal-body a").attr('href', shareableURL); + try { + shareableURL = new URL(workshop_url); + $("#{{ workshop_id }}-share-workshop-link .modal-body a").attr('href', shareableURL); + } catch (error) {console.log("no");} + + $("#{{ workshop_id }}-share-workshop-link").modal('show'); + } + $("#{{ workshop_id }}-share-workshop-btn").click(function (event) { + showShareDialogue{{ workshop_id }}(); + }); + }); + </script> + {# Share Button does not need the script logic from _create_button macro, therefore we create it in here #} + <button type="button" id="{{ workshop_id }}-share-workshop-btn" class="btn btn-share-workshop" data-toggle="modal" data-target="#{{ workshop_id }}-share-workshop-link">{{ svg.share_svg | safe }} Share </button> + + + {{ _create_button(workshop_id, "save", "btn-success", svg.save_svg | safe, "Save") }} + {{ _create_button(workshop_id, "reset", "btn-danger", svg.reset_svg | safe, "Reset") }} + {{ _create_button(workshop_id, "delete", "btn-danger", svg.delete_svg | safe, "Delete", align_right=true) }} + {%- endif %} + </div> +{%- endmacro %} + + + +<div class="container-fluid p-4"> + {#- TABLE #} + + <div class="table-responsive-md"> + <h2>Workshop Manager</h2> + <p>Select the options users might be able to use during your workshop.</p> + <p>Use shift or ctrl to select multiple items. <a style="color:#fff" href="https://jupyterjsc.pages.jsc.fz-juelich.de/docs/jupyterjsc/" target="_">Click here for more information.</a></p> + + <table id="jupyterlabs-table" class="table table-bordered table-striped table-hover table-light align-middle"> + {#- TABLE HEAD #} + <thead class="table-secondary"> + <tr> + <th scope="col" width="1%"></th> + <th scope="col" width="20%">Name</th> + <th scope="col">Configuration</th> + <th scope="col" class="text-center" width="10%">Link</th> + </tr> + </thead> + {#- TABLE BODY #} + + + + <tbody> + {# - List existing workshops #} + {%- for workshop_id, workshop_info in db_workshops.items() %} + + <!-- summary of row --> + <tr data-server-id="{{ workshop_id }}" class="summary-tr"> + <td class="details-td" data-bs-target="#{{ workshop_id }}-collapse"> + <div class="d-flex mx-4"> + </div> + </td> + + <th scope="row" class="name-td">{{ workshop_id }}</th> + <th scope="row" class="description-td">{{ workshop_info.user_options.description }}</th> + <th scope="row" class="url-td text-center"> + <button type="button" id="{{workshop_id}}-link-workshop-btn" class="btn btn-primary link-workshop-btn" data-toggle="modal" data-target="#{{ id }}-share-link" onclick="window.location.href='https://{{ hostname }}{{ base_url }}workshops/{{ workshop_id }}';">{{ svg.plus_svg | safe }} Join </button> + </th> + </tr> + {%- endfor %} + </tbody> + </table> + </div> {#- table responsive #} +</div> {#- container fluid #} + + +{%- endblock -%} + + +{%- block script -%} +<script> +require(["jquery", "jhapi", "utils"], function ( + $, + JHAPI, + utils +) { + "use strict"; + + var base_url = window.jhdata.base_url; + var api = new JHAPI(base_url); + + + /* + On page load + */ + $(document).ready(function() { + $("nav [id$=nav-item]").removeClass("active"); + }) +}) +</script> +{%- endblock %} \ No newline at end of file diff --git a/templates/workshop_manager.html b/templates/workshop_manager.html new file mode 100644 index 0000000..dd68441 --- /dev/null +++ b/templates/workshop_manager.html @@ -0,0 +1,38 @@ +{%- extends "page.html" -%} + +{%- block stylesheet -%} + <link rel="stylesheet" href='{{static_url("css/home.css")}}' type="text/css"/> +{%- endblock -%} + +{%- block main -%} +{%- import "macros/table/config/workshop_manager.jinja" as config %} +{%- import "macros/svgs.jinja" as svg %} +{%- import "macros/table/variables.jinja" as vars with context %} + +{%- set pagetype = vars.pagetype_workshopmanager %} + +{%- set table_rows = {vars.first_row_id: {}} %} +{%- set _ = table_rows.update(db_workshops) %} + + +{%- from "macros/table/table.jinja" import tables with context %} +{%- import "macros/table/content.jinja" as functions with context %} + +{{ tables( + config.frontend_config, + functions.workshopmanager_description, + functions.workshopmanager_headerlayout, + functions.workshopmanager_defaultheader, + functions.workshopmanager_firstheader, + functions.workshopmanager_row_content +) }} + +{%- endblock -%} + + +{%- block script -%} +{%- import "macros/table/variables.jinja" as vars with context %} +{%- set pagetype = vars.pagetype_workshopmanager %} +{%- import "macros/table/config/workshop_manager.jinja" as config with context %} +{%- include "macros/table/elements_js.jinja" with context %} +{%- endblock %} -- GitLab