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 @@