diff --git a/instances/templates/add_instance_network_block.html b/instances/templates/add_instance_network_block.html index 7de7b351..710b5284 100644 --- a/instances/templates/add_instance_network_block.html +++ b/instances/templates/add_instance_network_block.html @@ -46,7 +46,7 @@
- +
{% trans "A random address is filled in; empty: libvirt chooses one." %}
diff --git a/instances/templates/add_instance_volume.html b/instances/templates/add_instance_volume.html index 2a646185..4f8be1ab 100644 --- a/instances/templates/add_instance_volume.html +++ b/instances/templates/add_instance_volume.html @@ -69,7 +69,7 @@
+

{% trans "Start this VM when the host starts." %}

@@ -107,7 +107,7 @@
{% csrf_token %}
- +

{% trans "Show a device chooser when the VM starts." %}

@@ -314,7 +314,7 @@
- +
@@ -437,7 +437,7 @@

{% trans "Devices of the host given to the VM. The VM starts only on a host that has them; it is not migrated while it has them, and its clones get none." %}

{% if request.user.is_superuser %} {# the add forms come when the tab opens: their choices scan every VM of the host #} -
+
{% trans "Reading the host's devices…" %}
{% endif %} @@ -552,7 +552,10 @@

{% bs_icon 'device-hdd' %} {% trans "Storage" %}

-
+
@@ -765,13 +768,15 @@
- +
- +
-

+

{% elif app_settings.CLONE_INSTANCE_AUTO_NAME == 'True' %} @@ -795,12 +800,12 @@

eth{{ forloop.counter0 }} · {{ network.nic }}

- +
- + {% if forloop.first %} {# the DHCP host entry of the clone's name holds one address: the first NIC's #} - + {% endif %}
@@ -817,7 +822,7 @@

{{ disk.dev }} · {% blocktrans with pool=disk.storage %}Pool: {{ pool }}{% endblocktrans %} · {{ disk.format }}

- + {# a running or paused VM's disks are copied by a backup job, which does not preallocate #} {% if disk.format == 'qcow2' and instance.status == 5 %}
@@ -906,7 +911,7 @@
+ {% if instance.status != 5 or not request.user.is_superuser and not request.user.is_staff %}disabled{% endif %} data-submit-on-change>

@@ -948,7 +953,7 @@

+ {% if instance.config_rng %}checked{% endif %}{% if instance.config_rng == "custom" %} disabled{% endif %} data-submit-on-change>
{% if instance.config_rng == "custom" %} @@ -966,7 +971,7 @@
+ {% if instance.config_tpm %}checked{% endif %}{% if instance.config_tpm == "custom" or not instance.config_tpm and not tpm_support %} disabled{% endif %} data-submit-on-change>
{% if instance.config_tpm == "custom" %} diff --git a/instances/templates/instances/snapshots_tab.html b/instances/templates/instances/snapshots_tab.html index 38096858..a9e1771a 100755 --- a/instances/templates/instances/snapshots_tab.html +++ b/instances/templates/instances/snapshots_tab.html @@ -30,7 +30,7 @@
+ >
diff --git a/instances/templates/instances/stats_tab.html b/instances/templates/instances/stats_tab.html index 12f3c68e..809e1325 100644 --- a/instances/templates/instances/stats_tab.html +++ b/instances/templates/instances/stats_tab.html @@ -1,5 +1,7 @@ {% load i18n %} -
+
@@ -41,14 +43,14 @@

{% trans "Network" %} · {{ net.mac }}

 

-
+
{% endfor %} {% for disk in instance.disks %}

{% trans "Disk" %} · {{ disk.dev }}

 

-
+
{% endfor %}
@@ -57,7 +59,8 @@ {% if request.user.is_superuser %}
- +
@@ -79,35 +82,3 @@ -{% if request.user.is_superuser %} - -{% endif %} diff --git a/instances/tests/test_page_controls.py b/instances/tests/test_page_controls.py index 760f3899..afcc8650 100644 --- a/instances/tests/test_page_controls.py +++ b/instances/tests/test_page_controls.py @@ -219,7 +219,7 @@ class PageControlsTestCase(PageControls, TestCase): if user == owner: self.assertIn("The VM is paused. An administrator can resume it.", tab.text_content()) if status == 5: - self.assertEqual(tab.xpath(".//form/@onsubmit"), ["showPleaseWaitDialog()"]) + self.assertEqual(tab.xpath(".//form/@data-please-wait"), [""]) def test_a_running_paused_or_shut_off_vm_can_be_cloned(self): changer = self.user("vm_cloner", perms=("clone_instances",), owner={"is_change": True}) @@ -271,8 +271,8 @@ class TemplatePageControlsTestCase(PageControls, TestCase): doc = lxml_html.fromstring(self.last_page) self.assertEqual(doc.xpath("string(//section[@aria-labelledby='vm-name']//span[@class='badge text-bg-light border'])"), "Template") - self.assertTrue(doc.xpath("//button[contains(@onclick, \"show_tab('#clone')\")]")) - self.assertIn('location.hash || "#clone"', self.last_page) + self.assertTrue(doc.xpath("//button[@id='clone-button']")) + self.assertEqual(doc.xpath("string(//section[@id='vm-summary']/@data-opens-on)"), "#clone") for pane in ("power", "access", "graphics", "vncsettings", "hostdevs"): self.assertEqual(doc.xpath("//*[@id=$p]", p=pane), [], pane) diff --git a/instances/tests/test_pending_edits.py b/instances/tests/test_pending_edits.py index 245e63fa..d1347fd7 100644 --- a/instances/tests/test_pending_edits.py +++ b/instances/tests/test_pending_edits.py @@ -181,7 +181,7 @@ class PendingEditsTestCase(TestCase): self.assertEqual(form.xpath(".//input[@type='text']/@name"), ["name", "clone-net-mac-0", "clone-net-mac-1", "disk-vda", "disk-vdb", "clone-title"]) # the DHCP entry of the clone's name holds one address: only the first NIC guesses it - self.assertEqual(len(form.xpath(".//button[contains(@onclick, 'guess_mac_address')]")), 1) + self.assertEqual(len(form.xpath(".//button[@id='guess-clone-mac']")), 1) # a running or paused VM is copied by a backup, which does not preallocate self.assertEqual(form.xpath(".//input[@type='checkbox']/@name"), [*metadata, "clone-start"]) diff --git a/instances/tests/test_stats_sample.py b/instances/tests/test_stats_sample.py index 1c427ad7..c01715f7 100644 --- a/instances/tests/test_stats_sample.py +++ b/instances/tests/test_stats_sample.py @@ -21,6 +21,7 @@ from vrtManager.instance import USAGE_MAX_WINDOW # the page script runs after the shared chart helpers CHARTS_JS = settings.BASE_DIR / "static" / "js" / "charts.js" +STATS_JS = settings.BASE_DIR / "static" / "js" / "instance_stats.js" SAMPLE = {"key": [7, 4, ["vda"], [["52:54:00:00:00:01", "vnet0"]]], "counters": [1, [[2, 3]], [[4, 5]], 100.0]} @@ -144,7 +145,15 @@ class StatsScriptTestCase(TestDriverVM, TestCase): return response.content.decode() def test_the_next_poll_sends_the_previous_sample(self): - script = next(s for s in html.fromstring(self.page()).xpath("//script[not(@src)]/text()") if "statsSample" in s) + doc = html.fromstring(self.page()) + self.assertTrue(doc.xpath("//script[@src=$s]", s=settings.STATIC_URL + "js/instance_stats.js")) + (pane,) = doc.xpath("//div[@id='graphics']") + dataset = {"url": pane.get("data-url"), "averageOver": pane.get("data-average-over"), + "read": pane.get("data-read"), "write": pane.get("data-write"), + "in": pane.get("data-in"), "out": pane.get("data-out")} + canvases = [{"dataset": {"disk": c.get("data-disk")}} for c in doc.xpath("//canvas[@data-disk]")] + canvases += [{"dataset": {"mac": c.get("data-mac")}} for c in doc.xpath("//canvas[@data-mac]")] + script = STATS_JS.read_text() js = """ const vm = require("vm"); const charts = [], requests = [], handlers = {}; @@ -155,9 +164,18 @@ class StatsScriptTestCase(TestDriverVM, TestCase): requests.push(params); return {fail: (callback) => { if (fail) callback(); return {}; }}; }; + // the Stats pane with its data and charts; no Logs view; any other element + function getElementById(id) { + if (id === "graphics") { + return {dataset: DATASET, + querySelectorAll: (selector) => CANVASES.filter((c) => selector.includes(Object.keys(c.dataset)[0]))}; + } + if (id === "logs-tab") return null; + return {}; + } const page = { $, - document: {body: {}, getElementById: () => ({}), + document: {body: {}, getElementById, querySelector: () => ({addEventListener: (name, handler) => { handlers[name] = handler; }})}, // Chart.js adds the lists a config leaves out getComputedStyle: () => ({getPropertyValue: () => ""}), @@ -194,7 +212,8 @@ class StatsScriptTestCase(TestDriverVM, TestCase): console.log(JSON.stringify({requests, label, every: [poll.interval, poll.first], cpu: charts[0].data.datasets[0].data, disk: charts[2].data.datasets[1].data, net: charts[3].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)).replace("CANVASES", json.dumps(canvases)) 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/instances/tests/test_xss.py b/instances/tests/test_xss.py index 67742b9b..9c4fc990 100644 --- a/instances/tests/test_xss.py +++ b/instances/tests/test_xss.py @@ -110,13 +110,13 @@ class TemplateHtmlConcatenationTestCase(TestCase): return (ROOT / relative).read_text() def test_logs_table_does_not_concatenate_log_fields_into_html(self): - src = self._read("instances/templates/instances/stats_tab.html") + src = self._read("static/js/instance_stats.js") for field in ("date", "user", "message"): self.assertNotIn(f"+row['{field}']+", src) - self.assertNotIn('$("#logs_table > tbody").html(', src) + self.assertNotIn(".html(", src) def test_volume_dropdown_does_not_concatenate_volume_names_into_html(self): - src = self._read("instances/templates/instance.html") + src = self._read("static/js/instance_page.js") self.assertNotIn("'").append($("") + .append($("
{% trans "Date" %}
").text(text))); + } + $.getJSON(table.dataset.url, function (data) { + if (!data.length) { + note(table.dataset.empty); + return; + } + // Log messages contain user-entered names (snapshots, disks, clones); + // insert them as text, never as HTML. + tbody.empty(); + $.each(data, function (id, row) { + $("
").addClass("text-nowrap").text(row['date'])) + .append($("").text(row['user'])) + .append($("").text(row['message'])) + .append($("").text(row['result'])) + .appendTo(tbody); + }); + }).fail(function () { + note(table.dataset.failed); + }); + }); +})(); diff --git a/static/js/instance_status.js b/static/js/instance_status.js new file mode 100644 index 00000000..b2cf27e9 --- /dev/null +++ b/static/js/instance_status.js @@ -0,0 +1,13 @@ +// The VM page reloads when the VM's state changes (instance.html, #vm-summary). +backgroundJobRunning = false; +// not "status": that is window.status, which the browser may not keep +var vm_summary = document.getElementById("vm-summary"); +var vm_status = Number(vm_summary.dataset.status); +var status_poll = pollWhileVisible(5000, function () { + return $.getJSON(vm_summary.dataset.statusUrl); +}, function get_status(data) { + // an XML edit not saved yet stays: the reload waits until it is saved or undone + if (data['status'] != vm_status && !backgroundJobRunning && !(window.xmlEditUnsaved && window.xmlEditUnsaved())) { + window.location.reload(); + } +}); diff --git a/static/js/please_wait.js b/static/js/please_wait.js index d19a563f..39aabe6b 100644 --- a/static/js/please_wait.js +++ b/static/js/please_wait.js @@ -13,3 +13,7 @@ function hidePleaseWaitDialog() { pleaseModal.hide(); backgroundJobRunning = false; } +// a form marked data-please-wait shows the dialog while it is sent +document.addEventListener("submit", function (event) { + if (event.target.matches("form[data-please-wait]")) showPleaseWaitDialog(); +}); diff --git a/webvirtcloud/tests/test_poll_js.py b/webvirtcloud/tests/test_poll_js.py index c6791033..2bc212d9 100644 --- a/webvirtcloud/tests/test_poll_js.py +++ b/webvirtcloud/tests/test_poll_js.py @@ -2,7 +2,6 @@ none while the page is hidden. Its behaviour is checked with Node.""" import json -import re import shutil import subprocess import unittest @@ -11,7 +10,7 @@ from pathlib import Path from django.conf import settings BASE = Path(settings.BASE_DIR) -POLLING_TEMPLATES = ["instances/templates/instance.html", "computes/templates/overview.html"] +POLLING_SOURCES = ["static/js/instance_status.js", "static/js/instance_stats.js", "computes/templates/overview.html"] class PollTestCase(unittest.TestCase): @@ -37,9 +36,9 @@ class PollTestCase(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr) def test_pages_poll_through_poll_js(self): - for template in POLLING_TEMPLATES: - with self.subTest(template=template): - source = (BASE / template).read_text() + for path in POLLING_SOURCES: + with self.subTest(path=path): + source = (BASE / path).read_text() self.assertNotIn("setInterval", source) self.assertIn("pollWhileVisible(", source) @@ -47,11 +46,7 @@ class PollTestCase(unittest.TestCase): def test_the_vm_page_reloads_only_when_the_state_changes(self): # "status" is a property of the browser's window: a page variable of # that name holds what the browser makes of it (a string, or nothing) - source = (BASE / "instances/templates/instance.html").read_text() - script = re.search(r"", source, re.S).group(1) - script = script.replace("{{ instance.status|lower }}", "1").replace( - "{% url 'instances:status' instance.id %}", "/instances/1/status/" - ) + script = (BASE / "static/js/instance_status.js").read_text() js = """ const vm = require("vm"); const results = {}; @@ -60,6 +55,7 @@ class PollTestCase(unittest.TestCase): const page = { pollWhileVisible: (interval, request, onData) => { onData({status: answer}); return {stop() {}}; }, $: () => ({submit() {}}), + document: {getElementById: () => ({dataset: {status: "1", statusUrl: "/instances/1/status/"}})}, }; page.window = {location: {reload: () => reloads++}}; Object.defineProperty(page, "status", {get: () => "", set: () => {}});