diff --git a/README.md b/README.md index 96a311ab..d0c310ff 100644 --- a/README.md +++ b/README.md @@ -536,6 +536,7 @@ A rollback leaves the clone on a detached HEAD at the restored commit; the updat - A disk can be grown while its VM runs or is paused, not only when it is shut off. - Enabling vCPU hot plug keeps the VM's current vCPU count (it enabled all of them up to the maximum). - A host's CPU graph shows the average since its previous point (normally 5 s) instead of a 1 s sample taken while a worker waits. +- The CPU usage in the new-VM dialog is the current load (over 1 s), read when the dialog opens; it was the average since the host booted. - A VM's stats are polled every 5 s like the host graph, the first point as soon as the tab opens; each point is the average since the previous one, disk rates in MB/s (they were MiB/s labelled Mb/s); a counter the host does not report shows a gap instead of 0. - An ISO uploads to an SSH compute about 3.5 times faster (SFTP writes no longer wait for each 32 KiB); a failed write still fails the upload. - The VM page shows each NIC's own QoS and each CD-ROM's own pool (they showed the previous device's), and a NIC without a source or model no longer breaks it. diff --git a/computes/models.py b/computes/models.py index 2d624e29..175e34c5 100644 --- a/computes/models.py +++ b/computes/models.py @@ -78,10 +78,6 @@ class Compute(Model): def cpu_count(self): return self.proxy.get_node_info()[3] - @cached_property - def cpu_usage(self): - return round(self.proxy.get_cpu_usage(diff=False).get('usage')) - @cached_property def ram_size(self): return self.proxy.get_node_info()[2] diff --git a/computes/test_cpu_graph.py b/computes/test_cpu_graph.py index 1583a1b8..07f4ce60 100644 --- a/computes/test_cpu_graph.py +++ b/computes/test_cpu_graph.py @@ -66,10 +66,6 @@ class CpuUsageTestCase(UnitTestCase): clock.sleep.assert_called_once_with(1) self.assertEqual((usage["usage"], usage["window"]), (80.0, 1.0)) - def test_since_the_host_started(self, clock): - self.assertEqual(host((25, 75)).get_cpu_usage(diff=False), {"usage": 75.0}) - clock.sleep.assert_not_called() - class GraphTestCase(TestCase): def setUp(self): diff --git a/instances/templates/allinstances.html b/instances/templates/allinstances.html index bce72a38..ff32f79c 100644 --- a/instances/templates/allinstances.html +++ b/instances/templates/allinstances.html @@ -47,6 +47,49 @@ //let compute = $("#compute_select").val(); window.location.href = "{% url 'instances:create_instance_select_type' 1 %}".replace(1, compute); } + + // The new-VM dialog's CPU bars: each compute's load over one second, + // read when the dialog opens, two computes at a time (each read holds + // a worker for that second). Answers of an earlier opening are ignored. + const addInstance = document.getElementById("AddInstance"); + let cpuOpening = 0, cpuQueue = [], cpuReading = 0; + + function setCpuUsage(cell, percent, text) { + const bar = cell.querySelector(".progress-bar"); + bar.style.width = (percent || 0) + "%"; + if (percent === null) { + bar.removeAttribute("aria-valuenow"); + } else { + bar.setAttribute("aria-valuenow", percent); + } + cell.querySelector(".cpu-usage-value").textContent = text; + } + + function readCpuUsage() { + while (cpuReading < 2 && cpuQueue.length) { + const cell = cpuQueue.shift(), opening = cpuOpening; + const show = (percent, text) => { if (opening === cpuOpening) setCpuUsage(cell, percent, text); }; + cpuReading++; + $.getJSON(cell.dataset.url) + .done((data) => { + const usage = typeof data.cpudata === "number" ? Math.round(data.cpudata) : null; + show(usage, usage === null ? "—" : usage + "%"); + }) + .fail(() => show(null, "—")) + .always(() => { cpuReading--; readCpuUsage(); }); + } + } + + addInstance.addEventListener("show.bs.modal", function () { + cpuOpening++; + cpuQueue = Array.from(addInstance.querySelectorAll(".cpu-usage")); + cpuQueue.forEach((cell) => setCpuUsage(cell, null, "…")); + readCpuUsage(); + }); + addInstance.addEventListener("hide.bs.modal", function () { + cpuOpening++; + cpuQueue = []; + }); {% endif %} {% endblock script %} diff --git a/instances/templates/create_inst_block.html b/instances/templates/create_inst_block.html index 4c018fdd..3fcfa9ff 100644 --- a/instances/templates/create_inst_block.html +++ b/instances/templates/create_inst_block.html @@ -32,12 +32,13 @@ {{ compute.name }} {{ compute.cpu_count }} - +
-
{{ compute.cpu_usage }}% +
+ {{ compute.ram_size|filesizeformat }} diff --git a/instances/test_create_dialog_cpu.py b/instances/test_create_dialog_cpu.py new file mode 100644 index 00000000..eefff944 --- /dev/null +++ b/instances/test_create_dialog_cpu.py @@ -0,0 +1,114 @@ +"""The new-VM dialog's CPU bars: the page carries each compute's statistics +URL and reads no CPU usage itself; its script fills the bars when the dialog +opens, two computes at a time, and ignores answers of an earlier opening.""" + +import json +import shutil +import subprocess +import unittest +from unittest.mock import PropertyMock, patch + +from computes.models import Compute +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from lxml import html + +RUN = """ +const vm = require("vm"); +function element() { + const attrs = {}; + return {style: {}, textContent: "", attrs, + setAttribute: (k, v) => { attrs[k] = String(v); }, removeAttribute: (k) => { delete attrs[k]; }}; +} +const cells = URLS.map((url) => { + const bar = element(), label = element(); + return {dataset: {url}, bar, label, + querySelector: (sel) => (sel === ".progress-bar" ? bar : label)}; +}); +const handlers = {}, requests = []; +const dialog = {addEventListener: (name, fn) => { handlers[name] = fn; }, querySelectorAll: () => cells}; +const page = { + document: {getElementById: () => dialog}, + $: {getJSON: (url) => { + const r = {url, done: (f) => { r.ok = f; return r; }, fail: (f) => { r.err = f; return r; }, + always: (f) => { r.end = f; return r; }}; + requests.push(r); + return r; + }}, + window: {location: {}}, +}; +vm.runInNewContext(SCRIPT, vm.createContext(page)); +const state = () => cells.map((c) => [c.bar.style.width, c.bar.attrs["aria-valuenow"] || null, c.label.textContent]); +const answer = (i, data) => { const r = requests[i]; data === null ? r.err() : r.ok(data); r.end(); }; +const out = {}; +handlers["show.bs.modal"](); +out.loading = state(); +out.first = requests.map((r) => r.url); +handlers["hide.bs.modal"](); +answer(0, {cpudata: 5}); +answer(1, null); +out.hidden = [state(), requests.length]; +handlers["show.bs.modal"](); +answer(2, {cpudata: 0}); +answer(3, {cpudata: null}); +out.filled = state(); +handlers["hide.bs.modal"](); +handlers["show.bs.modal"](); +out.reopened = requests.length; +answer(4, {cpudata: 99}); +answer(5, {cpudata: 41.6}); +answer(6, null); +out.final = state(); +out.all = requests.map((r) => r.url); +console.log(JSON.stringify(out)); +""" + + +class CreateDialogCpuTestCase(TestCase): + def setUp(self): + self.client.force_login(get_user_model().objects.create_superuser("cd-admin", "cd@example.com", "pw")) + self.computes = [ + Compute.objects.create(name=f"cd{i}", hostname=f"192.0.2.{i}", login="u", password="p", type=1) + for i in (1, 2, 3) + ] + patches = [ + patch.object(Compute, name, new_callable=PropertyMock, return_value=value) + for name, value in (("status", True), ("cpu_count", 1), ("ram_size", 1024), ("ram_usage", 0)) + ] + patches += [patch("instances.views.utils.refr"), patch("computes.models.wvmHostDetails")] + for p in patches: + self.details = p.start() # the last one: the host connection + self.addCleanup(p.stop) + + def page(self): + response = self.client.get(reverse("instances:index")) + self.assertEqual(response.status_code, 200) + return response.content.decode() + + def test_the_page_carries_the_statistics_urls_and_reads_no_cpu_usage(self): + page = self.page() + urls = html.fromstring(page).xpath("//td[@class='cpu-usage']/@data-url") + self.assertEqual(urls, [reverse("compute_graph", args=[c.id]) for c in self.computes]) + self.details.return_value.get_cpu_usage.assert_not_called() + + @unittest.skipUnless(shutil.which("node"), "node is not installed") + def test_the_dialog_fills_the_bars_two_at_a_time(self): + page = self.page() + script = next(s for s in html.fromstring(page).xpath("//script[not(@src)]/text()") if "show.bs.modal" in s) + urls = [reverse("compute_graph", args=[c.id]) for c in self.computes] + js = RUN.replace("SCRIPT", json.dumps(script)).replace("URLS", json.dumps(urls)) + out = subprocess.run(["node", "-e", js], capture_output=True, text=True, timeout=30) + self.assertEqual(out.returncode, 0, out.stderr) + result = json.loads(out.stdout) + self.assertEqual(result["loading"], [["0%", None, "…"]] * 3) + self.assertEqual(result["first"], urls[:2]) + # answers that come after the dialog closed change nothing and read no more + self.assertEqual(result["hidden"], [[["0%", None, "…"]] * 3, 2]) + # 0 is a value, no value is a dash + self.assertEqual(result["filled"], [["0%", "0", "0%"], ["0%", None, "—"], ["0%", None, "…"]]) + # reopened while the third read runs: one more read, two in all + self.assertEqual(result["reopened"], 6) + # the earlier opening's answer (99) is ignored; a failed read is a dash + self.assertEqual(result["final"], [["42%", "42", "42%"], ["0%", None, "—"], ["0%", None, "…"]]) + self.assertEqual(result["all"], urls[:2] + urls + urls) diff --git a/instances/test_index.py b/instances/test_index.py index 8f8b2f5e..2acebeeb 100644 --- a/instances/test_index.py +++ b/instances/test_index.py @@ -36,8 +36,6 @@ class InstancesIndexTests(TestCase): Compute, "ram_size", new_callable=PropertyMock, return_value=1024 ), patch.object( Compute, "ram_usage", new_callable=PropertyMock, return_value=0 - ), patch.object( - Compute, "cpu_usage", new_callable=PropertyMock, return_value=0 ): response = self.client.get(reverse("instances:index")) diff --git a/instances/test_list_rpc.py b/instances/test_list_rpc.py index f8151c99..ce5f64e5 100644 --- a/instances/test_list_rpc.py +++ b/instances/test_list_rpc.py @@ -50,7 +50,7 @@ class ListInfoCallTests(TestCase): ) def get(self, url): - compute_patches = {"status": True, "cpu_count": 1, "ram_size": 1024, "ram_usage": 0, "cpu_usage": 0} + compute_patches = {"status": True, "cpu_count": 1, "ram_size": 1024, "ram_usage": 0} with patch("instances.views.utils.refr"), patch("computes.views.utils.refresh_instance_database"), patch( "instances.models.wvmInstance", side_effect=self.proxy ): diff --git a/instances/test_list_unreachable.py b/instances/test_list_unreachable.py index 59ac6be6..c9045329 100644 --- a/instances/test_list_unreachable.py +++ b/instances/test_list_unreachable.py @@ -44,7 +44,7 @@ class UnreachableListTests(TestCase): props = {"status": up, "connection_error": None if up else ERROR} patches = [patch.object(Compute, k, new_callable=PropertyMock, return_value=v) for k, v in props.items()] - for name in ("cpu_count", "ram_size", "ram_usage", "cpu_usage"): + for name in ("cpu_count", "ram_size", "ram_usage"): if up: patches.append(patch.object(Compute, name, new_callable=PropertyMock, return_value=1)) else: diff --git a/instances/test_states.py b/instances/test_states.py index eb9a3c1a..d40b79f6 100644 --- a/instances/test_states.py +++ b/instances/test_states.py @@ -59,7 +59,7 @@ class StateTests(TestCase): return SimpleNamespace(instance=Domain(by_uuid[uuid]), get_title=lambda: "", get_uuid=lambda: uuid) self.client.force_login(user) - props = {"status": True, "connection_error": None, "cpu_count": 1, "ram_size": 1, "ram_usage": 0, "cpu_usage": 0} + props = {"status": True, "connection_error": None, "cpu_count": 1, "ram_size": 1, "ram_usage": 0} patches = [patch.object(Compute, k, new_callable=PropertyMock, return_value=v) for k, v in props.items()] patches += [patch("instances.views.utils.refr"), patch("computes.views.utils.refresh_instance_database"), patch("instances.models.wvmInstance", side_effect=wvm)] diff --git a/vrtManager/hostdetails.py b/vrtManager/hostdetails.py index 0f626a4d..7e63a5dd 100644 --- a/vrtManager/hostdetails.py +++ b/vrtManager/hostdetails.py @@ -49,17 +49,14 @@ class wvmHostDetails(wvmConnect): stats = self.wvm.getCPUStats(VIR_NODE_CPU_STATS_ALL_CPUS, 0) return stats["idle"], sum(stats.get(name, 0) for name in CPU_TIMES) - def get_cpu_usage(self, diff=True, previous=None): + def get_cpu_usage(self, previous=None): """ - Busy share of all host CPUs in percent. diff=False: since the host - started. Otherwise since previous, an (idle, total, time) sample this - method returned, or over one second when there is none or it does not - lead to this one (the host restarted). Also returns the closing sample - and the seconds the usage covers. + Busy share of all host CPUs in percent since previous, an (idle, total, + time) sample this method returned, or over one second when there is + none or it does not lead to this one (the host restarted). Also returns + the closing sample and the seconds the usage covers. """ times = self._cpu_times() - if not diff: - return {"usage": cpu_percent((0, 0), times)} now = time.time() usage = None if previous is not None: