diff --git a/computes/templates/overview.html b/computes/templates/overview.html index 3a5c4de0..f532dec7 100644 --- a/computes/templates/overview.html +++ b/computes/templates/overview.html @@ -108,7 +108,7 @@
{% trans "The last 10 readings, one every 5 seconds. Memory in use leaves out the host's buffers and cache." %} - {% trans "Waiting for the first reading." %}
@@ -194,61 +194,6 @@ - + {% endif %} {% endblock %} diff --git a/computes/tests/test_cpu_graph.py b/computes/tests/test_cpu_graph.py index 6e7a4657..5030df7d 100644 --- a/computes/tests/test_cpu_graph.py +++ b/computes/tests/test_cpu_graph.py @@ -143,12 +143,17 @@ class GraphScriptTestCase(TestCase): details.return_value.get_node_info.return_value = ("h", "x86_64", 1024, 2, "cpu", "qemu+tcp://h/system") details.return_value.get_cpu_topology.return_value = TOPOLOGY page = self.client.get(reverse("overview", args=[compute.id])).content.decode() - script = next(s for s in html.fromstring(page).xpath("//script[not(@src)]/text()") if "pollWhileVisible(" in s) + doc = html.fromstring(page) + self.assertIn("/static/js/host_graphs.js", doc.xpath("//script/@src")) + (state,) = doc.xpath("//span[@id='graph_state']") + dataset = {"url": state.get("data-url"), "averageOver": state.get("data-average-over"), + "updated": "Updated at %(time)s.", "stale": "Not updated since %(time)s."} + script = (settings.BASE_DIR / "static/js/host_graphs.js").read_text() js = """ const vm = require("vm"); const charts = [], requests = []; const element = (dataset) => ({dataset, textContent: "", classList: {add: () => {}, remove: () => {}}}); - const elements = {graph_state: element({updated: "Updated at %(time)s.", stale: "Not updated since %(time)s."}), + const elements = {graph_state: element(DATASET), cpu_now: element({}), mem_now: element({})}; let poll; const page = { @@ -180,7 +185,8 @@ class GraphScriptTestCase(TestCase): console.log(JSON.stringify({requests, label, shown, state: elements.graph_state.textContent, cpu: charts[0].data.datasets[0].data, mem: charts[1].data.datasets[0].data, memMax: charts[1].options.scales.y.max})); - """.replace("SCRIPT", json.dumps(script)).replace("CHARTS", json.dumps(CHARTS_JS.read_text())) + """.replace("SCRIPT", json.dumps(script)).replace("CHARTS", json.dumps(CHARTS_JS.read_text())).replace( + "DATASET", json.dumps(dataset)) out = subprocess.run(["node", "-e", js], capture_output=True, text=True, timeout=30) self.assertEqual(out.returncode, 0, out.stderr) result = json.loads(out.stdout) diff --git a/interfaces/templates/create_iface_block.html b/interfaces/templates/create_iface_block.html index 13402240..ac14e79b 100644 --- a/interfaces/templates/create_iface_block.html +++ b/interfaces/templates/create_iface_block.html @@ -151,18 +151,5 @@ - + {% endif %} diff --git a/networks/templates/create_net_block.html b/networks/templates/create_net_block.html index 9e45bbc2..3449e7ed 100644 --- a/networks/templates/create_net_block.html +++ b/networks/templates/create_net_block.html @@ -130,22 +130,5 @@ - + {% endif %} diff --git a/networks/templates/network.html b/networks/templates/network.html index 40a4f012..fa401f02 100644 --- a/networks/templates/network.html +++ b/networks/templates/network.html @@ -449,47 +449,5 @@ {% block script %} {% if fixed_addresses %}{% endif %} - + {% endblock %} diff --git a/nwfilters/templates/nwfilter.html b/nwfilters/templates/nwfilter.html index 40c5315a..5d696c66 100644 --- a/nwfilters/templates/nwfilter.html +++ b/nwfilters/templates/nwfilter.html @@ -187,24 +187,6 @@ {% block script %} {% if uuid %} - + {% endif %} {% endblock %} diff --git a/nwfilters/templates/nwfilters.html b/nwfilters/templates/nwfilters.html index e6ecaacf..df10c525 100644 --- a/nwfilters/templates/nwfilters.html +++ b/nwfilters/templates/nwfilters.html @@ -100,28 +100,7 @@ {% endblock %} {% block script %} -{% if nwfilters %} - - -{% endif %} +{% if nwfilters %}{% endif %} - + {% endblock %} diff --git a/static/js/host_graphs.js b/static/js/host_graphs.js new file mode 100644 index 00000000..c21690d4 --- /dev/null +++ b/static/js/host_graphs.js @@ -0,0 +1,54 @@ +// The host's CPU and memory charts (overview.html), polled every 5 s. +var state = document.getElementById("graph_state"); +// the seconds each CPU point averages +var cpuWindows = []; +var cpuChart = lineChart(document.getElementById("cpuChart"), "%", ["Usage"], { + max: 100, + tooltip: function (context, text) { + var span = cpuWindows[context.dataIndex]; + return span ? text + ' (' + state.dataset.averageOver + ' ' + span + ' s)' : text; + }, +}); +var memChart = lineChart(document.getElementById("memChart"), "GB", ["Usage"]); +var cpuNow = document.getElementById("cpu_now"); +var memNow = document.getElementById("mem_now"); +var lastUpdate = "–"; + +function gb(bytes) { + return Math.round(bytes / 107374182.4) / 10; +} + +// a failed reading leaves the charts as they were: say since when +function stale() { + state.textContent = state.dataset.stale.replace("%(time)s", lastUpdate); + state.classList.add("text-danger"); +} + +// the CPU sample of the previous answer: usage is taken since then +var cpuSample = null; +pollWhileVisible(5000, function () { + return $.getJSON(state.dataset.url, cpuSample ? {cpu_sample: cpuSample} : {}).fail(stale); +}, function (data) { + cpuSample = data.cpu_sample || null; + cpuWindows.push(data.window); + if (cpuWindows.length > CHART_POINTS) { + cpuWindows.shift(); + } + addPoint(cpuChart, data.timeline, [data.cpudata]); + // a host that could not be read gives no values: a gap, same axis + if (data.memdata.total) { + memChart.options.scales.y.max = gb(data.memdata.total); + addPoint(memChart, data.timeline, [gb(data.memdata.usage)]); + memNow.textContent = gb(data.memdata.usage) + " / " + gb(data.memdata.total) + " GB"; + } else { + addPoint(memChart, data.timeline, [null]); + } + if (data.cpudata == null) { + stale(); + return; + } + cpuNow.textContent = data.cpudata + " %"; + lastUpdate = data.timeline; + state.textContent = state.dataset.updated.replace("%(time)s", data.timeline); + state.classList.remove("text-danger"); +}, 0); diff --git a/static/js/interface_dialog.js b/static/js/interface_dialog.js new file mode 100644 index 00000000..e765f59d --- /dev/null +++ b/static/js/interface_dialog.js @@ -0,0 +1,13 @@ +// The New interface dialog (create_iface_block.html). +// a static address's fields show with its choice (after the page's +// scripts, Bootstrap's among them) +document.addEventListener('DOMContentLoaded', function () { + var form = document.getElementById('interface-form'); + form.querySelectorAll('select[data-static]').forEach(function (method) { + var fields = form.querySelector(method.dataset.static); + function show() { fields.hidden = fields.disabled = method.value !== 'static'; } + method.addEventListener('change', show); + show(); + }); + choiceDialog(document.getElementById('AddInterface'), 'itype', function () {}); +}); diff --git a/static/js/network_dialog.js b/static/js/network_dialog.js new file mode 100644 index 00000000..0da8fce4 --- /dev/null +++ b/static/js/network_dialog.js @@ -0,0 +1,17 @@ +// The New network dialog (create_net_block.html). +// the IPv6 fields show with their box; a NAT or routed network needs a +// subnet (after the page's scripts, Bootstrap's among them) +document.addEventListener('DOMContentLoaded', function () { + var ipv6 = document.getElementById('net_ipv6'); + function showIpv6() { + var fields = document.getElementById('net_ipv6_fields'); + fields.hidden = !ipv6.checked; + fields.querySelectorAll('input').forEach(function (input) { input.disabled = !ipv6.checked; }); + document.getElementById('net_subnet6').required = ipv6.checked; + } + ipv6.addEventListener('change', showIpv6); + choiceDialog(document.getElementById('AddNetPool'), 'forward', function (mode) { + document.getElementById('net_subnet').required = mode === 'nat' || mode === 'route'; + showIpv6(); + }); +}); diff --git a/static/js/network_page.js b/static/js/network_page.js new file mode 100644 index 00000000..62da9606 --- /dev/null +++ b/static/js/network_page.js @@ -0,0 +1,42 @@ +// A network's page (network.html): its XML editor and the dialogs its rows open. +var editor = ace.edit("edit_editor"); +editor.getSession().setMode("ace/mode/xml"); +var editInput = $('input[name="edit_xml"]'); +editor.getSession().on("change", function () { + editInput.val(editor.getSession().getValue()); +}); +// the editor measures itself when its section is shown +document.getElementById("network-xml").addEventListener("shown.bs.collapse", () => editor.resize()); + +// a dialog opened by a row's button gets that row's values, one opened by an Add button starts empty +function fillFrom(dialogId, fill) { + var dialog = document.getElementById(dialogId); + if (dialog) dialog.addEventListener("show.bs.modal", function (event) { + fill(dialog.querySelector("form"), event.relatedTarget.dataset); + }); +} +fillFrom("FixedAddress", function (form, data) { + form.elements.original.value = data.original || ""; + form.elements.address.value = data.address || ""; + form.elements.mac.value = data.mac || ""; + form.elements.name.value = data.name || ""; + // Add starts from the first subnet, not the one a row chose before + form.elements.ip_index.value = data.ipIndex || form.elements.ip_index.options[0].value; + document.getElementById("fixed_subnet_row").hidden = Boolean(data.original); +}); +fillFrom("DhcpRange", function (form, data) { + form.elements.ip_index.value = data.ipIndex; + form.elements.range_start.value = data.start; + form.elements.range_end.value = data.end; + document.getElementById("dhcp_subnet").textContent = data.subnet; +}); +fillFrom("QosLimit", function (form, data) { + var direction = form.elements.qos_direction; + direction.querySelectorAll("option").forEach(function (option) { + option.disabled = data.direction ? option.value !== data.direction : option.hasAttribute("data-taken"); + }); + direction.value = data.direction || direction.querySelector("option:not(:disabled)").value; + form.elements.qos_average.value = data.average || ""; + form.elements.qos_peak.value = data.peak || ""; + form.elements.qos_burst.value = data.burst || ""; +}); diff --git a/static/js/nwfilter_page.js b/static/js/nwfilter_page.js new file mode 100644 index 00000000..b195d5ef --- /dev/null +++ b/static/js/nwfilter_page.js @@ -0,0 +1,18 @@ +// A network filter's page (nwfilter.html). +// each editor's text goes in its form's hidden field, the starter too if sent as it is, +// and it measures itself when its section or dialog is shown +function xmlEditor(textareaId, fieldName, shownBy, shownEvent) { + var textarea = document.getElementById(textareaId); + var form = textarea.closest("form"); + var editor = ace.edit(textareaId); + editor.getSession().setMode("ace/mode/xml"); + editor.setOption("textInputAriaLabel", textarea.getAttribute("aria-label")); + var field = form.elements[fieldName]; + field.value = editor.getSession().getValue(); + editor.getSession().on("change", function () { + field.value = editor.getSession().getValue(); + }); + document.getElementById(shownBy).addEventListener(shownEvent, () => editor.resize()); +} +xmlEditor("edit_editor", "edit_xml", "nwfilter-xml", "shown.bs.collapse"); +xmlEditor("rule_editor", "nwfilterrule_xml", "AddNWFilterRule", "shown.bs.modal"); diff --git a/static/js/nwfilters_page.js b/static/js/nwfilters_page.js new file mode 100644 index 00000000..05845fb2 --- /dev/null +++ b/static/js/nwfilters_page.js @@ -0,0 +1,23 @@ +// The network filters page (nwfilters.html): the Clone dialog takes the +// filter of its row, and the New filter dialog's XML editor fills its form. +var cloneDialog = document.getElementById("CloneNWFilter"); +if (cloneDialog) { + cloneDialog.addEventListener("show.bs.modal", function (event) { + var name = event.relatedTarget.dataset.name; + this.querySelector("form").elements.nwfiltername.value = name; + document.getElementById("clone_source").textContent = name; + document.getElementById("clone_name").value = name + "-copy"; + }); +} + +var textarea = document.getElementById("create_editor"); +var editor = ace.edit("create_editor"); +editor.getSession().setMode("ace/mode/xml"); +editor.setOption("textInputAriaLabel", textarea.getAttribute("aria-label")); +var input = $('input[name="nwfilter_xml"]'); +input.val(editor.getSession().getValue()); // the starter too, if sent as it is +editor.getSession().on("change", function () { + input.val(editor.getSession().getValue()); +}); +// the editor measures itself when its dialog is shown +document.getElementById("AddNWFilter").addEventListener("shown.bs.modal", () => editor.resize()); diff --git a/static/js/pool_dialog.js b/static/js/pool_dialog.js new file mode 100644 index 00000000..6b519d5d --- /dev/null +++ b/static/js/pool_dialog.js @@ -0,0 +1,31 @@ +// The New pool dialog (create_stg_block.html). +// the ISO pool is a directory named iso; a path follows the pool's name +// until it is edited (after the page's scripts, Bootstrap's among them) +document.addEventListener('DOMContentLoaded', function () { + var form = document.getElementById('pool-form'); + var name = form.elements.namedItem('name'); + var otherName = name.value; + var hasCephSecrets = "cephSecrets" in form.dataset; + form.querySelectorAll('[data-follow-name]').forEach(function (path) { + path.dataset.derived = name.value ? path.dataset.followName + name.value : ''; + }); + name.addEventListener('input', function () { + form.querySelectorAll('[data-follow-name]').forEach(function (path) { + var derived = name.value ? path.dataset.followName + name.value : ''; + if (!path.value || path.value === path.dataset.derived) path.value = derived; + path.dataset.derived = derived; + }); + }); + // a new device or name: the erasing is agreed to again + form.querySelectorAll('#pool_lvm_source, #pool_name').forEach(function (field) { + field.addEventListener('input', function () { document.getElementById('pool_lvm_erase').checked = false; }); + }); + choiceDialog(document.getElementById('AddStgPool'), 'pool_kind', function (kind) { + var iso = kind === 'iso'; + if (!name.readOnly) otherName = name.value; + name.value = iso ? 'iso' : otherName; + name.readOnly = iso; + form.elements.namedItem('stg_type').value = iso ? 'dir' : kind; + form.querySelector('button[name=create]').disabled = kind === 'rbd' && !hasCephSecrets; + }); +}); diff --git a/static/js/secrets_page.js b/static/js/secrets_page.js new file mode 100644 index 00000000..5ad86a2e --- /dev/null +++ b/static/js/secrets_page.js @@ -0,0 +1,34 @@ +// The secrets page (secrets.html): the labels of a secret's usage and +// value follow its usage type (usage_texts from the view). +var usageTexts = JSON.parse(document.getElementById("usage-texts").textContent); + +var usageType = document.getElementById("secret_usage_type"); +if (usageType) { + var showUsage = function () { + var texts = usageTexts[usageType.value]; + document.getElementById("secret_usage_label").textContent = texts.usage; + document.getElementById("secret_usage_help").textContent = texts.usageHelp; + document.getElementById("secret_value_label").textContent = texts.value; + document.getElementById("secret_value_help").textContent = texts.valueHelp + " " + usageTexts.later; + }; + usageType.addEventListener("change", showUsage); + showUsage(); +} + +var setValue = document.getElementById("SetSecretValue"); +if (setValue) { + setValue.addEventListener("show.bs.modal", function (event) { + var row = event.relatedTarget.dataset; + var texts = usageTexts[row.type] || usageTexts.none; + var form = this.querySelector("form"); + form.reset(); // no value of another secret stays in the field + form.elements.uuid.value = row.uuid; + document.getElementById("value_usage").textContent = row.usage; + document.getElementById("set_value_label").textContent = texts.value; + document.getElementById("set_value_help").textContent = texts.valueHelp; + // a passphrase's data stays encrypted with the old one + var passphrase = ["volume", "tls", "vtpm"].includes(row.type); + document.getElementById("set_value_note").hidden = !passphrase; + form.elements.value.setAttribute("aria-describedby", passphrase ? "set_value_help set_value_note" : "set_value_help"); + }); +} diff --git a/static/js/storage_page.js b/static/js/storage_page.js new file mode 100644 index 00000000..c741fc1a --- /dev/null +++ b/static/js/storage_page.js @@ -0,0 +1,151 @@ +// A storage pool's page (storage.html): the volume Clone dialog, a refused +// new volume's dialog, and the ISO upload in chunks. + +// the clone dialog: filled for the volume whose button opened it, its +// choices reset; the format rows show while converting +function showCloneRows() { + var convert = $('#clone-convert').prop('checked'); + $('#clone-format-row').toggleClass('d-none', !convert); + $('#clone-prealloc-row').toggleClass('d-none', !(convert && $('#clone-format').val() == 'qcow2')); +} +$('#CloneVolume').on('show.bs.modal', function (event) { + var volume = event.relatedTarget.dataset.volume; + $('#clone-image').val(volume); + $('#clone-image-name').text(volume); + $('#clone-name').val(''); + $('#clone-convert, #clone-prealloc').prop('checked', false); + $('#clone-format').val('raw'); + showCloneRows(); +}); +$('#clone-convert, #clone-format').on('change', showCloneRows); + +// a refused new volume: its dialog opens again on what was entered, at the refused field +var addImage = document.getElementById('AddImage'); +if (addImage && addImage.hasAttribute('data-open')) { + addImage.addEventListener('shown.bs.modal', focusFirstError, {once: true}); + bootstrap.Modal.getOrCreateInstance(addImage).show(); +} + +$(document).ready(function() { + var pendingUpload = null; + var uploadInProgress = false; + // the browser sends the file: leaving the page stops the upload + window.addEventListener('beforeunload', function(e) { + if (uploadInProgress) { e.preventDefault(); e.returnValue = ''; } + }); + $('#isoUploadForm').on('submit', function(e) { + e.preventDefault(); + // the form's messages and token; it is sent to this page + var form = this; + var texts = form.dataset; + if (uploadInProgress) return; + + var fileInput = $('#id_file')[0]; + var errorMessage = $('#upload-error-message'); + if (fileInput.files.length === 0) { + errorMessage.text(texts.noFile).removeClass("d-none"); + return; + } + + var file = fileInput.files[0]; + var fileName = file.name; + if (!/\.iso$/i.test(fileName) || fileName.length <= 4) { + errorMessage.text(texts.notIso).removeClass("d-none"); + return; + } + if (file.size === 0) { + errorMessage.text(texts.empty).removeClass("d-none"); + return; + } + var chunkSize = 32 * 1024 * 1024; // Fewer SSH connections per upload + var totalChunks = Math.ceil(file.size / chunkSize); + var fileSignature = [fileName, file.size, file.lastModified].join(':'); + if (!pendingUpload || pendingUpload.signature !== fileSignature) { + pendingUpload = { + signature: fileSignature, + chunkIndex: 0, + uploadId: (window.crypto && window.crypto.randomUUID) ? window.crypto.randomUUID() : (Date.now() + '-' + Math.random().toString(36).substring(2, 10)) + }; + } + var chunkIndex = pendingUpload.chunkIndex; + var uploadId = pendingUpload.uploadId; + var retryCount = 0; + uploadInProgress = true; + + var uploadDialog = bootstrap.Modal.getOrCreateInstance(document.getElementById('IsoUpload')); + var uploadStatus = $('#upload-status'); + var uploadStatusText = $('#upload-status-text'); + var uploadButton = $('#isoUploadForm button[type="submit"]'); + + errorMessage.addClass("d-none"); + uploadStatusText.text(texts.starting); + uploadStatus.removeClass('d-none').addClass('d-flex'); + uploadButton.prop('disabled', true); + + function uploadChunk() { + if (chunkIndex >= totalChunks) { + return; + } + + var start = chunkIndex * chunkSize; + var end = Math.min(start + chunkSize, file.size); + var chunk = file.slice(start, end); + + var formData = new FormData(); + formData.append('file', chunk, fileName); + formData.append('file_name', fileName); + formData.append('chunk_index', chunkIndex); + formData.append('total_chunks', totalChunks); + formData.append('file_size', file.size); + formData.append('upload_id', uploadId); + formData.append('iso_upload', 'true'); + formData.append('csrfmiddlewaretoken', form.elements.csrfmiddlewaretoken.value); + + $.ajax({ + url: form.action, + type: 'POST', + data: formData, + processData: false, + contentType: false, + success: function(data) { + if (chunkIndex === 0) { + // the job panel follows the upload from here + window.resourceJobPanel.track(data.job); + uploadStatusText.text(texts.uploading); + uploadDialog.hide(); + } + retryCount = 0; + chunkIndex++; + pendingUpload.chunkIndex = chunkIndex; + + if (chunkIndex < totalChunks) { + uploadChunk(); + } else { + pendingUpload = null; + uploadInProgress = false; + uploadStatus.removeClass('d-flex').addClass('d-none'); + uploadButton.prop('disabled', false); + } + }, + error: function(jqXHR, textStatus, errorThrown) { + if ((jqXHR.status === 0 || jqXHR.status === 429 || jqXHR.status >= 500) && retryCount < 3) { + retryCount++; + uploadStatusText.text(texts.retrying); + setTimeout(uploadChunk, 1000 * Math.pow(2, retryCount - 1)); + return; + } + uploadInProgress = false; + uploadStatus.removeClass('d-flex').addClass('d-none'); + uploadButton.prop('disabled', false); + if (jqXHR.status >= 400 && jqXHR.status < 500 && jqXHR.status !== 429) { + pendingUpload = null; + } + var errorText = jqXHR.responseJSON && jqXHR.responseJSON.error ? jqXHR.responseJSON.error : texts.failed; + errorMessage.text(errorText).removeClass("d-none"); + uploadDialog.show(); + } + }); + } + uploadChunk(); + }); +}); diff --git a/storages/templates/create_stg_block.html b/storages/templates/create_stg_block.html index 372cb7da..fa8d65c3 100644 --- a/storages/templates/create_stg_block.html +++ b/storages/templates/create_stg_block.html @@ -11,7 +11,7 @@