1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00

fix(instances): a dependent list on the create pages shows only the latest choice

The machine types, volumes, templates and disk buses load when their
parent changes. A slow answer for an earlier choice could arrive last and
fill the list for the newer one, and while a list loaded it still held
the previous choice's items, which could be submitted.

Each list now empties when its load starts, and a newer load aborts the
older request, so only the answer for the current choice fills it. Chosen
volumes stay: they are found by name in any pool.

Fixes #177

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
catborise 2026-10-24 12:07:23 +03:00
parent 4f0ae3b30d
commit 49cf16ecbe
4 changed files with 56 additions and 10 deletions

View file

@ -70,12 +70,15 @@
{% endblock %}
{% block script %}
<script>
// the machine types of the chosen architecture, the default one selected
// the machine types of the chosen architecture, the default one selected;
// the list is empty while it loads, and a newer load aborts the older one
function load_machines(arch) {
const next = document.querySelector("#next");
const next = document.querySelector("#next"), list = document.querySelector("#select_chipset");
next.disabled = true;
$.getJSON("/computes/{{ compute_id }}/archs/" + encodeURIComponent(arch) + "/machines", function (data) {
document.querySelector("#select_chipset").replaceChildren(...data['machines'].map(
list.replaceChildren();
if (list.request) list.request.abort();
list.request = $.getJSON("/computes/{{ compute_id }}/archs/" + encodeURIComponent(arch) + "/machines", function (data) {
list.replaceChildren(...data['machines'].map(
item => new Option(item, item, false, item === "{{ default_machine|escapejs }}")));
next.disabled = !data['machines'].length;
});

View file

@ -530,9 +530,17 @@
return "/computes/" + COMPUTE + "/storage/" + encodeURIComponent(pool) + "/volumes";
}
// A dependent list is empty while it loads, and a newer load aborts the
// older one: an answer for the previous choice never fills it.
function load_list(list, url, fill) {
if (list.request) list.request.abort();
list.request = $.getJSON(url, fill);
return list.request;
}
function load_volumes(pool) {
return $.getJSON(volumes_url(pool), function (data) {
images_control.clearOptions();
images_control.clearOptions(); // keeps the chosen volumes
return load_list(images_control, volumes_url(pool), function (data) {
data['vols'].forEach(item => images_control.addOption({value: item, text: item}));
});
}
@ -544,14 +552,17 @@
}
function load_templates(pool) {
return $.getJSON(volumes_url(pool), function (data) {
document.querySelector("#template").replaceChildren(...data['vols'].map(item => new Option(item, item)));
const list = document.querySelector("#template");
list.replaceChildren();
return load_list(list, volumes_url(pool), function (data) {
list.replaceChildren(...data['vols'].map(item => new Option(item, item)));
});
}
function load_buses(bus_select, disk_type) {
const url = "/computes/" + COMPUTE + "/archs/{{ arch|escapejs }}/machines/{{ machine|escapejs }}/disks/" + disk_type + "/buses";
return $.getJSON(url, function (data) {
bus_select.replaceChildren();
return load_list(bus_select, url, function (data) {
bus_select.replaceChildren(...data['bus'].map(item => new Option(item, item)));
});
}

View file

@ -85,9 +85,23 @@ class CreatePageScriptTests(TestCase):
def test_the_disk_bus_list_is_asked_for_the_page_architecture_and_machine(self):
compute, page = self.page()
out = node(f"const COMPUTE = {compute.id}; let asked; const $ = {{getJSON: url => {{ asked = url; }}}};"
+ functions(page, "load_buses") + "load_buses({}, 'cdrom'); console.log(JSON.stringify(asked));")
+ functions(page, "load_list", "load_buses")
+ "load_buses({replaceChildren() {}}, 'cdrom'); console.log(JSON.stringify(asked));")
self.assertEqual(out, reverse("buses", args=[compute.id, "i686", "pc", "cdrom"]))
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def test_a_list_is_empty_while_it_loads_and_a_newer_load_aborts_the_older(self):
compute, page = self.page()
out = node(f"const COMPUTE = {compute.id}; const requests = [];"
"const $ = {getJSON: (url, fill) => { const r = {fill, abort() { r.aborted = true; }}; requests.push(r); return r; }};"
"const Option = function (text) { this.text = text; };"
"const bus = {children: ['ide'], replaceChildren(...items) { this.children = items; }};"
+ functions(page, "load_list", "load_buses")
+ "load_buses(bus, 'disk'); const loading = bus.children; load_buses(bus, 'cdrom');"
"requests[1].fill({bus: ['sata']});"
"console.log(JSON.stringify({loading, aborted: requests.map(r => !!r.aborted), filled: bus.children.map(o => o.text)}));")
self.assertEqual(out, {"loading": [], "aborted": [True, False], "filled": ["sata"]})
def test_the_form_has_one_mac_field(self):
_, page = self.page()
form = html.fromstring(page).get_element_by_id("create-vm-form")

View file

@ -1,6 +1,7 @@
"""The create wizard's first page sends the browser to the next page for the
chosen architecture and machine type; the URL is built from placeholders."""
import json
import re
import subprocess
import shutil
@ -51,3 +52,20 @@ class CreateWizardUrlTests(TestCase):
out = subprocess.run(["node", "-e", js], capture_output=True, text=True, timeout=30)
with self.subTest(machines=machines):
self.assertEqual(out.stdout.strip(), str(disabled).lower(), out.stderr)
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def test_a_newer_architecture_aborts_the_older_load(self):
compute = Compute.objects.create(name="cw3", hostname="10.0.0.8", login="u", password="p", type=1)
request = RequestFactory().get("/")
request.user = get_user_model().objects.create_superuser("cw3-admin", "cw3@example.com", "pw")
page = render_to_string("create_instance_w1.html", {"compute": compute, "compute_id": compute.id}, request=request)
function = re.search(r"function load_machines\(arch\) \{.*?\n \}", page, re.S).group(0)
js = ("const next = {}, select = {children: ['pc'], replaceChildren(...items) { this.children = items; }};"
"const document = {querySelector: sel => sel === '#next' ? next : select};"
"const Option = function (text) { this.text = text; }; const requests = [];"
"const $ = {getJSON: (url, fill) => { const r = {fill, abort() { r.aborted = true; }}; requests.push(r); return r; }};"
f"{function} load_machines('x86_64'); const loading = select.children; load_machines('aarch64');"
"requests[1].fill({machines: ['virt']});"
"console.log(JSON.stringify({loading, aborted: requests.map(r => !!r.aborted), filled: select.children.map(o => o.text)}));")
out = subprocess.run(["node", "-e", js], capture_output=True, text=True, timeout=30)
self.assertEqual(json.loads(out.stdout), {"loading": [], "aborted": [True, False], "filled": ["virt"]}, out.stderr)