diff --git a/computes/templates/computes/instances.html b/computes/templates/computes/instances.html index 8a41bc96..ca5bb03c 100644 --- a/computes/templates/computes/instances.html +++ b/computes/templates/computes/instances.html @@ -87,18 +87,18 @@ - {% if instance.proxy.instance.info.0 == 1 %} + {% if instance.info.0 == 1 %} {% trans "Active" %} {% endif %} - {% if instance.proxy.instance.info.0 == 5 %} + {% if instance.info.0 == 5 %} {% trans "Off" %} {% endif %} - {% if instance.proxy.instance.info.0 == 3 %} + {% if instance.info.0 == 3 %} {% trans "Suspended" %} {% endif %} - {{ instance.proxy.instance.info.3 }} -

{% widthratio instance.proxy.instance.info.1 1024 1 %} MiB

+ {{ instance.info.3 }} +

{% widthratio instance.info.1 1024 1 %} MiB

{% include 'instance_actions.html' %} diff --git a/instances/models.py b/instances/models.py index 91fb4472..0ca07c47 100644 --- a/instances/models.py +++ b/instances/models.py @@ -63,6 +63,11 @@ class Instance(models.Model): def disks(self) -> list[dict]: return self.proxy.get_disk_devices() + @cached_property + def info(self) -> list: + """virDomainGetInfo, read once: state, max memory KiB, memory KiB, vCPUs, CPU time""" + return self.proxy.instance.info() + @cached_property def status(self) -> int: return self.proxy.get_status() diff --git a/instances/templates/allinstances_index_grouped.html b/instances/templates/allinstances_index_grouped.html index 290abe03..8e9f7f9a 100644 --- a/instances/templates/allinstances_index_grouped.html +++ b/instances/templates/allinstances_index_grouped.html @@ -62,9 +62,9 @@ - {% if instance.proxy.instance.info.0 == 1 %}{% trans "Active" %}{% endif %} - {% if instance.proxy.instance.info.0 == 5 %}{% trans "Off" %}{% endif %} - {% if instance.proxy.instance.info.0 == 3 %} + {% if instance.info.0 == 1 %}{% trans "Active" %}{% endif %} + {% if instance.info.0 == 5 %}{% trans "Off" %}{% endif %} + {% if instance.info.0 == 3 %} {% trans "Suspended" %} {% endif %} @@ -73,8 +73,8 @@ {% if instance.drbd == "Primary/OK" or instance.drbd == "Secondary/OK" %}{% else %}{% endif %}{{ instance.drbd }} {% endif %} - {{ instance.proxy.instance.info.3 }} - {{ instance.cur_memory }} MB + {{ instance.info.3 }} + {% widthratio instance.info.2 1024 1 %} MB {% include 'instance_actions.html' %} diff --git a/instances/templates/allinstances_index_nongrouped.html b/instances/templates/allinstances_index_nongrouped.html index c5b06b66..5a35c243 100644 --- a/instances/templates/allinstances_index_nongrouped.html +++ b/instances/templates/allinstances_index_nongrouped.html @@ -40,11 +40,11 @@ {% endif %} - {% if instance.proxy.instance.info.0 == 1 %}{% trans "Active" %}{% endif %} - {% if instance.proxy.instance.info.0 == 5 %}{% trans "Off" %}{% endif %} - {% if instance.proxy.instance.info.0 == 3 %}{% trans "Suspended" %}{% endif %} {% if app_settings.VM_DRBD_STATUS == 'True' %} @@ -52,8 +52,8 @@ {% if instance.drbd == "Primary/OK" or instance.drbd == "Secondary/OK" %}{% else %}{% endif %}{{ instance.drbd }} {% endif %} - {{ instance.proxy.instance.info.3 }} - {{ instance.cur_memory }} MB + {{ instance.info.3 }} + {% widthratio instance.info.2 1024 1 %} MB {% include 'instance_actions.html' %} diff --git a/instances/templates/instance_actions.html b/instances/templates/instance_actions.html index f3f24fdc..f1b56e86 100644 --- a/instances/templates/instance_actions.html +++ b/instances/templates/instance_actions.html @@ -1,7 +1,7 @@ {% load i18n %} {% load bootstrap_icons %}
- {% if instance.proxy.instance.info.0 == 5 %} + {% if instance.info.0 == 5 %} {% if instance.is_template %} {% bs_icon 'copy' %} @@ -21,7 +21,7 @@ {% bs_icon 'eye-fill' %} {% endif %} - {% if instance.proxy.instance.info.0 == 3 %} + {% if instance.info.0 == 3 %}
{% csrf_token %} {% endif %} - {% if instance.proxy.instance.info.0 == 1 %} + {% if instance.info.0 == 1 %} {% csrf_token %} diff --git a/instances/test_list_rpc.py b/instances/test_list_rpc.py new file mode 100644 index 00000000..f8151c99 --- /dev/null +++ b/instances/test_list_rpc.py @@ -0,0 +1,86 @@ +"""Instance lists ask libvirt for a VM's info once per row.""" + +from types import SimpleNamespace +from unittest.mock import PropertyMock, patch + +from appsettings.models import AppSettings +from computes.models import Compute +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from instances.models import Instance + +# virDomainGetInfo: state, max memory KiB, memory KiB, vCPUs, CPU time +INFO = [1, 4194304, 2097152, 3, 0] + + +class FakeDomain: + # a plain class: Django templates do not call MagicMock attributes + def __init__(self): + self.info_calls = 0 + self.xml_calls = 0 + + def info(self): + self.info_calls += 1 + return INFO + + def XMLDesc(self, flags=0): + self.xml_calls += 1 + return "2097152" + + +class ListInfoCallTests(TestCase): + def setUp(self): + self.client.force_login( + get_user_model().objects.create_superuser(username="rpc-admin", password="pwd", email="rpc@example.com") + ) + self.compute = Compute.objects.create(name="rpc-compute", hostname="localhost", type=4, login="", password="") + Instance.objects.create(compute=self.compute, name="vm-a", uuid="11111111-1111-1111-1111-111111111111") + Instance.objects.create(compute=self.compute, name="vm-b", uuid="22222222-2222-2222-2222-222222222222") + self.domains = [] + + def proxy(self, *args, **kwargs): + domain = FakeDomain() + self.domains.append(domain) + return SimpleNamespace( + instance=domain, + get_uuid=lambda: "uuid", + get_title=lambda: domain.XMLDesc() and "", + get_cur_memory=lambda: int(domain.XMLDesc().split(">")[4].split("<")[0]) // 1024, + ) + + def get(self, url): + compute_patches = {"status": True, "cpu_count": 1, "ram_size": 1024, "ram_usage": 0, "cpu_usage": 0} + with patch("instances.views.utils.refr"), patch("computes.views.utils.refresh_instance_database"), patch( + "instances.models.wvmInstance", side_effect=self.proxy + ): + patches = [patch.object(Compute, k, new_callable=PropertyMock, return_value=v) for k, v in compute_patches.items()] + for p in patches: + p.start() + try: + response = self.client.get(url) + finally: + for p in patches: + p.stop() + self.assertEqual(response.status_code, 200) + return response + + def assert_one_info_per_vm(self, response, memory="2048 MB"): + self.assertEqual(len(self.domains), 2, "one proxy per VM row") + for domain in self.domains: + self.assertEqual(domain.info_calls, 1) + self.assertEqual(domain.xml_calls, 1, "only the title reads the XML") + self.assertContains(response, memory, count=2) + self.assertContains(response, "Active") + + def test_nongrouped_index(self): + AppSettings.objects.filter(key="VIEW_INSTANCES_LIST_STYLE").update(value="nongrouped") + self.assert_one_info_per_vm(self.get(reverse("instances:index"))) + + def test_grouped_index(self): + AppSettings.objects.filter(key="VIEW_INSTANCES_LIST_STYLE").update(value="grouped") + self.assert_one_info_per_vm(self.get(reverse("instances:index"))) + + def test_compute_instances(self): + # this page shows the maximum memory, as before + self.assert_one_info_per_vm(self.get(reverse("instances", args=[self.compute.id])), memory="4096 MiB")