@@ -55,41 +55,21 @@
{% if given %}
- {{ given.ratio|floatformat:1 }}:1
- {% blocktrans count counter=given.vcpus.all %}{{ counter }} vCPU{% plural %}{{ counter }} vCPUs{% endblocktrans %} {% blocktrans count counter=given.cpus %}on {{ counter }} CPU{% plural %}on {{ counter }} CPUs{% endblocktrans %}
- {% blocktrans with running=given.vcpus.running %}{{ running }} running{% endblocktrans %}
+ {% include "computes/capacity_vcpus.html" %}
- {% blocktrans with all=given.assigned.all|filesizeformat memory=given.memory|filesizeformat %}{{ all }} given of {{ memory }}{% endblocktrans %}
-
- {% blocktrans with running=given.assigned.running|filesizeformat %}{{ running }} of it running{% endblocktrans %}
+ {% include "computes/capacity_memory.html" %}
- {% if given.pool %}{% include "computes/pool_bar.html" with pool=given.pool %}{% endif %}
- {% if given.more_pools %}
-
- {% blocktrans count counter=given.more_pools|length %}{{ counter }} more pool{% plural %}{{ counter }} more pools{% endblocktrans %}
- {% for pool in given.more_pools %}
- {% include "computes/pool_bar.html" %}
- {% endfor %}
-
- {% endif %}
+ {% include "computes/capacity_storage.html" %}
{% else %}
{% if compute.status is True %}{% trans "The capacity could not be read." %}{% else %}{% trans "No capacity while the host cannot be reached." %}{% endif %}
{% endif %}
diff --git a/computes/templates/overview.html b/computes/templates/overview.html
index efc5a40e..fda5fcb3 100644
--- a/computes/templates/overview.html
+++ b/computes/templates/overview.html
@@ -5,97 +5,154 @@
{% block title %}{% trans "Overview" %} - {{ compute.name }}{% endblock %}
+{% block page_heading_extra %}
+{% bs_icon 'pencil' %} {% trans "Edit connection" %}
+{% bs_icon 'x-circle' %} {% trans "Remove from panel" %}
+{% endblock page_heading_extra %}
+
{% block page_heading %}{{ compute.name }}{% endblock page_heading %}
{% block content %}
-
- {% include "computes/host_nav.html" with section="overview" %}
-
+
+
+ {% if connected %}
+ {% trans "Connected" %}
+ {% else %}
+ {% trans "Not connected" %}
+ {% endif %}
+
+ {{ compute.hostname }}
+ {% if compute.details %}{{ compute.details }} {% endif %}
+
-
-
-
- {% trans "Hostname" %}
- {{ hostname }}
- {% trans "Hypervisors" %}
-
-
- {% for arch, hpv in hypervisor.items|slice:":4" %}
-
- {{ arch }}
-
-
- {% endfor %}
-
-
- {% if hypervisor.items|length > 4 %}
-
- {{ hypervisor.items|slice:"4:"|length }} {% trans 'more' %}...
-
-
- {% endif %}
-
-
- {% trans "Emulator" %}
- {{ emulator }}
- {% trans "Version" %}
-
- {% trans 'Qemu' %}
- {{ version }}
- {% trans 'Libvirt' %}
- {{ lib_version }}
-
- {% trans "Memory" %}
- {{ host_memory|filesizeformat }}
- {% trans "Architecture" %}
- {{ host_arch }}
- {% trans "Logical CPUs" %}
- {{ logical_cpu }}
- {% trans "Processor" %}
- {{ model_cpu }}
- {% trans "Connection" %}
- {{ uri_conn }}
- {% trans "Details" %}
- {{ compute.details }}
+{% include "computes/host_nav.html" with section="overview" %}
+
+{% if not connected %}
+
+{% else %}
+
+{% if read_error %}
+{% trans "The host's details could not be read:" %} {{ read_error }}
+{% endif %}
+
+
+{% if given %}
+
+
+
+
{% trans "VMs" %}
+
{{ given.vm_count }}
+
+ {% blocktrans with running=given.vms.running shutoff=given.vms.shutoff %}{{ running }} running, {{ shutoff }} shut off{% endblocktrans %}{% if given.vms.paused %}, {% blocktrans with paused=given.vms.paused %}{{ paused }} paused{% endblocktrans %}{% endif %}{% if given.vms.other %}, {% blocktrans with other=given.vms.other %}{{ other }} other{% endblocktrans %}{% endif %}
+
+
{% trans "Instances" %} →
+
+
+
+
+
{% trans "vCPUs" %}
+ {% include "computes/capacity_vcpus.html" %}
+
+
+
+
+
{% trans "Memory" %}
+ {% include "computes/capacity_memory.html" %}
+
+
+
+
+{% else %}
+{% trans "The capacity could not be read." %}
+{% endif %}
+
+{# the load is read on its own: shown also when the details could not be #}
+
+
+
+
+
{% trans "CPU in use" %} –
+
+
+
+
+
+
{% trans "Memory in use" %} –
+
+
+
+
+
+ {% trans "The last 10 readings, one every 5 seconds. Memory in use leaves out the host's buffers and cache." %}
+ {% trans "Waiting for the first reading." %}
+
+
+{% if host %}
+
+
+
+
+ {% trans "Hostname" %}
+ {{ host.hostname }}
+ {% trans "Architecture" %}
+ {{ host.arch }}
+ {% trans "Processor" %}
+ {{ host.processor|default:"–" }}
+ {% trans "Logical CPUs" %}
+ {{ host.topology.cpus }}
+ {% trans "Physical cores" %}
+ {{ host.topology.cores|default:"–" }}
+ {% trans "Sockets" %}
+ {{ host.topology.sockets|default:"–" }}
+ {% trans "NUMA nodes" %}
+ {{ host.topology.nodes|default:"–" }}
+ {% trans "Clock" %}
+ {{ host.topology.mhz }} MHz
+ {% trans "Memory" %}
+ {{ host.memory|filesizeformat }}
-
-
-
-
-
-
- {% bs_icon 'arrow-right' %}
- {% trans "CPU Utilization" %}
-
-
-
-
-
-
-
-
- {% bs_icon 'arrow-right'%} {% trans "RAM Utilization" %}
-
-
-
-
-
-
+
+
+
+
+ {% trans "Hypervisor" %}
+ {{ host.hypervisor }} {{ host.hypervisor_version }}
+ libvirt
+ {{ host.libvirt_version }}
+ {% trans "Emulator" %}
+ {{ host.emulator|default:"–" }}
+ {% trans "Guest architectures" %}
+
+ {% for arch, types in host.guest_archs.items %}
+ {{ arch }} ({{ types|join:", " }})
+ {% endfor %}
+
+ {% trans "Connection" %}
+ {{ host.uri }}
+
+
+
+{% endif %}
+{% endif %}
{% endblock %}
+
{% block script %}
+{% if connected %}
+{% endif %}
{% endblock %}
diff --git a/computes/tests/test_capacity.py b/computes/tests/test_capacity.py
index 785a7ad3..d7cb6107 100644
--- a/computes/tests/test_capacity.py
+++ b/computes/tests/test_capacity.py
@@ -11,6 +11,7 @@ from libvirt import libvirtError
from lxml import html
GIB = 1024 ** 3
+VMS = {"running": 3, "paused": 1, "shutoff": 2, "other": 0}
def pool(name, size_gib, free_gib, status=1):
@@ -33,6 +34,14 @@ class CapacityTests(TestCase):
"cpus": 16, "vcpus": {"all": 24, "running": 8}, "ratio": 1.5,
"pools": [pool("default", 500, 120), pool("iso", 100, 90), pool("nfs", 900, 800)]})
+ @patch("computes.models.wvmStorages")
+ @patch("computes.models.wvmHostDetails")
+ def test_pools_that_cannot_be_read_leave_the_cpus_and_memory(self, details, storages):
+ details.return_value.get_capacity.return_value = {"cpus": 16, "vcpus": {"all": 24, "running": 8}}
+ storages.return_value.get_storages_info.side_effect = libvirtError("cannot refresh pool")
+ self.assertEqual(self.compute.capacity, {
+ "cpus": 16, "vcpus": {"all": 24, "running": 8}, "ratio": 1.5, "pools": None})
+
@patch("computes.models.wvmHostDetails")
def test_a_host_whose_read_fails_has_no_capacity(self, details):
# a VM that goes while the host is read, for one
@@ -53,7 +62,7 @@ class ComputesPageTests(TestCase):
c1 = Compute.objects.create(name="host-one", hostname="192.0.2.1", login="u", password="p", type=1)
Compute.objects.create(name="host-two", hostname="192.0.2.2", login="u", password="p", type=1)
found = {c1.id: {"cpus": 16, "memory": 64 * GIB, "vcpus": {"all": 40, "running": 24},
- "assigned": {"all": 48 * GIB, "running": 32 * GIB}, "ratio": 2.5,
+ "assigned": {"all": 48 * GIB, "running": 32 * GIB}, "ratio": 2.5, "vms": VMS,
"pools": [pool("default", 500, 120)]}}
with patch.object(Compute, "capacity", new=property(lambda c: found.get(c.id))), \
patch.object(Compute, "status", new=True):
@@ -77,7 +86,7 @@ class ComputesPageTests(TestCase):
Compute.objects.create(name="host-one", hostname="192.0.2.1", login="u", password="p", type=1)
pools = [pool("default", 500, 120), pool("backup", 900, 800), pool("iso", 100, 90), pool("nfs", 2000, 1000)]
given = {"cpus": 16, "memory": 64 * GIB, "vcpus": {"all": 8, "running": 8}, "ratio": 0.5,
- "assigned": {"all": 8 * GIB, "running": 8 * GIB}, "pools": pools}
+ "assigned": {"all": 8 * GIB, "running": 8 * GIB}, "pools": pools, "vms": VMS}
with patch.object(Compute, "capacity", new=given), patch.object(Compute, "status", new=True):
page = html.fromstring(self.client.get(reverse("computes")).content)
(storage,) = page.xpath("//tbody/tr/td[4]")
@@ -95,7 +104,7 @@ class SingularTests(TestCase):
self.client.force_login(admin)
c = Compute.objects.create(name="host-one", hostname="192.0.2.1", login="u", password="p", type=1)
given = {"cpus": 1, "memory": 4 * GIB, "vcpus": {"all": 1, "running": 1},
- "assigned": {"all": GIB, "running": GIB}, "ratio": 1.0, "pools": []}
+ "assigned": {"all": GIB, "running": GIB}, "ratio": 1.0, "pools": [], "vms": VMS}
with patch.object(Compute, "capacity", new=given), patch.object(Compute, "status", new=True):
page = html.fromstring(self.client.get(reverse("computes")).content)
line = self.client.get(reverse("compute_capacity", args=[c.id])).content.decode()
diff --git a/computes/tests/test_cpu_graph.py b/computes/tests/test_cpu_graph.py
index bbb509f1..30ed4d57 100644
--- a/computes/tests/test_cpu_graph.py
+++ b/computes/tests/test_cpu_graph.py
@@ -24,6 +24,7 @@ from vrtManager.util import ConnectionFailed
# the page script runs after the shared chart helpers
CHARTS_JS = settings.BASE_DIR / "static" / "js" / "charts.js"
+TOPOLOGY = {"nodes": 1, "sockets": 1, "cores": 2, "threads": 1, "mhz": 2400, "cpus": 2}
def host(*readings):
@@ -138,17 +139,20 @@ class GraphScriptTestCase(TestCase):
compute = Compute.objects.create(name="gs", hostname="192.0.2.1", login="u", password="p", type=1)
with patch("computes.views.wvmHostDetails") as details, patch.object(
Compute, "status", new_callable=PropertyMock, return_value=True
- ):
+ ), patch.object(Compute, "capacity", new=None):
details.return_value.get_node_info.return_value = ("h", "x86_64", 1024, 2, "cpu", "qemu+tcp://h/system")
- details.return_value.get_memory_usage.return_value = {"total": 1024, "usage": 512, "percent": 50}
+ details.return_value.get_cpu_topology.return_value = TOPOLOGY
page = self.client.get(reverse("overview", args=[compute.id])).content.decode()
script = next(s for s in html.fromstring(page).xpath("//script[not(@src)]/text()") if "pollWhileVisible(" in s)
js = """
const vm = require("vm");
const charts = [], requests = [];
+ const element = (dataset) => ({dataset, textContent: "", classList: {add: () => {}, remove: () => {}}});
+ const elements = {graph_state: element({updated: "Updated at %(time)s.", stale: "Not updated since %(time)s."}),
+ cpu_now: element({}), mem_now: element({})};
let poll;
const page = {
- document: {body: {}, getElementById: () => ({})},
+ document: {body: {}, getElementById: (id) => elements[id] || {}},
// Chart.js adds the lists a config leaves out
getComputedStyle: () => ({getPropertyValue: () => ""}),
Chart: Object.assign(function (canvas, config) {
@@ -158,21 +162,23 @@ class GraphScriptTestCase(TestCase):
this.update = () => {};
charts.push(this);
}, {defaults: {}}),
- $: {getJSON: (url, params) => { requests.push(params); return {}; }},
+ $: {getJSON: (url, params) => { requests.push(params); return {fail() { return this; }}; }},
pollWhileVisible: (interval, request, onData) => { poll = {request, onData}; },
};
const context = vm.createContext(page);
vm.runInContext(CHARTS, context);
vm.runInContext(SCRIPT, context);
const answer = (cpu, sample) => ({cpudata: cpu, window: 5.1, cpu_sample: sample, timeline: "t",
- memdata: {total: 1048576, usage: 0}});
+ memdata: {total: 2 * 1073741824, usage: 1073741824}});
poll.request();
poll.onData(answer(3, "token-1"));
+ const shown = [elements.cpu_now.textContent, elements.mem_now.textContent, elements.graph_state.textContent];
poll.request();
poll.onData({cpudata: null, memdata: {total: null, usage: null}, timeline: "t"});
poll.request();
const label = charts[0].options.plugins.tooltip.callbacks.label({dataset: {label: "Usage"}, formattedValue: "3", dataIndex: 0});
- console.log(JSON.stringify({requests, label, cpu: charts[0].data.datasets[0].data,
+ console.log(JSON.stringify({requests, label, shown, state: elements.graph_state.textContent,
+ cpu: charts[0].data.datasets[0].data,
mem: charts[1].data.datasets[0].data, memMax: charts[1].options.scales.y.max}));
""".replace("SCRIPT", json.dumps(script)).replace("CHARTS", json.dumps(CHARTS_JS.read_text()))
out = subprocess.run(["node", "-e", js], capture_output=True, text=True, timeout=30)
@@ -181,5 +187,8 @@ class GraphScriptTestCase(TestCase):
self.assertEqual(result["requests"], [{}, {"cpu_sample": "token-1"}, {}])
self.assertEqual(result["cpu"], [3, None])
# a host that could not be read leaves a gap and keeps the memory axis
- self.assertEqual((result["mem"], result["memMax"]), ([0, None], 1))
+ self.assertEqual((result["mem"], result["memMax"]), ([1, None], 2))
+ # the readings in words, and since when they are not updated
+ self.assertEqual(result["shown"], ["3 %", "1 / 2 GB", "Updated at t."])
+ self.assertEqual(result["state"], "Not updated since t.")
self.assertTrue(result["label"].endswith(": 3 % (average over 5.1 s)"), result["label"])
diff --git a/computes/tests/test_overview.py b/computes/tests/test_overview.py
new file mode 100644
index 00000000..e482efc8
--- /dev/null
+++ b/computes/tests/test_overview.py
@@ -0,0 +1,94 @@
+"""The host overview page: a host the panel reaches, one it cannot reach,
+and one whose details cannot be read."""
+
+from unittest.mock import PropertyMock, patch
+
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+from django.urls import reverse
+from libvirt import libvirtError
+from lxml import html
+
+from computes.models import Compute
+
+GIB = 1024 ** 3
+GIVEN = {"cpus": 8, "memory": 32 * GIB, "vcpus": {"all": 12, "running": 6},
+ "assigned": {"all": 24 * GIB, "running": 12 * GIB}, "ratio": 1.5,
+ "vms": {"running": 3, "paused": 1, "shutoff": 2, "other": 0},
+ "pools": [{"name": "default", "status": 1, "size": 500 * GIB, "free": 120 * GIB}]}
+
+
+def text(page, xpath):
+ return " ".join(" ".join(page.xpath(xpath)[0].itertext()).split())
+
+
+class OverviewTestCase(TestCase):
+ def setUp(self):
+ self.client.force_login(get_user_model().objects.create_superuser("ov-admin", "ov@example.com", "pw"))
+ self.compute = Compute.objects.create(name="host-one", hostname="192.0.2.1", login="u", password="p",
+ type=1, details="rack 4")
+ self.url = reverse("overview", args=[self.compute.id])
+
+ def page(self, connected=True, given=GIVEN):
+ with patch.object(Compute, "status", new_callable=PropertyMock, return_value=connected), \
+ patch.object(Compute, "connection_error", new_callable=PropertyMock,
+ return_value="Connection Failed: 192.0.2.1 did not answer within 5 s"), \
+ patch.object(Compute, "capacity", new=given and {**given}):
+ response = self.client.get(self.url)
+ self.assertEqual(response.status_code, 200)
+ return html.fromstring(response.content)
+
+ @patch("computes.views.wvmHostDetails")
+ def test_a_host_shows_its_vms_capacity_hardware_and_software(self, details):
+ conn = details.return_value
+ conn.get_node_info.return_value = ("wvc-host", "x86_64", 32 * GIB, 8, "Xeon", "qemu+tcp://192.0.2.1/system")
+ conn.get_cpu_topology.return_value = {"nodes": 2, "sockets": 2, "cores": 4, "mhz": 2400, "cpus": 8}
+ conn.wvm.getType.return_value = "QEMU"
+ conn.get_version.return_value = "10.1.0"
+ conn.get_lib_version.return_value = "11.10.0"
+ conn.get_emulator.return_value = "/usr/libexec/qemu-kvm"
+ conn.get_hypervisors_domain_types.return_value = {"i686": ["kvm", "qemu"], "x86_64": ["kvm", "qemu"]}
+ page = self.page()
+ self.assertIn("Connected", text(page, "//p[span]"))
+ self.assertIn("rack 4", text(page, "//p[span]"))
+ cards = [" ".join(" ".join(card.itertext()).split()) for card in page.xpath("//div[@class='card-body' or contains(@class,'card-body ')]")]
+ self.assertIn("VMs 6 3 running, 2 shut off, 1 paused Instances →", cards)
+ self.assertTrue(any(card.startswith("vCPUs 1.5:1 12 vCPUs on 8 CPUs 6 running") for card in cards), cards)
+ self.assertTrue(any("24.0 GB given of 32.0 GB" in card for card in cards), cards)
+ self.assertTrue(any("120.0 GB free of 500.0 GB" in card for card in cards), cards)
+ hardware = text(page, "//h4[.='Hardware']/following-sibling::dl")
+ self.assertIn("Logical CPUs 8 Physical cores 4 Sockets 2", hardware)
+ self.assertIn("NUMA nodes 2", hardware)
+ software = text(page, "//h4[.='Software and connection']/following-sibling::dl")
+ self.assertIn("Hypervisor QEMU 10.1.0", software)
+ self.assertIn("x86_64 (kvm, qemu)", software)
+ # the charts poll only for a host that was read
+ self.assertTrue(page.xpath("//canvas[@id='cpuChart']"))
+
+ @patch("computes.views.wvmHostDetails")
+ def test_a_host_the_panel_cannot_reach_keeps_its_page_with_the_reason(self, details):
+ page = self.page(connected=False, given=None)
+ details.assert_not_called()
+ self.assertIn("Not connected", text(page, "//p[span]"))
+ self.assertIn("did not answer within 5 s", text(page, "//div[@role='alert']"))
+ # the way back: its sections, its connection and its removal
+ self.assertTrue(page.xpath("//nav[@aria-label='Host sections']"))
+ self.assertTrue(page.xpath(f"//a[@href='{reverse('compute_update', args=[self.compute.id])}']"))
+ self.assertTrue(page.xpath(f"//a[@href='{reverse('compute_delete', args=[self.compute.id])}']"))
+ self.assertFalse(page.xpath("//canvas"))
+
+ @patch("computes.views.wvmHostDetails")
+ def test_details_that_cannot_be_read_leave_the_capacity_and_the_load(self, details):
+ details.return_value.get_node_info.return_value = ("h", "x86_64", GIB, 2, None, "qemu+tcp://192.0.2.1/system")
+ details.return_value.get_lib_version.side_effect = libvirtError("cannot read the version")
+ page = self.page()
+ self.assertIn("cannot read the version", text(page, "//div[@role='alert']"))
+ self.assertTrue(page.xpath("//h5[.='VMs']"))
+ # the load is read on its own, by the charts
+ self.assertTrue(page.xpath("//canvas[@id='cpuChart']"))
+ self.assertFalse(page.xpath("//h4[.='Hardware']"))
+
+ def test_the_list_links_the_overview_of_a_host_it_cannot_reach(self):
+ with patch.object(Compute, "status", new_callable=PropertyMock, return_value=False):
+ page = html.fromstring(self.client.get(reverse("computes")).content)
+ self.assertTrue(page.xpath(f"//a[@title='Overview'][@href='{self.url}']"))
diff --git a/computes/tests/test_overview_status.py b/computes/tests/test_overview_status.py
index 54074c20..d181cf4e 100644
--- a/computes/tests/test_overview_status.py
+++ b/computes/tests/test_overview_status.py
@@ -17,17 +17,6 @@ class OverviewStatusTests(TestCase):
# an ssh_config alias: not a name a TCP probe can reach
self.compute = Compute.objects.create(name="ov-alias", hostname="wvc-alias", login="root", password="", type=2)
- @patch("vrtManager.connection.connection_manager.host_is_up", return_value=False)
- @patch("computes.views.wvmHostDetails")
- def test_overview_state_comes_from_the_connection(self, host_details, host_is_up):
- conn = host_details.return_value
- conn.get_node_info.return_value = ("host", "x86_64", 1024, 2, "cpu", "qemu+ssh://root@wvc-alias/system")
- conn.get_memory_usage.return_value = {"total": 1024, "usage": 512, "percent": 50}
- with patch.object(Compute, "status", new_callable=PropertyMock, return_value=True):
- response = self.client.get(reverse("overview", args=[self.compute.id]))
- self.assertEqual(response.status_code, 200)
- self.assertEqual(response.context["status"], "true")
-
def test_compute_list_shows_why_a_host_is_not_connected(self):
with patch.object(Compute, "status", new_callable=PropertyMock, return_value=False), patch.object(
Compute, "connection_error", new_callable=PropertyMock, return_value=ERROR
diff --git a/computes/views.py b/computes/views.py
index 62e246ef..a941875a 100644
--- a/computes/views.py
+++ b/computes/views.py
@@ -30,28 +30,36 @@ CPU_SAMPLE_MAX_AGE = 15
CPU_SAMPLE_SIGNER = signing.TimestampSigner(salt="computes.cpu-sample")
+def shown_capacity(compute):
+ """The compute's capacity, with what its bars show; None when it cannot
+ be read."""
+ given = compute.capacity
+ if given:
+ # the memory bar: running and shut-off VMs' memory, and a mark at the
+ # host's memory, on a scale that holds whichever is larger
+ assigned, memory = given["assigned"], given["memory"]
+ scale = max(assigned["all"], memory) or 1
+ given["bar"] = {"running": assigned["running"] * 100 / scale,
+ "stopped": (assigned["all"] - assigned["running"]) * 100 / scale,
+ "host": memory * 100 / scale, "over": assigned["all"] > memory}
+ given["memory_percent"] = round(assigned["all"] * 100 / memory) if memory else 0
+ given["vm_count"] = sum(given["vms"].values())
+ # the default pool (else the first) shows; the others (a host may
+ # have dozens) open on a click
+ pools = given["pools"] or []
+ for pool in pools:
+ pool["used_percent"] = (pool["size"] - pool["free"]) * 100 / pool["size"] if pool["size"] else 0
+ given["pool"] = pools[0] if pools else None
+ given["more_pools"] = pools[1:]
+ return given
+
+
@superuser_only
def computes(request):
"""The computes, with what each has given its VMs."""
computes = list(Compute.objects.order_by("name"))
for compute in computes:
- given = compute.capacity
- if given:
- # the memory bar: running and shut-off VMs' memory, and a mark at the
- # host's memory, on a scale that holds whichever is larger
- assigned, memory = given["assigned"], given["memory"]
- scale = max(assigned["all"], memory) or 1
- given["bar"] = {"running": assigned["running"] * 100 / scale,
- "stopped": (assigned["all"] - assigned["running"]) * 100 / scale,
- "host": memory * 100 / scale, "over": assigned["all"] > memory}
- given["memory_percent"] = round(assigned["all"] * 100 / memory) if memory else 0
- for pool in given["pools"]:
- pool["used_percent"] = (pool["size"] - pool["free"]) * 100 / pool["size"] if pool["size"] else 0
- # the default pool (else the first) shows; the others (a host may
- # have dozens) open on a click
- pools = given["pools"]
- given["pool"] = pools[0] if pools else None
- given["more_pools"] = pools[1:]
+ compute.shown_capacity = shown_capacity(compute)
return render(request, "computes/list.html", {"computes": computes})
@@ -65,33 +73,29 @@ def capacity(request, compute_id):
@superuser_only
def overview(request, compute_id):
+ """The host's page: whether the panel reaches it, its VMs, what it has
+ given them, its load, hardware and software. A host it cannot reach
+ keeps the page, with the reason and its connection to edit."""
compute = get_object_or_404(Compute, pk=compute_id)
# the connection itself, not a TCP probe: ssh aliases and ProxyJump hosts
# are not reachable by name and port from here
- status = "true" if compute.status else "false"
-
- conn = wvmHostDetails(
- compute.hostname,
- compute.login,
- compute.password,
- compute.type,
- )
- (
- hostname,
- host_arch,
- host_memory,
- logical_cpu,
- model_cpu,
- uri_conn,
- ) = conn.get_node_info()
- hypervisor = conn.get_hypervisors_domain_types()
- mem_usage = conn.get_memory_usage()
- emulator = conn.get_emulator(host_arch)
- version = conn.get_version()
- lib_version = conn.get_lib_version()
- conn.close()
-
- return render(request, "overview.html", locals())
+ context = {"compute": compute, "connected": compute.status is True}
+ if context["connected"]:
+ try:
+ conn = wvmHostDetails(compute.hostname, compute.login, compute.password, compute.type)
+ hostname, arch, memory, _cpus, processor, uri = conn.get_node_info()
+ context["host"] = {
+ "hostname": hostname, "arch": arch, "memory": memory, "processor": processor, "uri": uri,
+ "topology": conn.get_cpu_topology(),
+ "hypervisor": conn.wvm.getType(), "hypervisor_version": conn.get_version(),
+ "libvirt_version": conn.get_lib_version(),
+ "emulator": conn.get_emulator(arch), "guest_archs": conn.get_hypervisors_domain_types(),
+ }
+ conn.close()
+ except libvirtError as error:
+ context["read_error"] = str(error)
+ context["given"] = shown_capacity(compute)
+ return render(request, "overview.html", context)
@superuser_only
diff --git a/static/icon_cache/arrow-clockwise_None_None_None.svg b/static/icon_cache/arrow-clockwise_None_None_None.svg
new file mode 100644
index 00000000..23dd7e18
--- /dev/null
+++ b/static/icon_cache/arrow-clockwise_None_None_None.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/icon_cache/x-circle_None_None_None.svg b/static/icon_cache/x-circle_None_None_None.svg
new file mode 100644
index 00000000..cb45faf9
--- /dev/null
+++ b/static/icon_cache/x-circle_None_None_None.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/vrtManager/hostdetails.py b/vrtManager/hostdetails.py
index 3de53687..08f30c65 100644
--- a/vrtManager/hostdetails.py
+++ b/vrtManager/hostdetails.py
@@ -1,11 +1,26 @@
import time
-from libvirt import VIR_DOMAIN_SHUTOFF, VIR_NODE_CPU_STATS_ALL_CPUS
+from libvirt import (
+ libvirtError,
+ VIR_DOMAIN_BLOCKED,
+ VIR_DOMAIN_PAUSED,
+ VIR_DOMAIN_RUNNING,
+ VIR_DOMAIN_SHUTOFF,
+ VIR_NODE_CPU_STATS_ALL_CPUS,
+)
from vrtManager.connection import wvmConnect
from vrtManager.util import get_xml_path
+# a VM's state as the host's VM counts group it; others count as "other"
+VM_STATES = {
+ VIR_DOMAIN_RUNNING: "running",
+ VIR_DOMAIN_BLOCKED: "running", # running, waiting on a resource
+ VIR_DOMAIN_PAUSED: "paused",
+ VIR_DOMAIN_SHUTOFF: "shutoff",
+}
+
# the times of virNodeGetCPUStats that add up to the elapsed CPU time; guest is
# already part of user and utilization is a percentage
CPU_TIMES = ("kernel", "user", "idle", "iowait")
@@ -78,23 +93,47 @@ class wvmHostDetails(wvmConnect):
info.append(self.host_info[0]) # architecture
info.append(self.host_info[1] * 1048576) # memory
info.append(self.host_info[2]) # cpu core count
- info.append(
- get_xml_path(self.wvm.getSysinfo(0), func=cpu_version)
- ) # cpu version
+ try:
+ info.append(get_xml_path(self.wvm.getSysinfo(0), func=cpu_version)) # cpu version
+ except libvirtError: # not every driver or host gives its sysinfo
+ info.append(None)
info.append(self.wvm.getURI()) # uri
return info
def get_capacity(self):
"""
What the host has given its VMs: vCPUs and memory (bytes) of all VMs
- and of the running ones, against its CPUs and memory. A shut-off VM
- takes its share again when it starts.
+ and of the running ones (paused ones too: they keep theirs), against
+ its CPUs and memory. A shut-off VM takes its share again when it
+ starts. Also its VMs by state: running, paused, shut off and other
+ (crashed, suspended, shutting down).
"""
vcpus = {"all": 0, "running": 0}
assigned = {"all": 0, "running": 0}
+ vms = {"running": 0, "paused": 0, "shutoff": 0, "other": 0}
for vm in self.get_host_instances(raw_mem_size=True).values():
for share in ("all",) if vm["status"] == VIR_DOMAIN_SHUTOFF else ("all", "running"):
vcpus[share] += int(vm["vcpu"])
assigned[share] += vm["memory"]
+ vms[VM_STATES.get(vm["status"], "other")] += 1
return {"cpus": self.host_info[2], "memory": self.host_info[1] * 1048576,
- "vcpus": vcpus, "assigned": assigned}
+ "vcpus": vcpus, "assigned": assigned, "vms": vms}
+
+ def get_cpu_topology(self):
+ """
+ The host's logical CPUs and MHz, and from the CPUs of its NUMA cells
+ in its capabilities, the NUMA nodes, sockets and physical cores (None
+ when it does not say). Not virNodeGetInfo's topology: it makes one up
+ for hosts it cannot describe, such as asymmetric NUMA.
+ """
+
+ def topology(doc):
+ cpus = doc.xpath("/capabilities/host/topology/cells/cell/cpus/cpu[@socket_id]")
+ return {
+ "nodes": len(doc.xpath("/capabilities/host/topology/cells/cell")) or None,
+ "sockets": len({cpu.get("socket_id") for cpu in cpus}) or None,
+ # a core is one within its socket and die
+ "cores": len({(cpu.get("socket_id"), cpu.get("die_id"), cpu.get("core_id")) for cpu in cpus}) or None,
+ }
+
+ return {**get_xml_path(self.get_cap_xml(), func=topology), "cpus": self.host_info[2], "mhz": self.host_info[3]}
diff --git a/vrtManager/tests/test_host_reads.py b/vrtManager/tests/test_host_reads.py
index 9ada59de..f544fb28 100644
--- a/vrtManager/tests/test_host_reads.py
+++ b/vrtManager/tests/test_host_reads.py
@@ -158,10 +158,35 @@ class CapacityTestCase(unittest.TestCase):
"cpus": 4, "memory": 2 * gib,
"vcpus": {"all": 14, "running": 6},
"assigned": {"all": 14 * gib, "running": 6 * gib},
+ "vms": {"running": 1, "paused": 1, "shutoff": 1, "other": 0},
})
conn.get_host_instances.assert_called_once_with(raw_mem_size=True)
+class CpuTopologyTestCase(unittest.TestCase):
+ """The topology comes from the capabilities' NUMA cells: getInfo makes
+ one up (one node, one socket, every CPU a core) for hosts it cannot
+ describe."""
+
+ def topology(self, cells):
+ conn = connection(wvmHostDetails)
+ conn.__dict__["host_info"] = ["x86_64", 65536, 16, 2400, 1, 1, 16, 1] # getInfo's made-up one
+ conn.get_cap_xml = lambda: f"{cells} "
+ return conn.get_cpu_topology()
+
+ def test_sockets_and_cores_are_counted_from_the_cpus_of_the_numa_cells(self):
+ # two nodes, two sockets, four cores of two threads each
+ cells = "".join(
+ f""
+ + "".join(f" " for i in range(4))
+ + " | " for node in range(2))
+ self.assertEqual(self.topology(cells), {"nodes": 2, "sockets": 2, "cores": 4, "cpus": 16, "mhz": 2400})
+
+ def test_a_host_that_does_not_say_has_no_sockets_or_cores(self):
+ cells = " | "
+ self.assertEqual(self.topology(cells), {"nodes": 1, "sockets": None, "cores": None, "cpus": 16, "mhz": 2400})
+
+
class StoragesInfoTestCase(unittest.TestCase):
def test_each_pool_tells_its_free_space(self):
conn = connection(wvmStorages)