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

refactor(instances): the VM create pages' scripts load from static files

The create wizard's two pages lose their inline scripts and handlers:
create_machine.js (machine types of the chosen architecture, Next, the
XML editor) and create_instance.js (volume and network pickers, disk
source, presets and flavors, row lists, a refused form restored). Their
URLs and messages are data attributes of #select_chipset, #next and
#create-vm-form; URL parts use __name__ placeholders, so the volume list
URL is now the routed one with its trailing slash. The XML form shows
the please-wait dialog through data-please-wait. The page script tests
read the static files.
This commit is contained in:
catborise 2026-11-02 09:26:39 +03:00
parent 561937b31a
commit e143fe353a
7 changed files with 346 additions and 338 deletions

View file

@ -28,7 +28,8 @@
</div>
<div class="col-md-6">
<label class="form-label" for="select_chipset">{% trans "Machine type" %}</label>
<select class="form-select" id="select_chipset" name="chipset" aria-describedby="chipset_help"></select>
<select class="form-select" id="select_chipset" name="chipset" aria-describedby="chipset_help"
data-machines-url="{% url 'machines' compute.id '__arch__' %}" data-default="{{ default_machine }}"></select>
<div id="chipset_help" class="form-text">{% trans "The host's machine types for this architecture." %}</div>
</div>
</div>
@ -37,7 +38,8 @@
<div class="d-flex flex-wrap gap-2 justify-content-end mb-4">
<a class="btn btn-link" href="{% url 'instances' compute.id %}">{% trans "Cancel" %}</a>
<button class="btn btn-primary" type="button" id="next" disabled>{% trans "Next" %}</button>
<button class="btn btn-primary" type="button" id="next" disabled
data-create-url="{% url 'instances:create_instance' compute.id '__arch__' '__machine__' %}">{% trans "Next" %}</button>
</div>
<section class="accordion mb-3" aria-labelledby="xml-heading">
@ -49,12 +51,12 @@
</button>
</h2>
<div class="accordion-collapse collapse{% if xml_error %} show{% endif %}" id="from-xml">
<form method="post" class="accordion-body" aria-label="{% trans "Create instance from XML" %}">{% csrf_token %}
<form method="post" class="accordion-body" aria-label="{% trans "Create instance from XML" %}" data-please-wait>{% csrf_token %}
{% if xml_error %}<div class="alert alert-danger form-errors" role="alert">{{ xml_error }}</div>{% endif %}
<input type="hidden" name="dom_xml" value="{{ xml }}">
<div class="xmlheight mb-3"><textarea id="editor" aria-label="{% trans "Domain XML" %}">{{ xml }}</textarea></div>
<div class="d-flex justify-content-end">
<button type="submit" class="btn btn-primary" name="create_xml" onclick="showPleaseWaitDialog()">
<button type="submit" class="btn btn-primary" name="create_xml">
{% trans "Create VM" %}
</button>
</div>
@ -65,50 +67,6 @@
</div>
{% endblock %}
{% block script %}
<script>
// 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"), list = document.querySelector("#select_chipset");
next.disabled = true;
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;
});
}
// placeholders, not "x86_64"/"pc": an architecture such as ppc64 contains "pc"
function create_url(arch, machine) {
return "{% url 'instances:create_instance' compute.id '__arch__' '__machine__' %}"
.replace("__arch__", encodeURIComponent(arch)).replace("__machine__", encodeURIComponent(machine));
}
function goto_create() {
window.location.href = create_url(document.querySelector("#select_archs").value,
document.querySelector("#select_chipset").value);
}
$(document).ready(function () {
const archs = document.querySelector("#select_archs");
archs.addEventListener("change", () => load_machines(archs.value));
document.querySelector("#next").addEventListener("click", goto_create);
load_machines(archs.value);
});
</script>
<script src="{% static "js/ace/ace.js" %}"></script>
<script>
var editor = ace.edit("editor");
editor.getSession().setMode("ace/mode/xml");
var input = $('input[name="dom_xml"]');
editor.getSession().on("change",function () {
input.val(editor.getSession().getValue());
})
// the editor measures itself when its section is shown
document.querySelector("#from-xml").addEventListener("shown.bs.collapse", () => editor.resize());
</script>
<script src="{% static "js/create_machine.js" %}"></script>
{% endblock %}

