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:
parent
c247a7a3f0
commit
4565221c5c
12 changed files with 740 additions and 688 deletions
|
|
@ -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,
|
||||
|
|
|
|||
19
instances/templates/instances/boot_device.html
Normal file
19
instances/templates/instances/boot_device.html
Normal 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>
|
||||
|
|
@ -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 #}
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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)):
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue