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

feat(instances): the Boot tab shows the boot order as the chain the VM tries

A checkbox marked a device as booting and also chose what Up and Down
moved, which moved every checked device at once: to move one device the
others had to be unchecked and checked again. The buttons sat apart
from the rows, and a VM booting by device type got a note that did not
say what to do.

Now the devices the VM tries are a numbered chain, each with its own
up, down and Remove, and the others are listed below it with Add; the
posted order is unchanged. Devices read by kind first (CD-ROM sda,
Disk vda) with their file or network below; a VM booting by device
type says so and how to choose an order. Autostart and the boot menu
sit below, lighter. Focus stays on the device that moved, and on a
narrow screen a device's buttons wrap below its name.

The Turkish catalog says "Ön yükleme" and "sanal makine" throughout,
also in the Power tab texts.
This commit is contained in:
catborise 2026-11-03 19:41:19 +03:00
parent c247a7a3f0
commit 4565221c5c
12 changed files with 740 additions and 688 deletions

View file

@ -223,17 +223,25 @@ class Instance(models.Model):
"disk:vda"."""
order = [f"{d['dev']}:{d['target']}" for position, d in sorted(self.config_boot_order.items())]
devices = (
[{"value": f"disk:{d['dev']}", "name": d["dev"], "kind": _("Disk"), "source": d["image"]}
[{"value": f"disk:{d['dev']}", "name": d["dev"], "kind": _("Disk"), "source": d["image"], "icon": "device-hdd"}
for d in self.config_disks]
+ [{"value": f"cdrom:{d['dev']}", "name": d["dev"], "kind": _("CD-ROM"), "source": d["image"]}
+ [{"value": f"cdrom:{d['dev']}", "name": d["dev"], "kind": _("CD-ROM"), "source": d["image"], "icon": "disc"}
for d in self.config_media]
+ [{"value": f"network:{n['mac']}", "name": n["mac"], "kind": _("NIC"), "source": n["nic"]}
+ [{"value": f"network:{n['mac']}", "name": n["mac"], "kind": _("NIC"), "source": n["nic"], "icon": "ethernet"}
for n in self.config_networks]
)
for device in devices:
device["checked"] = device["value"] in order
return sorted(devices, key=lambda d: order.index(d["value"]) if d["checked"] else len(order))
@cached_property
def config_boot_types(self) -> str:
"""The device types the VM boots by, in turn ("disk, cdrom"), when its
order names types rather than devices; "" otherwise."""
if any(d["checked"] for d in self.config_boot_devices):
return ""
return ", ".join(b["target"] for position, b in sorted(self.config_boot_order.items()))
@cached_property
def config_video_model(self) -> str | None:
"""Video model and console settings of the persistent definition,

View file