View file

@ -27,7 +27,10 @@
{% if vm_form %}
<form method="post" id="create-vm-form" aria-label="{% trans "Create virtual machine" %}"
{% if template_restore %}data-template-pool="{{ template_restore.pool }}" data-template="{{ template_restore.template }}"{% endif %}>
{% if template_restore %}data-template-pool="{{ template_restore.pool }}" data-template="{{ template_restore.template }}"{% endif %}
data-volumes-url="{% url 'volumes' compute_id '__pool__' %}" data-random-mac-url="{% url 'instances:random_mac_address' %}"
data-buses-url="{% url 'buses' compute_id arch machine '__disk__' %}"
data-add-volume="{% trans "Add volume" %}" data-add-network="{% trans "Add network" %}">
{% csrf_token %}{% submission_key %}
{% if submitted_lists %}{{ submitted_lists|json_script:"submitted-lists" }}{% endif %}
{% include "instances/form_errors.html" with form=vm_form %}
@ -371,250 +374,5 @@
{% block script %}
<script src="{% static 'js/tom-select.complete.min.js' %}"></script>
<script>
const COMPUTE = {{ compute_id }};
let images_control, networks_control;
// the volume and network pickers: each chosen item is a row below its picker
function make_pickers() {
images_control = new TomSelect('#image-control', {
plugins: ['checkbox_options'],
placeholder: '{{ _("Add volume")|escapejs }}',
hidePlaceholder: false,
onItemAdd: function (value) { if (!row_for("#img-list", value)) add_disk_row(value); },
onItemRemove: function (value) { remove_row("#img-list", value); },
});
networks_control = new TomSelect('#network-control', {
plugins: ['checkbox_options'],
placeholder: '{{ _("Add network")|escapejs }}',
hidePlaceholder: false,
onItemAdd: function (value) { if (!row_for("#net-list", value)) add_network_row(value, ""); },
onItemRemove: function (value) { remove_row("#net-list", value); },
});
}
$(document).ready(function () {
if (!document.querySelector("#create-vm-form")) return;
make_pickers();
document.querySelectorAll('input[name="disk_source"]').forEach(function (radio) {
radio.addEventListener("change", function () {
show_source(this.value);
if (this.value !== "new") clear_presets(); // its size is not sent
});
});
show_source(document.querySelector('input[name="disk_source"]:checked').value);
$("#create-vm-form").on("click", ".preset", function () { apply_preset(this); });
$("#flavors-dialog").on("submit", "form", function (event) {
event.preventDefault();
$.post(this.action, $(this).serialize(), show_flavors).fail(() => this.submit());
});
["#vm_vcpu", "#vm_memory", "#vm_hdd_size"].forEach(function (sel) {
document.querySelector(sel).addEventListener("input", clear_presets);
});
document.querySelector("#storage-control").addEventListener("change", function () { load_volumes(this.value); });
document.querySelector("#template_pool").addEventListener("change", function () { template_pool_chosen(this.value); });
if (document.body.dataset.refused) restore_submitted();
else add_first_network();
});
// a new VM starts with one interface: on "default" if the host has it
function add_first_network() {
const names = Object.keys(networks_control.options);
if (names.length) networks_control.addItem(names.includes("default") ? "default" : names[0]);
}
// Only the chosen disk source's fields are shown and sent: the others
// are disabled, so the browser neither submits nor validates them.
function show_source(source) {
document.querySelectorAll(".disk-source").forEach(function (fieldset) {
const on = fieldset.id === "source-" + source;
fieldset.hidden = !on;
fieldset.disabled = !on;
});
images_control[source === "existing" ? "enable" : "disable"]();
}
// A preset fills the size fields and picks a new disk; editing one of
// them afterwards leaves no preset marked as applied.
function apply_preset(button) {
document.querySelector("#vm_vcpu").value = button.dataset.vcpu;
document.querySelector("#vm_memory").value = button.dataset.memory;
document.querySelector("#vm_hdd_size").value = button.dataset.disk;
document.querySelector('input[name="disk_source"][value="new"]').checked = true;
show_source("new");
clear_presets();
button.setAttribute("aria-pressed", "true");
button.classList.replace("text-body", "btn-primary");
document.querySelector("#preset-status").textContent =
`${button.dataset.label}: ${button.dataset.vcpu} vCPU, ${button.dataset.memory} MiB, new ${button.dataset.disk} GiB disk`;
}
function clear_presets() {
document.querySelectorAll(".preset").forEach(function (b) {
b.setAttribute("aria-pressed", "false");
b.classList.replace("btn-primary", "text-body");
});
}
// Adding or deleting a flavor stays on the page: its reply replaces the
// presets and the dialog's list and form, and the dialog keeps the focus.
function show_flavors(reply) {
const parts = new DOMParser().parseFromString(reply, "text/html");
["#presets", "#flavors"].forEach(sel => document.querySelector(sel).replaceWith(parts.querySelector(sel)));
document.querySelector("#flavors .is-invalid, #flavors input:not([type=hidden])").focus();
}
// Disk and network rows: one per chosen volume or network, cloned from
// the page's row templates; the fields the server reads are derived
// from them in sync_lists.
function new_row(list, template, item) {
const row = document.getElementById(template).content.firstElementChild.cloneNode(true);
row.dataset.item = item;
row.querySelector(".row-item").textContent = item;
document.querySelector(list).append(row);
return row;
}
function add_disk_row(volume) {
const row = new_row("#img-list", "disk-row", volume);
const bus = row.querySelector(".disk-bus");
row.querySelector(".disk-device").addEventListener("change", function () {
load_buses(bus, this.value);
});
row.querySelector(".row-remove").addEventListener("click", function () { images_control.removeItem(volume); });
sync_lists();
return row;
}
// a network row without a MAC gets a random one
function add_network_row(network, mac) {
const row = new_row("#net-list", "network-row", network);
const input = row.querySelector("input");
input.value = mac;
input.addEventListener("input", sync_lists);
row.querySelector(".row-remove").addEventListener("click", function () { networks_control.removeItem(network); });
if (!mac) random_mac(input);
sync_lists();
return row;
}
function row_for(list, item) {
return [...document.querySelector(list).children].find(row => row.dataset.item === item);
}
function remove_row(list, item) {
row_for(list, item)?.remove();
sync_lists();
}
// volumes, networks and their MACs in row order; device<i> and bus<i>
// belong to the i-th volume
function sync_lists() {
const disks = [...document.querySelector("#img-list").children];
const nics = [...document.querySelector("#net-list").children];
disks.forEach(function (row, i) {
row.querySelector(".row-label").textContent = "hdd" + i;
row.querySelector(".disk-device").name = "device" + i;
row.querySelector(".disk-bus").name = "bus" + i;
});
nics.forEach(function (row, i) {
row.querySelector(".row-label").textContent = "eth" + i;
});
document.querySelector("#images").value = disks.map(row => row.dataset.item).join(",");
document.querySelector("#networks").value = nics.map(row => row.dataset.item).join(",");
document.querySelector("#vm_mac").value = nics.map(row => row.querySelector("input").value).join(",");
}
// a MAC typed while the request was out stays
function random_mac(input) {
$.getJSON("{% url 'instances:random_mac_address' %}", function (data) {
if (!input.value) input.value = data['mac'];
sync_lists();
});
}
function volumes_url(pool) {
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) {
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}));
});
}
// the copy goes into the template's pool unless the user picks another
function template_pool_chosen(pool) {
select_value(document.querySelector("#template_storage"), pool);
return load_templates(pool);
}
function load_templates(pool) {
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";
bus_select.replaceChildren();
return load_list(bus_select, url, function (data) {
bus_select.replaceChildren(...data['bus'].map(item => new Option(item, item)));
});
}
// a value the loaded list no longer has is kept, not swapped for the first
function select_value(select, value) {
if (![...select.options].some(option => option.value === value)) select.add(new Option(value, value));
select.value = value;
}
// A refused form comes back as it was sent: its networks, volumes and
// template loaded first, any Advanced field with an error shown, then
// the focus on the first error.
function restore_submitted() {
const form = document.querySelector("#create-vm-form");
const element = document.getElementById("submitted-lists");
const lists = element ? JSON.parse(element.textContent) : {networks: [], disks: [], pool: ""};
lists.networks.forEach(function (nic) {
networks_control.addOption({value: nic.name, text: nic.name});
add_network_row(nic.name, nic.mac);
networks_control.addItem(nic.name, true);
});
const loads = [];
if (lists.pool) {
select_value(document.querySelector("#storage-control"), lists.pool);
loads.push(load_volumes(lists.pool).then(function () {
return $.when(...lists.disks.map(function (disk) {
images_control.addOption({value: disk.volume, text: disk.volume});
const row = add_disk_row(disk.volume);
images_control.addItem(disk.volume, true);
const bus = row.querySelector(".disk-bus");
select_value(row.querySelector(".disk-device"), disk.device);
return load_buses(bus, disk.device).then(() => select_value(bus, disk.bus));
}));
}));
}
if (form.dataset.templatePool) {
select_value(document.querySelector("#template_pool"), form.dataset.templatePool);
loads.push(load_templates(form.dataset.templatePool).then(function () {
select_value(document.querySelector("#template"), form.dataset.template);
}));
}
const advanced = document.querySelector("#advanced");
if (advanced.querySelector(".is-invalid")) bootstrap.Collapse.getOrCreateInstance(advanced, {toggle: false}).show();
$.when(...loads).then(focusFirstError);
}
</script>
<script src="{% static 'js/create_instance.js' %}"></script>
{% endblock %}

