From a8f445d580464ac8eef9729c49f5a755f4088af1 Mon Sep 17 00:00:00 2001 From: catborise Date: Fri, 30 Oct 2026 07:59:11 +0300 Subject: [PATCH] feat(instances): a VM's network devices as cards, each with its QoS limits Settings > Network was a table whose fields were read-only inputs cut off at phone width, an unlabelled "+", three icon buttons per NIC and a separate QoS table keyed by MAC. The owner chose card layout from three rendered options, with the QoS limits folding out like the device details: - A card per NIC: a VM's only NIC spans the row, two or more sit side by side from md. Each has its name and target, the link state as a switch, its source and IPv4 addresses, and "Device details" (type, MAC, model, NWFilter, IPv6) in a
. - "QoS limits" in a second
, its summary naming the limited directions: a form per inbound or outbound limit, with Save and Delete limit, and the units. - Labelled Edit, Add QoS and Delete in the card footer; "Add network device" by the heading. The edit and add-QoS dialogs are titled and labelled, and the edit dialog's fields have labels. The limits come from each NIC of the persistent definition, so the QoS table, Instance.config_qos and wvmInstance.get_all_qos are removed. --- instances/models.py | 5 - .../templates/add_instance_network_block.html | 6 +- .../templates/instances/nic_qos_form.html | 26 ++ .../templates/instances/settings_tab.html | 347 ++++++------------ instances/tests/test_live_dataloss.py | 6 +- instances/tests/test_pending_edits.py | 31 +- instances/tests/test_views.py | 10 +- networks/templates/add_network_qos.html | 7 +- static/icon_cache/ethernet_None_None_None.svg | 11 + vrtManager/instance.py | 42 --- vrtManager/tests/test_device_edits.py | 4 +- 11 files changed, 184 insertions(+), 311 deletions(-) create mode 100644 instances/templates/instances/nic_qos_form.html create mode 100644 static/icon_cache/ethernet_None_None_None.svg diff --git a/instances/models.py b/instances/models.py index 5f55aeda..27dcc3d3 100644 --- a/instances/models.py +++ b/instances/models.py @@ -168,11 +168,6 @@ class Instance(models.Model): edit does not post the live value back over it.""" return self.proxy.get_disk_devices(config=True) - @cached_property - def config_qos(self): - """QoS of the persistent definition, which the QoS form changes.""" - return self.proxy.get_all_qos(config=True) - @cached_property def config_vcpu(self) -> int: """vCPUs of the persistent definition, which the CPU form changes.""" diff --git a/instances/templates/add_instance_network_block.html b/instances/templates/add_instance_network_block.html index 72bf2a8f..62028d35 100644 --- a/instances/templates/add_instance_network_block.html +++ b/instances/templates/add_instance_network_block.html @@ -1,16 +1,14 @@ {% load i18n %} {% load bootstrap_icons %} {% if request.user.is_superuser %} - + - {% endif %} -
+ {% if show_hostdevs %}
diff --git a/instances/tests/test_live_dataloss.py b/instances/tests/test_live_dataloss.py index 3e6a0893..0c465659 100644 --- a/instances/tests/test_live_dataloss.py +++ b/instances/tests/test_live_dataloss.py @@ -602,9 +602,9 @@ class LiveDataLossTestCase(TestCase): def row(): page = html.fromstring(self.client.get(reverse("instances:instance", args=[inst.id])).content) - (label,) = page.xpath("//label[@class='col-form-label'][contains(., 'Inbound')]") - fields = label.xpath("ancestor::tr[1]//input[@name]") # the browser posts the row's inputs - return {f.get("name"): f.get("value") for f in fields} + # the browser posts the inputs of the NIC's inbound limit form + (form,) = page.xpath("//form[input[@name='qos_direction'][@value='inbound']]") + return {f.get("name"): f.get("value") for f in form.xpath(".//input[@name!='csrfmiddlewaretoken']")} self.post("set_qos", inst, {**row(), "qos_average": "1000"}) form = row() diff --git a/instances/tests/test_pending_edits.py b/instances/tests/test_pending_edits.py index 3ea28e90..5d87ebcc 100644 --- a/instances/tests/test_pending_edits.py +++ b/instances/tests/test_pending_edits.py @@ -28,8 +28,8 @@ class PendingEditsTestCase(TestCase): compute = Compute.objects.create(name="pe", hostname="127.0.0.1:1", login="root", password="", type=1) self.instance = Instance.objects.create(compute=compute, name="pe-vm", uuid="aaaaaaaa-1111-2222-3333-444444444444") - def page(self, live=(), config=(), qos=({}, {}), status=1, **getters): - """live and config: disks; qos and getters (get_vcpu=(2, 4)): values of + def page(self, live=(), config=(), nics=((), ()), status=1, **getters): + """live and config: disks; nics and getters (get_vcpu=(2, 4)): values of the live and the persistent definition.""" with patch("instances.models.wvmInstance") as wvm, patch.object( Compute, "status", new_callable=PropertyMock, return_value=True @@ -44,12 +44,12 @@ class PendingEditsTestCase(TestCase): getattr(proxy, getter).side_effect = lambda config=False, values=values: values[config] proxy.get_max_cpus.return_value = [1, 2, 3, 4] proxy.get_vcpus.return_value = {} - for getter in ("get_networks", "get_ifaces", "get_storages", "get_media_devices", "get_net_devices", + for getter in ("get_networks", "get_ifaces", "get_storages", "get_media_devices", "get_snapshot", "get_snapshot_jobs"): getattr(proxy, getter).return_value = [] proxy.get_hostdevs.return_value = ([], [], []) proxy.get_disk_devices.side_effect = lambda **kw: config if kw.get("config") else live - proxy.get_all_qos.side_effect = lambda **kw: qos[1] if kw.get("config") else qos[0] + proxy.get_net_devices.side_effect = lambda config=False: [dict(nic) for nic in nics[config]] proxy.get_console_type.return_value = "vnc" proxy.get_video_models.return_value = ["vga", "virtio"] proxy.get_cache_modes.return_value = {"default": "Default", "none": "Disabled"} @@ -70,13 +70,24 @@ class PendingEditsTestCase(TestCase): self.assertEqual(vda.xpath("string(.//input[@name='vol_serial']/@value)"), "") def test_the_qos_form_shows_the_pending_definition(self): - mac = "52:54:10:00:00:01" - inbound = {"direction": "inbound", "average": "500", "peak": "0", "burst": "0", "floor": None} - doc = self.page(qos=({mac: [inbound]}, {mac: [{**inbound, "average": "1000"}, {**inbound, "direction": "outbound"}]})) + nic = {"type": "network", "mac": "52:54:10:00:00:01", "nic": "default", "state": "up", "model": "virtio", + "ipv4": None, "ipv6": None, "filterref": "", "target": "vnet0", "outbound": [], + "inbound": {"average": "500", "peak": "0", "burst": "0"}} + pending = {**nic, "inbound": {"average": "1000", "peak": "0", "burst": "0"}, "outbound": nic["inbound"]} + doc = self.page(nics=([nic], [pending])) + # the pending inbound value, and the pending outbound limit has a form self.assertEqual(doc.xpath("//input[@name='qos_average']/@value"), ["1000", "500"]) - # the pending outbound has a row - self.assertEqual([" ".join(t.split()) for t in doc.xpath("//label[@class='col-form-label']/text()")], - [f"{mac} Inbound", f"{mac} Outbound"]) + self.assertEqual(doc.xpath("//form[.//input[@name='qos_average']]//input[@name='qos_direction']/@value"), + ["inbound", "outbound"]) + + def test_one_nic_card_spans_the_row_and_more_sit_side_by_side(self): + nic = {"type": "network", "mac": "52:54:10:00:00:01", "nic": "default", "state": "up", "model": "virtio", + "ipv4": None, "ipv6": None, "filterref": "", "target": "", "inbound": [], "outbound": []} + second = {**nic, "mac": "52:54:10:00:00:02"} + for nics, wide in (([nic], ["col-12"]), ([nic, second], ["col-12 col-md-6"] * 2)): + with self.subTest(count=len(nics)): + doc = self.page(nics=(nics, nics)) + self.assertEqual(doc.xpath("//div[@id='network']//div[article]/@class"), wide) def test_one_disk_card_spans_the_row_and_more_sit_side_by_side(self): for disks, wide in (([disk("vda", "default", "")], ["col-12"]), diff --git a/instances/tests/test_views.py b/instances/tests/test_views.py index f2ba1efd..1c48a3e2 100644 --- a/instances/tests/test_views.py +++ b/instances/tests/test_views.py @@ -1012,7 +1012,7 @@ class InstancesTestCase(TestCase): self.assertEqual(self.instance.networks[0]["state"], "down") def test_set_unset_qos(self): - self.assertEqual(len(self.instance.config_qos.keys()), 0) + self.assertFalse(self.instance.config_networks[0]["inbound"]) net_mac = self.instance.networks[0]["mac"] response = self.client.post( reverse("instances:set_qos", args=[self.instance.id]), @@ -1027,8 +1027,8 @@ class InstancesTestCase(TestCase): ) self.assertEqual(response.status_code, 302) - del self.instance.config_qos - self.assertEqual(len(self.instance.config_qos.keys()), 1) + del self.instance.config_networks + self.assertEqual(self.instance.config_networks[0]["inbound"]["average"], "1") response = self.client.post( reverse("instances:unset_qos", args=[self.instance.id]), @@ -1039,8 +1039,8 @@ class InstancesTestCase(TestCase): HTTP_REFERER=reverse("index"), ) self.assertEqual(response.status_code, 302) - del self.instance.config_qos - self.assertEqual(len(self.instance.config_qos.keys()), 0) + del self.instance.config_networks + self.assertFalse(self.instance.config_networks[0]["inbound"]) # test on running instance # self.instance.proxy.start() diff --git a/networks/templates/add_network_qos.html b/networks/templates/add_network_qos.html index 3d0d248b..64bc425b 100644 --- a/networks/templates/add_network_qos.html +++ b/networks/templates/add_network_qos.html @@ -1,17 +1,12 @@ {% load i18n %} -{% load bootstrap_icons %} {% if request.user.is_superuser %} - - - -