@ -0,0 +1,19 @@
{% load i18n %}
{% load bootstrap_icons %}
{# one device of the boot order editor: in #boot-chain it is numbered and moves up, down or out; in #boot-spare it is added #}
<li class="boot-node d-flex flex-wrap align-items-center gap-2 gap-sm-3" data-boot-value="{{ device.value }}">
<span class="boot-step" aria-hidden="true"></span>
<span class="fs-5 text-body-secondary" aria-hidden="true">{% if device.icon == "disc" %}{% bs_icon 'disc' %}{% elif device.icon == "ethernet" %}{% bs_icon 'ethernet' %}{% else %}{% bs_icon 'device-hdd' %}{% endif %}</span>
<span class="boot-text flex-grow-1 text-break">
<span class="fw-semibold">{{ device.kind }} {{ device.name }}</span>
<span class="d-block small text-body-secondary">{{ device.source|default:_("empty") }}</span>
</span>
<span class="boot-chain-only d-flex gap-1 ms-auto">
<button type="button" class="btn btn-sm btn-outline-secondary" data-boot="up" aria-label="{% blocktrans with kind=device.kind name=device.name %}Move {{ kind }} {{ name }} up{% endblocktrans %}">{% bs_icon 'arrow-up' %}</button>
<button type="button" class="btn btn-sm btn-outline-secondary" data-boot="down" aria-label="{% blocktrans with kind=device.kind name=device.name %}Move {{ kind }} {{ name }} down{% endblocktrans %}">{% bs_icon 'arrow-down' %}</button>
<button type="button" class="btn btn-sm btn-outline-secondary" data-boot="remove">{% trans "Remove" %}</button>
</span>
<span class="boot-spare-only ms-auto text-nowrap">
<button type="button" class="btn btn-sm btn-outline-primary" data-boot="add">{% bs_icon 'plus-lg' %} {% trans "Add" %}</button>
</span>
</li>

View file

@ -92,57 +92,45 @@
{% endif %}
{% if request.user.is_superuser %}
<div role="tabpanel" class="tab-pane active" id="boot_opt">
<section class="mb-4" aria-labelledby="startup-heading">
<h2 class="h6" id="startup-heading">{% trans "Startup settings" %}</h2>
<div class="list-group">
<div class="list-group-item py-3">
<form action="{% if instance.autostart %}{% url 'instances:unset_autostart' instance.id %}{% else %}{% url 'instances:set_autostart' instance.id %}{% endif %}" method="post">{% csrf_token %}
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="autostart" aria-describedby="autostart-help"{% if instance.autostart %} checked{% endif %} data-submit-on-change>
<label class="form-check-label fw-semibold" for="autostart">{% trans "Autostart" %}</label>
</div>
<p class="small text-body-secondary mb-0 mt-1" id="autostart-help">{% trans "Start this VM when the host starts." %}</p>
</form>
</div>
<div class="list-group-item py-3">
<form action="{% if instance.config_bootmenu %}{% url 'instances:unset_bootmenu' instance.id %}{% else %}{% url 'instances:set_bootmenu' instance.id %}{% endif %}" method="post">{% csrf_token %}
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="bootmenu" aria-describedby="bootmenu-help"{% if instance.config_bootmenu %} checked{% endif %} data-submit-on-change>
<label class="form-check-label fw-semibold" for="bootmenu">{% trans "Boot menu" %}</label>
</div>
<p class="small text-body-secondary mb-0 mt-1" id="bootmenu-help">{% trans "Show a device chooser when the VM starts." %}</p>
</form>
</div>
</div>
</section>
<section aria-labelledby="boot-order-heading">
{# the devices the VM tries in turn when it starts, and those it does not try; each moves on its own #}
<section class="mb-4" aria-labelledby="boot-order-heading">
<h2 class="h6" id="boot-order-heading">{% trans "Boot order" %}</h2>
<p class="small text-body-secondary" id="boot-order-help">{% trans "Checked devices boot from top to bottom. Up and Down move all checked devices." %}</p>
{% if instance.config_boot_order and not instance.config_boot_devices.0.checked %}
<p class="small text-body-secondary" id="boot-by-type">
{% trans "The VM now boots by device type:" %}
{% for b in instance.config_boot_order.values %}{{ b.target }}{% if not forloop.last %}, {% endif %}{% endfor %}.
{% trans "Checking devices replaces this order." %}
</p>
{% endif %}
<p class="small text-body-secondary" id="boot-order-help">{% trans "When the VM starts it tries these devices in turn and boots from the first that works." %}</p>
<form action="{% url 'instances:set_bootorder' instance.id %}" method="post" aria-labelledby="boot-order-heading" aria-describedby="boot-order-help">{% csrf_token %}
<input type="hidden" id="bootorder" name="bootorder">
<div id="b_order" class="list-group boot-sequence mb-3">
{% for device in instance.config_boot_devices %}
<label class="list-group-item d-flex align-items-center gap-3">
<input class="form-check-input flex-shrink-0 m-0" type="checkbox" value="{{ device.value }}"{% if device.checked %} checked{% endif %}>
<span class="boot-rank small text-body-secondary" aria-hidden="true"></span>
<span class="text-break"><strong>{{ device.name }}</strong><span class="d-block small text-body-secondary">{{ device.kind }} · {{ device.source|default:_("empty") }}</span></span>
</label>
{% endfor %}
</div>
<div class="d-flex flex-wrap gap-2">
<button type="button" id="boot_order_up" class="btn btn-sm btn-secondary">{% bs_icon 'arrow-up' %} {% trans "Up" %}</button>
<button type="button" id="boot_order_down" class="btn btn-sm btn-secondary">{% bs_icon 'arrow-down' %} {% trans "Down" %}</button>
<button type="submit" class="btn btn-sm btn-primary ms-auto">{% trans "Apply order" %}</button>
</div>
<ol id="boot-chain" class="boot-chain list-unstyled mb-0">
{% for device in instance.config_boot_devices %}{% if device.checked %}{% include "instances/boot_device.html" %}{% endif %}{% endfor %}
</ol>
<p class="small text-body-secondary py-2 mb-0" id="boot-chain-empty"{% if instance.config_boot_devices.0.checked %} hidden{% endif %}>
{% if instance.config_boot_types %}{% blocktrans with types=instance.config_boot_types %}The VM boots by device type: {{ types }}. Add a device to choose the order yourself.{% endblocktrans %}
{% else %}{% trans "No device is in the boot order. Add one." %}{% endif %}
</p>
<h3 class="h6 small text-body-secondary mt-3 mb-1">{% trans "Not in the boot order" %}</h3>
<ul id="boot-spare" class="boot-spare list-unstyled mb-3">
{% for device in instance.config_boot_devices %}{% if not device.checked %}{% include "instances/boot_device.html" %}{% endif %}{% endfor %}
</ul>
<button type="submit" class="btn btn-sm btn-primary">{% trans "Save order" %}</button>
</form>
</section>
<section class="border-top pt-3" aria-labelledby="startup-heading">
<h2 class="h6" id="startup-heading">{% trans "Startup settings" %}</h2>
<div class="row g-3">
<form class="col-12 col-md-6" action="{% if instance.autostart %}{% url 'instances:unset_autostart' instance.id %}{% else %}{% url 'instances:set_autostart' instance.id %}{% endif %}" method="post">{% csrf_token %}
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="autostart" aria-describedby="autostart-help"{% if instance.autostart %} checked{% endif %} data-submit-on-change>
<label class="form-check-label" for="autostart">{% trans "Autostart" %}</label>
</div>
<p class="small text-body-secondary mb-0" id="autostart-help">{% trans "Start this VM when the host starts." %}</p>
</form>
<form class="col-12 col-md-6" action="{% if instance.config_bootmenu %}{% url 'instances:unset_bootmenu' instance.id %}{% else %}{% url 'instances:set_bootmenu' instance.id %}{% endif %}" method="post">{% csrf_token %}
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="bootmenu" aria-describedby="bootmenu-help"{% if instance.config_bootmenu %} checked{% endif %} data-submit-on-change>
<label class="form-check-label" for="bootmenu">{% trans "Boot menu" %}</label>
</div>
<p class="small text-body-secondary mb-0" id="bootmenu-help">{% trans "Show a device chooser when the VM starts." %}</p>
</form>
</div>
</section>
</div>
<div role="tabpanel" class="tab-pane" id="disks">
{# its CD-ROM drives, then its disks as list rows; the forms are those of the actions #}

View file

@ -493,7 +493,7 @@ class LiveDataLossTestCase(TestCase):
page = html.fromstring(self.client.get(reverse("instances:instance", args=[inst.id])).content)
(boot,) = page.xpath("//form[@action=$a]", a=reverse("instances:set_bootorder", args=[inst.id]))
self.assertEqual(boot.xpath(".//input[@type='checkbox']/@value"), ["network:52:54:00:aa:05:21", "disk:vda"])
self.assertEqual(boot.xpath(".//ol[@id='boot-chain']/li/@data-boot-value"), ["network:52:54:00:aa:05:21", "disk:vda"])
self.assertEqual(page.xpath("//select[@name='video_model']/option[@selected]/@value"), [video])
self.post("set_bootorder", inst, {"bootorder": "network:52:54:00:aa:05:22,disk:vda"})

View file

@ -306,9 +306,9 @@ class PendingEditsTestCase(TestCase):
get_console_passwd=("live-pw", "pending-pw"),
)
(boot,) = doc.xpath("//form[@action=$a]", a=reverse("instances:set_bootorder", args=[self.instance.id]))
# the devices in the order first, checked
self.assertEqual(boot.xpath(".//input[@type='checkbox']/@value"), ["cdrom:sda", "disk:vda", "disk:vdb"])
self.assertEqual(boot.xpath(".//input[@checked]/@value"), ["cdrom:sda"])
# the devices in the order first, in the boot chain; the others below it
self.assertEqual(boot.xpath(".//li/@data-boot-value"), ["cdrom:sda", "disk:vda", "disk:vdb"])
self.assertEqual(boot.xpath(".//ol[@id='boot-chain']/li/@data-boot-value"), ["cdrom:sda"])
# the pending boot menu is on: its switch turns it off, also while the VM runs
self.assertEqual(doc.xpath("//input[@id='bootmenu']/ancestor::form/@action"),
[reverse("instances:unset_bootmenu", args=[self.instance.id])])
@ -323,8 +323,8 @@ class PendingEditsTestCase(TestCase):
doc = self.page(live=[disk("vda", "default", "")], config=[disk("vda", "default", "")],
get_bootorder=({}, {0: {"type": "file", "dev": "hd", "target": "disk"},
1: {"type": "file", "dev": "cdrom", "target": "cdrom"}}))
self.assertIn("disk, cdrom.", " ".join(doc.xpath("string(//p[@id='boot-by-type'])").split()))
self.assertEqual(doc.xpath("//div[@id='b_order']//input[@checked]"), [])
self.assertIn("by device type: disk, cdrom.", " ".join(doc.xpath("string(//p[@id='boot-chain-empty'])").split()))
self.assertEqual(doc.xpath("//ol[@id='boot-chain']/li"), [])
def test_a_boot_order_choice_not_in_the_definition_is_refused(self):
with patch("instances.models.wvmInstance") as wvm:
@ -337,7 +337,7 @@ class PendingEditsTestCase(TestCase):
with patch("instances.models.wvmInstance") as wvm:
response = self.client.post(reverse("instances:set_bootorder", args=[self.instance.id]), {"bootorder": ""})
wvm.return_value.set_bootorder.assert_not_called()
self.assertEqual([str(m) for m in get_messages(response.wsgi_request)], ["Check at least one device to boot from."])
self.assertEqual([str(m) for m in get_messages(response.wsgi_request)], ["Add at least one device to the boot order."])
def test_an_active_vm_says_its_settings_apply_at_the_next_start(self):
for status, shown in ((1, True), (3, True), (5, False)):

View file

@ -1816,7 +1816,7 @@ def set_bootorder(request, pk):
instance = get_instance(request.user, pk)
bootorder = request.POST.get("bootorder", "")
if not bootorder:
messages.error(request, _("Check at least one device to boot from."))
messages.error(request, _("Add at least one device to the boot order."))
return _back(request, pk, "boot_opt")
order_list = {}
for idx, val in enumerate(bootorder.split(",")):

Binary file not shown.

File diff suppressed because it is too large Load diff

Binary file not shown.

File diff suppressed because it is too large Load diff

View file

@ -120,7 +120,6 @@ th[aria-sort="ascending"]:not([data-sort-method="none"])::after {
padding-right: var(--bs-breadcrumb-item-padding-x);
}
/* the boot order editor numbers its checked devices, top to bottom */
/* the power tab's states: the current one filled, the others a ring */
.power-dot {
width: .8rem;
@ -131,26 +130,39 @@ th[aria-sort="ascending"]:not([data-sort-method="none"])::after {
}
.power-dot.bg-success, .power-dot.bg-warning, .power-dot.bg-secondary { border-color: transparent; }
.boot-sequence {
counter-reset: boot;
/* the boot order: the devices the VM tries in turn, numbered and joined by a line */
.boot-chain { counter-reset: boot; }
.boot-node { position: relative; padding: .5rem 0; }
.boot-chain > .boot-node { counter-increment: boot; }
.boot-step {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
border: 2px solid var(--bs-primary);
color: var(--bs-primary);
background: var(--bs-body-bg);
font-size: .8rem;
font-weight: 600;
position: relative;
z-index: 1;
}
.boot-sequence > label:has(> input:checked) {
counter-increment: boot;
}
.boot-rank {
width: 1rem;
text-align: center;
}
.boot-rank::before {
content: "—";
}
.boot-sequence > label:has(> input:checked) .boot-rank::before {
content: counter(boot);
.boot-chain .boot-step::before { content: counter(boot); }
.boot-chain > .boot-node:not(:last-child)::after {
content: "";
position: absolute;
left: calc(.875rem - 1px);
top: calc(50% + .875rem);
height: calc(100% - 1.75rem);
border-left: 2px solid var(--bs-border-color);
}
.boot-spare .boot-step { visibility: hidden; }
/* on a narrow screen a device's buttons go below its name, not over it */
.boot-text { min-width: 10rem; flex-basis: 10rem; }
.boot-chain .boot-spare-only, .boot-spare .boot-chain-only { display: none !important; }
/* a log message keeps a readable width when its table scrolls on a phone */
.log-message {

View file

@ -154,35 +154,37 @@
// every NIC of the clone a new address: one the VM has would clash with it
document.querySelectorAll("[data-new-mac]").forEach(function (input) { random_mac(input.name); });
// the boot order: the checked devices, top to bottom, as "disk:vda,cdrom:sda"
function set_orderlist() {
var order = $('#b_order input:checked').map(function () { return this.value; }).get();
$('#bootorder').val(order.join(','));
// the boot order: the devices of #boot-chain in turn, as "disk:vda,cdrom:sda"; each moves on its own
var chain = document.getElementById("boot-chain");
if (chain) {
var spare = document.getElementById("boot-spare");
var refresh = function () {
var nodes = Array.from(chain.children);
document.getElementById("bootorder").value = nodes.map(function (node) { return node.dataset.bootValue; }).join(",");
document.getElementById("boot-chain-empty").hidden = nodes.length > 0;
nodes.forEach(function (node, i) {
node.querySelector('[data-boot="up"]').disabled = i === 0;
node.querySelector('[data-boot="down"]').disabled = i === nodes.length - 1;
});
};
document.getElementById("boot_opt").addEventListener("click", function (event) {
var button = event.target.closest("[data-boot]");
if (!button) return;
var node = button.closest(".boot-node");
var action = button.dataset.boot;
if (action === "up") chain.insertBefore(node, node.previousElementSibling);
if (action === "down") chain.insertBefore(node.nextElementSibling, node);
if (action === "remove") spare.prepend(node);
if (action === "add") chain.append(node);
refresh();
// the keyboard stays on the device that moved
var same = { up: "up", down: "down", remove: "add", add: "remove" }[action];
var target = node.querySelector('[data-boot="' + same + '"]');
if (target.disabled) target = node.querySelector('.boot-chain-only [data-boot]:not([disabled])');
target.focus();
});
refresh();
}
set_orderlist();
$('#b_order').on('change', 'input', set_orderlist);
$('#boot_order_up').on('click', function () {
$('#b_order label input:checked').each(function () {
var label = $(this).parent();
var newPos = label.index() - 1;
if (newPos > -1) {
$('#b_order label').eq(newPos).before(label);
}
});
set_orderlist();
});
$('#boot_order_down').on('click', function () {
var countOptions = $('#b_order label').length;
var countSelected = $('#b_order label input:checked').length;
$('#b_order label input:checked').each(function () {
var label = $(this).parent();
var newPos = label.index() + countSelected;
if (newPos < countOptions) {
$('#b_order label').eq(newPos).after(label);
}
});
set_orderlist();
});
// the Host devices tab's add forms: loaded once, when the tab first opens
var hostDevices = document.getElementById("host-devices");