From 561937b31ae77890c5b97751da3be44f2bdda3f4 Mon Sep 17 00:00:00 2001 From: catborise Date: Mon, 2 Nov 2026 09:04:29 +0300 Subject: [PATCH] refactor(instances): the VM page's scripts load from static files The VM page's thirteen inline script blocks and its inline handlers move to static files: instance_page.js (tabs, OS details, clone names and MACs, Add disk dialog, boot order, host devices, migrate plan, console window, switches that send their form), instance_stats.js (charts and the Logs view) and instance_status.js (the reload when the VM's state changes). Their values and messages come as data attributes of the elements they belong to, page-wide ones on #vm-summary; a form marked data-please-wait shows the please-wait dialog. Three blocks that did nothing are dropped: popovers (the page has none), the video model preselection (the server selects it) and the delete form's poll stop (that form is on the destroy page). The HTML sink inventory now also reads the app's own scripts. --- .../templates/add_instance_network_block.html | 2 +- instances/templates/add_instance_volume.html | 10 +- instances/templates/instance.html | 378 +----------------- instances/templates/instances/access_tab.html | 3 +- instances/templates/instances/info_tab.html | 2 +- instances/templates/instances/power_tab.html | 2 +- .../templates/instances/settings_tab.html | 37 +- .../templates/instances/snapshots_tab.html | 2 +- instances/templates/instances/stats_tab.html | 43 +- instances/tests/test_page_controls.py | 6 +- instances/tests/test_pending_edits.py | 2 +- instances/tests/test_stats_sample.py | 25 +- instances/tests/test_xss.py | 27 +- static/js/instance_page.js | 280 +++++++++++++ static/js/instance_stats.js | 115 ++++++ static/js/instance_status.js | 13 + static/js/please_wait.js | 4 + webvirtcloud/tests/test_poll_js.py | 16 +- 18 files changed, 507 insertions(+), 460 deletions(-) create mode 100644 static/js/instance_page.js create mode 100644 static/js/instance_stats.js create mode 100644 static/js/instance_status.js 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: () => {}});