View file

@ -8,6 +8,7 @@ import subprocess
import unittest
from unittest.mock import patch
from django.conf import settings
from django.test import TestCase
from django.urls import reverse
from instances.forms import NewVMForm
@ -58,13 +59,16 @@ class TomSelect {
}
"""
ROWS = ("make_pickers", "new_row", "add_disk_row", "add_network_row", "row_for", "remove_row", "sync_lists",
# the page's script, with the form's data in a stand-in #create-vm-form
SCRIPT = (settings.BASE_DIR / "static/js/create_instance.js").read_text()
ROWS = ("form_data", "make_pickers", "new_row", "add_disk_row", "add_network_row", "row_for", "remove_row", "sync_lists",
"random_mac", "load_buses")
def functions(page, *names):
"""The page's script functions of these names, in this order."""
return "".join(re.search(rf"\n function {name}\(.*?\n }}\n", page, re.S).group(0) for name in names)
def functions(*names):
"""The page script's functions of these names, in this order."""
return "".join(re.search(rf"\nfunction {name}\(.*?\n}}\n", SCRIPT, re.S).group(0) for name in names)
def node(script):
@ -84,19 +88,20 @@ class CreatePageScriptTests(TestCase):
@unittest.skipUnless(shutil.which("node"), "node is not installed")
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_list", "load_buses")
buses_url = html.fromstring(page).get_element_by_id("create-vm-form").get("data-buses-url")
out = node(f"const document = {{querySelector: () => ({{dataset: {{busesUrl: {json.dumps(buses_url)}}}}})}};"
"let asked; const $ = {getJSON: url => { asked = url; }};"
+ functions("form_data", "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 = [];"
out = node("const document = {querySelector: () => ({dataset: {busesUrl: '/__disk__/'}})}; 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")
+ functions("form_data", "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)}));")
@ -123,8 +128,7 @@ class CreatePageScriptTests(TestCase):
def run_rows(self, test):
"""Run the page's row script against the fake DOM, then test."""
_, page = self.page()
return node(FAKE_DOM + "let images_control, networks_control;" + functions(page, *ROWS)
return node(FAKE_DOM + "let images_control, networks_control;" + functions(*ROWS)
+ "make_pickers();" + test)
@unittest.skipUnless(shutil.which("node"), "node is not installed")
@ -199,9 +203,7 @@ const pressed = () => presets.map(b => [b.attrs["aria-pressed"], [...b.classes].
"""
def run_script(self, test):
_, response = render_create_page(self)
page = response.content.decode()
return node(self.HARNESS + functions(page, "show_source", "apply_preset", "clear_presets") + test)
return node(self.HARNESS + functions("show_source", "apply_preset", "clear_presets") + test)
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def test_only_the_chosen_source_is_shown_and_sent(self):
@ -238,10 +240,8 @@ class NewFormScriptTests(TestCase):
template's pool unless the user picks another."""
def script(self, test):
_, response = render_create_page(self)
page = response.content.decode()
return node(FAKE_DOM + "let images_control, networks_control;" + functions(page, *ROWS)
+ functions(page, "add_first_network", "select_value", "template_pool_chosen") + "make_pickers();" + test)
return node(FAKE_DOM + "let images_control, networks_control;" + functions(*ROWS)
+ functions("add_first_network", "select_value", "template_pool_chosen") + "make_pickers();" + test)
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def test_a_new_form_starts_on_default_if_the_host_has_it(self):
@ -273,10 +273,8 @@ class RestoreScriptTests(TestCase):
def run_restore(self, test, lists=LISTS):
"""Run restore_submitted against the fake DOM, with loaders that
answer when the test calls answer()."""
_, response = render_create_page(self)
page = response.content.decode()
harness = FAKE_DOM + "let images_control, networks_control;" \
+ functions(page, *ROWS[:-1], "select_value", "restore_submitted") + """
+ functions(*ROWS[:-1], "select_value", "restore_submitted") + """
templates["submitted-lists"] = {textContent: %s};
$.when = (...loading) => Promise.all(loading);
const log = [], answers = [];

View file

@ -8,23 +8,33 @@ import shutil
import unittest
from computes.models import Compute
from django.conf import settings
from django.contrib.auth import get_user_model
from django.template.loader import render_to_string
from django.test import RequestFactory, TestCase
from lxml import html
SCRIPT = (settings.BASE_DIR / "static/js/create_machine.js").read_text()
def function(name):
"""The page script's function of this name."""
return re.search(rf"\nfunction {name}\(.*?\n}}\n", SCRIPT, re.S).group(0)
class CreateWizardUrlTests(TestCase):
def render_script(self):
def render_page(self):
compute = Compute.objects.create(name="cw", hostname="10.0.0.6", login="u", password="p", type=1)
request = RequestFactory().get("/")
request.user = get_user_model().objects.create_superuser("cw-admin", "cw@example.com", "pw")
page = render_to_string("create_instance_w1.html", {"compute": compute}, request=request)
# the function that builds the URL from arch and machine
return re.search(r"function create_url\(arch, machine\) \{.*?\n \}", page, re.S).group(0)
return compute, html.fromstring(render_to_string("create_instance_w1.html", {"compute": compute}, request=request))
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def test_arch_and_machine_names_land_in_their_segments(self):
script = self.render_script()
_, page = self.render_page()
create_url = page.get_element_by_id("next").get("data-create-url")
script = ("const document = {querySelector: () => ({dataset: {createUrl: %s}})};" % json.dumps(create_url)
+ function("create_url"))
cases = {
("x86_64", "q35"): "x86_64/q35/",
("ppc64le", "pseries"): "ppc64le/pseries/", # contains "pc"
@ -38,33 +48,25 @@ class CreateWizardUrlTests(TestCase):
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def test_next_waits_for_a_machine_type(self):
compute = Compute.objects.create(name="cw2", hostname="10.0.0.7", login="u", password="p", type=1)
request = RequestFactory().get("/")
request.user = get_user_model().objects.create_superuser("cw2-admin", "cw2@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)
load_machines = function("load_machines")
for machines, disabled in (([], True), (["q35"], False)):
js = ("const next = {disabled: false}, select = {replaceChildren() {}};"
js = ("const next = {disabled: false}, select = {dataset: {machinesUrl: '/__arch__/'}, replaceChildren() {}};"
"const document = {querySelector: sel => sel === '#next' ? next : select};"
"const Option = function () {};"
f"const $ = {{getJSON: (url, f) => f({{machines: {machines!r}}})}};"
f"{function} load_machines('x86_64'); console.log(next.disabled);")
f"{load_machines} load_machines('x86_64'); console.log(next.disabled);")
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; }};"
load_machines = function("load_machines")
js = ("const next = {}, select = {dataset: {machinesUrl: '/__arch__/'}, 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');"
f"{load_machines} 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)

View file

@ -121,11 +121,11 @@ class TemplateHtmlConcatenationTestCase(TestCase):
self.assertNotIn('pool + "<span', src)
def test_create_wizard_chipset_list_does_not_concatenate_into_html(self):
src = self._read("instances/templates/create_instance_w1.html")
src = self._read("static/js/create_machine.js")
self.assertNotIn("""append('<option value="' + item""", src)
def test_create_wizard_volume_lists_do_not_concatenate_volume_names_into_html(self):
src = self._read("instances/templates/create_instance_w2.html")
src = self._read("static/js/create_instance.js")
self.assertNotIn("'<option value=' + item", src)

View file

@ -0,0 +1,249 @@
// The VM create form (create_instance_w2.html). Its URLs and messages are
// data attributes of #create-vm-form.
let images_control, networks_control;
function form_data() {
return document.querySelector("#create-vm-form").dataset;
}
// the volume and network pickers: each chosen item is a row below its picker
function make_pickers() {
images_control = new TomSelect('#image-control', {
plugins: ['checkbox_options'],
placeholder: form_data().addVolume,
hidePlaceholder: false,
onItemAdd: function (value) { if (!row_for("#img-list", value)) add_disk_row(value); },
onItemRemove: function (value) { remove_row("#img-list", value); },
});
networks_control = new TomSelect('#network-control', {
plugins: ['checkbox_options'],
placeholder: form_data().addNetwork,
hidePlaceholder: false,
onItemAdd: function (value) { if (!row_for("#net-list", value)) add_network_row(value, ""); },
onItemRemove: function (value) { remove_row("#net-list", value); },
});
}
// a new VM starts with one interface: on "default" if the host has it
function add_first_network() {
const names = Object.keys(networks_control.options);
if (names.length) networks_control.addItem(names.includes("default") ? "default" : names[0]);
}
// Only the chosen disk source's fields are shown and sent: the others
// are disabled, so the browser neither submits nor validates them.
function show_source(source) {
document.querySelectorAll(".disk-source").forEach(function (fieldset) {
const on = fieldset.id === "source-" + source;
fieldset.hidden = !on;
fieldset.disabled = !on;
});
images_control[source === "existing" ? "enable" : "disable"]();
}
// A preset fills the size fields and picks a new disk; editing one of
// them afterwards leaves no preset marked as applied.
function apply_preset(button) {
document.querySelector("#vm_vcpu").value = button.dataset.vcpu;
document.querySelector("#vm_memory").value = button.dataset.memory;
document.querySelector("#vm_hdd_size").value = button.dataset.disk;
document.querySelector('input[name="disk_source"][value="new"]').checked = true;
show_source("new");
clear_presets();
button.setAttribute("aria-pressed", "true");
button.classList.replace("text-body", "btn-primary");
document.querySelector("#preset-status").textContent =
`${button.dataset.label}: ${button.dataset.vcpu} vCPU, ${button.dataset.memory} MiB, new ${button.dataset.disk} GiB disk`;
}
function clear_presets() {
document.querySelectorAll(".preset").forEach(function (b) {
b.setAttribute("aria-pressed", "false");
b.classList.replace("btn-primary", "text-body");
});
}
// Adding or deleting a flavor stays on the page: its reply replaces the
// presets and the dialog's list and form, and the dialog keeps the focus.
function show_flavors(reply) {
const parts = new DOMParser().parseFromString(reply, "text/html");
["#presets", "#flavors"].forEach(sel => document.querySelector(sel).replaceWith(parts.querySelector(sel)));
document.querySelector("#flavors .is-invalid, #flavors input:not([type=hidden])").focus();
}
// Disk and network rows: one per chosen volume or network, cloned from
// the page's row templates; the fields the server reads are derived
// from them in sync_lists.
function new_row(list, template, item) {
const row = document.getElementById(template).content.firstElementChild.cloneNode(true);
row.dataset.item = item;
row.querySelector(".row-item").textContent = item;
document.querySelector(list).append(row);
return row;
}
function add_disk_row(volume) {
const row = new_row("#img-list", "disk-row", volume);
const bus = row.querySelector(".disk-bus");
row.querySelector(".disk-device").addEventListener("change", function () {
load_buses(bus, this.value);
});
row.querySelector(".row-remove").addEventListener("click", function () { images_control.removeItem(volume); });
sync_lists();
return row;
}
// a network row without a MAC gets a random one
function add_network_row(network, mac) {
const row = new_row("#net-list", "network-row", network);
const input = row.querySelector("input");
input.value = mac;
input.addEventListener("input", sync_lists);
row.querySelector(".row-remove").addEventListener("click", function () { networks_control.removeItem(network); });
if (!mac) random_mac(input);
sync_lists();
return row;
}
function row_for(list, item) {
return [...document.querySelector(list).children].find(row => row.dataset.item === item);
}
function remove_row(list, item) {
row_for(list, item)?.remove();
sync_lists();
}
// volumes, networks and their MACs in row order; device<i> and bus<i>
// belong to the i-th volume
function sync_lists() {
const disks = [...document.querySelector("#img-list").children];
const nics = [...document.querySelector("#net-list").children];
disks.forEach(function (row, i) {
row.querySelector(".row-label").textContent = "hdd" + i;
row.querySelector(".disk-device").name = "device" + i;
row.querySelector(".disk-bus").name = "bus" + i;
});
nics.forEach(function (row, i) {
row.querySelector(".row-label").textContent = "eth" + i;
});
document.querySelector("#images").value = disks.map(row => row.dataset.item).join(",");
document.querySelector("#networks").value = nics.map(row => row.dataset.item).join(",");
document.querySelector("#vm_mac").value = nics.map(row => row.querySelector("input").value).join(",");
}
// a MAC typed while the request was out stays
function random_mac(input) {
$.getJSON(form_data().randomMacUrl, function (data) {
if (!input.value) input.value = data['mac'];
sync_lists();
});
}
function volumes_url(pool) {
return form_data().volumesUrl.replace("__pool__", encodeURIComponent(pool));
}
// 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) {
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}));
});
}
// the copy goes into the template's pool unless the user picks another
function template_pool_chosen(pool) {
select_value(document.querySelector("#template_storage"), pool);
return load_templates(pool);
}
function load_templates(pool) {
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 = form_data().busesUrl.replace("__disk__", encodeURIComponent(disk_type));
bus_select.replaceChildren();
return load_list(bus_select, url, function (data) {
bus_select.replaceChildren(...data['bus'].map(item => new Option(item, item)));
});
}
// a value the loaded list no longer has is kept, not swapped for the first
function select_value(select, value) {
if (![...select.options].some(option => option.value === value)) select.add(new Option(value, value));
select.value = value;
}
// A refused form comes back as it was sent: its networks, volumes and
// template loaded first, any Advanced field with an error shown, then
// the focus on the first error.
function restore_submitted() {
const form = document.querySelector("#create-vm-form");
const element = document.getElementById("submitted-lists");
const lists = element ? JSON.parse(element.textContent) : {networks: [], disks: [], pool: ""};
lists.networks.forEach(function (nic) {
networks_control.addOption({value: nic.name, text: nic.name});
add_network_row(nic.name, nic.mac);
networks_control.addItem(nic.name, true);
});
const loads = [];
if (lists.pool) {
select_value(document.querySelector("#storage-control"), lists.pool);
loads.push(load_volumes(lists.pool).then(function () {
return $.when(...lists.disks.map(function (disk) {
images_control.addOption({value: disk.volume, text: disk.volume});
const row = add_disk_row(disk.volume);
images_control.addItem(disk.volume, true);
const bus = row.querySelector(".disk-bus");
select_value(row.querySelector(".disk-device"), disk.device);
return load_buses(bus, disk.device).then(() => select_value(bus, disk.bus));
}));
}));
}
if (form.dataset.templatePool) {
select_value(document.querySelector("#template_pool"), form.dataset.templatePool);
loads.push(load_templates(form.dataset.templatePool).then(function () {
select_value(document.querySelector("#template"), form.dataset.template);
}));
}
const advanced = document.querySelector("#advanced");
if (advanced.querySelector(".is-invalid")) bootstrap.Collapse.getOrCreateInstance(advanced, {toggle: false}).show();
$.when(...loads).then(focusFirstError);
}
// without storage on the host there is no form to set up
if (document.querySelector("#create-vm-form")) {
make_pickers();
document.querySelectorAll('input[name="disk_source"]').forEach(function (radio) {
radio.addEventListener("change", function () {
show_source(this.value);
if (this.value !== "new") clear_presets(); // its size is not sent
});
});
show_source(document.querySelector('input[name="disk_source"]:checked').value);
$("#create-vm-form").on("click", ".preset", function () { apply_preset(this); });
$("#flavors-dialog").on("submit", "form", function (event) {
event.preventDefault();
$.post(this.action, $(this).serialize(), show_flavors).fail(() => this.submit());
});
["#vm_vcpu", "#vm_memory", "#vm_hdd_size"].forEach(function (sel) {
document.querySelector(sel).addEventListener("input", clear_presets);
});
document.querySelector("#storage-control").addEventListener("change", function () { load_volumes(this.value); });
document.querySelector("#template_pool").addEventListener("change", function () { template_pool_chosen(this.value); });
if (document.body.dataset.refused) restore_submitted();
else add_first_network();
}

View file

@ -0,0 +1,43 @@
// The create wizard's first page (create_instance_w1.html): the machine
// types of the chosen architecture, Next to the form for them, and a VM
// defined from XML instead.
// 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"), list = document.querySelector("#select_chipset");
next.disabled = true;
list.replaceChildren();
if (list.request) list.request.abort();
list.request = $.getJSON(list.dataset.machinesUrl.replace("__arch__", encodeURIComponent(arch)), function (data) {
list.replaceChildren(...data['machines'].map(
item => new Option(item, item, false, item === list.dataset.default)));
next.disabled = !data['machines'].length;
});
}
// placeholders, not "x86_64"/"pc": an architecture such as ppc64 contains "pc"
function create_url(arch, machine) {
return document.querySelector("#next").dataset.createUrl
.replace("__arch__", encodeURIComponent(arch)).replace("__machine__", encodeURIComponent(machine));
}
function goto_create() {
window.location.href = create_url(document.querySelector("#select_archs").value,
document.querySelector("#select_chipset").value);
}
const archs = document.querySelector("#select_archs");
archs.addEventListener("change", () => load_machines(archs.value));
document.querySelector("#next").addEventListener("click", goto_create);
load_machines(archs.value);
// the XML editor fills the form's field
const editor = ace.edit("editor");
editor.getSession().setMode("ace/mode/xml");
const xml_input = document.querySelector('input[name="dom_xml"]');
editor.getSession().on("change", function () {
xml_input.value = editor.getSession().getValue();
});
// the editor measures itself when its section is shown
document.querySelector("#from-xml").addEventListener("shown.bs.collapse", () => editor.resize());