diff --git a/computes/models.py b/computes/models.py index deb3ca45..4f48941a 100644 --- a/computes/models.py +++ b/computes/models.py @@ -90,16 +90,19 @@ class Compute(Model): @cached_property def capacity(self): - """What the host has given its VMs (wvmHostDetails.get_capacity) and - its active storage pools, or None when it cannot be read.""" + """What the host has given its VMs (wvmHostDetails.get_capacity), its + vCPUs per CPU, and its active storage pools, the default one first; or + None when it cannot be read.""" if self.status is not True: return None try: given = self.proxy.get_capacity() storages = wvmStorages(self.hostname, self.login, self.password, self.type) - given["pools"] = [pool for pool in storages.get_storages_info() if pool["status"]] + pools = [pool for pool in storages.get_storages_info() if pool["status"]] except libvirtError: return None + given["ratio"] = given["vcpus"]["all"] / given["cpus"] if given["cpus"] else 0 + given["pools"] = sorted(pools, key=lambda pool: (pool["name"] != "default", pool["name"])) return given def __str__(self): diff --git a/computes/templates/computes/capacity_line.txt b/computes/templates/computes/capacity_line.txt new file mode 100644 index 00000000..fd472d2d --- /dev/null +++ b/computes/templates/computes/capacity_line.txt @@ -0,0 +1,6 @@ +{% load i18n %}{% autoescape off %}{% if given %} +{% blocktrans with vcpus=given.vcpus.all ratio=given.ratio|floatformat:1 assigned=given.assigned.all|filesizeformat %}Given to VMs: {{ vcpus }} vCPUs ({{ ratio }}:1), {{ assigned }} memory.{% endblocktrans %} +{% with pool=given.pools.0 %}{% if pool %}{% blocktrans with name=pool.name free=pool.free|filesizeformat %}Pool {{ name }}: {{ free }} free.{% endblocktrans %}{% endif %}{% endwith %} +{% else %} +{% trans "The capacity could not be read." %} +{% endif %}{% endautoescape %} diff --git a/computes/tests/test_capacity.py b/computes/tests/test_capacity.py index 26b41c20..749f18ad 100644 --- a/computes/tests/test_capacity.py +++ b/computes/tests/test_capacity.py @@ -25,9 +25,13 @@ class CapacityTests(TestCase): @patch("computes.models.wvmStorages") @patch("computes.models.wvmHostDetails") def test_a_host_tells_what_it_gave_and_its_active_pools(self, details, storages): - details.return_value.get_capacity.return_value = {"cpus": 16} - storages.return_value.get_storages_info.return_value = [pool("default", 500, 120), pool("off", 10, 0, status=0)] - self.assertEqual(self.compute.capacity, {"cpus": 16, "pools": [pool("default", 500, 120)]}) + details.return_value.get_capacity.return_value = {"cpus": 16, "vcpus": {"all": 24, "running": 8}} + storages.return_value.get_storages_info.return_value = [ + pool("nfs", 900, 800), pool("off", 10, 0, status=0), pool("default", 500, 120), pool("iso", 100, 90)] + # the default pool first, the others by name + self.assertEqual(self.compute.capacity, { + "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.wvmHostDetails") def test_a_host_whose_read_fails_has_no_capacity(self, details): @@ -49,7 +53,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}, + "assigned": {"all": 48 * GIB, "running": 32 * GIB}, "ratio": 2.5, "pools": [pool("default", 500, 120)]}} with patch.object(Compute, "capacity", new=property(lambda c: found.get(c.id))), \ patch.object(Compute, "status", new=True): @@ -71,9 +75,8 @@ class ComputesPageTests(TestCase): admin = get_user_model().objects.create_superuser("cap-pools", "pools@example.com", "pw") self.client.force_login(admin) Compute.objects.create(name="host-one", hostname="192.0.2.1", login="u", password="p", type=1) - # libvirt lists the pools in no order - pools = [pool("nfs", 2000, 1000), pool("default", 500, 120), pool("iso", 100, 90), pool("backup", 900, 800)] - given = {"cpus": 16, "memory": 64 * GIB, "vcpus": {"all": 8, "running": 8}, + 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} with patch.object(Compute, "capacity", new=given), patch.object(Compute, "status", new=True): page = html.fromstring(self.client.get(reverse("computes")).content) @@ -84,3 +87,32 @@ class ComputesPageTests(TestCase): (more,) = storage.xpath(".//details") self.assertEqual(" ".join(more.xpath("summary")[0].text_content().split()), "3 more pools") self.assertEqual(more.xpath(".//div[@class='small']/text()"), ["backup", "iso", "nfs"]) + + +class NewVmDialogLineTests(TestCase): + """The new-VM dialog loads one line per host when it opens.""" + + def setUp(self): + admin = get_user_model().objects.create_superuser("cap-line", "line@example.com", "pw") + self.client.force_login(admin) + self.compute = Compute.objects.create(name="host-one", hostname="192.0.2.1", login="u", password="p", type=1) + + def line(self, given): + with patch.object(Compute, "capacity", new=given): + response = self.client.get(reverse("compute_capacity", args=[self.compute.id])) + self.assertEqual(response["Content-Type"], "text/plain; charset=utf-8") + return " ".join(response.content.decode().split()) + + def test_the_line_tells_what_the_host_has_given(self): + given = {"cpus": 16, "memory": 64 * GIB, "vcpus": {"all": 24, "running": 8}, + "assigned": {"all": 48 * GIB, "running": 32 * GIB}, "ratio": 1.5, + "pools": [pool("a&b", 500, 120), pool("iso", 100, 90)]} + # plain text: a pool name is not escaped + self.assertEqual(self.line(given), "Given to VMs: 24 vCPUs (1.5:1), 48.0 GB memory. " + "Pool a&b: 120.0 GB free.") + + def test_a_host_without_pools_or_capacity_says_so(self): + given = {"cpus": 2, "memory": 4 * GIB, "vcpus": {"all": 0, "running": 0}, + "assigned": {"all": 0, "running": 0}, "ratio": 0, "pools": []} + self.assertEqual(self.line(given), "Given to VMs: 0 vCPUs (0.0:1), 0 bytes memory.") + self.assertEqual(self.line(None), "The capacity could not be read.") diff --git a/computes/urls.py b/computes/urls.py index 806e5907..24657e7c 100644 --- a/computes/urls.py +++ b/computes/urls.py @@ -59,6 +59,7 @@ urlpatterns = [ path("update/", views.compute_update, name="compute_update"), path("delete/", views.compute_delete, name="compute_delete"), path("statistics", views.compute_graph, name="compute_graph"), + path("capacity/", views.capacity, name="compute_capacity"), path("instances/", views.instances, name="instances"), path("storages/", storages, name="storages"), path("storage//volumes/", get_volumes, name="volumes"), diff --git a/computes/views.py b/computes/views.py index dd678e67..e608d069 100644 --- a/computes/views.py +++ b/computes/views.py @@ -38,7 +38,6 @@ def computes(request): for compute in computes: given = compute.capacity if given: - given["ratio"] = given["vcpus"]["all"] / given["cpus"] if given["cpus"] else 0 # 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"] @@ -47,15 +46,24 @@ def computes(request): "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 - pools = sorted(given["pools"], key=lambda pool: pool["name"]) - for pool in pools: + for pool in given["pools"]: pool["used_percent"] = (pool["size"] - pool["free"]) * 100 / pool["size"] if pool["size"] else 0 - # the default pool shows; the others (a host may have dozens) open on a click - given["pool"] = next((pool for pool in pools if pool["name"] == "default"), pools[0] if pools else None) - given["more_pools"] = [pool for pool in pools if pool is not given["pool"]] + # 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:] return render(request, "computes/list.html", {"computes": computes}) +@superuser_only +def capacity(request, compute_id): + """One line of what the compute has given its VMs, as plain text, for the + new-VM dialog.""" + compute = get_object_or_404(Compute, pk=compute_id) + return render(request, "computes/capacity_line.txt", {"given": compute.capacity}, content_type="text/plain; charset=utf-8") + + @superuser_only def overview(request, compute_id): compute = get_object_or_404(Compute, pk=compute_id) diff --git a/instances/templates/allinstances.html b/instances/templates/allinstances.html index 2852f9e9..7c5f1192 100644 --- a/instances/templates/allinstances.html +++ b/instances/templates/allinstances.html @@ -98,11 +98,12 @@ {% if request.user.is_superuser %} {% endif %} diff --git a/instances/templates/create_inst_block.html b/instances/templates/create_inst_block.html index 4729f458..01142d4f 100644 --- a/instances/templates/create_inst_block.html +++ b/instances/templates/create_inst_block.html @@ -40,6 +40,7 @@ +
{% empty %} diff --git a/instances/tests/test_create_dialog_cpu.py b/instances/tests/test_create_dialog_cpu.py index 1b294216..54097ee7 100644 --- a/instances/tests/test_create_dialog_cpu.py +++ b/instances/tests/test_create_dialog_cpu.py @@ -1,6 +1,7 @@ -"""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.""" +"""The new-VM dialog's CPU bars and capacity lines: the page carries each +compute's URLs and reads neither itself; its script fills them when the +dialog opens, two reads at a time, and ignores answers of an earlier +opening.""" import json import shutil @@ -16,21 +17,25 @@ from lxml import html RUN = """ const vm = require("vm"); -function element() { +function element(cls) { const attrs = {}; - return {style: {}, textContent: "", attrs, + return {style: {}, textContent: "", attrs, classList: {contains: (c) => c === cls}, setAttribute: (k, v) => { attrs[k] = String(v); }, removeAttribute: (k) => { delete attrs[k]; }}; } -const cells = URLS.map((url) => { - const meter = element(), bar = element(), label = element(); - return {dataset: {url}, meter, bar, label, - querySelector: (sel) => ({".progress": meter, ".progress-bar": bar})[sel] || label}; +const cells = CPU_URLS.map((url) => { + const cell = element("cpu-usage"), meter = element(), bar = element(), label = element(); + cell.dataset = {url}; + cell.querySelector = (sel) => ({".progress": meter, ".progress-bar": bar})[sel] || label; + Object.assign(cell, {meter, bar, label}); + return cell; }); +const lines = LINE_URLS.map((url) => Object.assign(element("compute-capacity"), {dataset: {url}})); const handlers = {}, requests = []; -const dialog = {addEventListener: (name, fn) => { handlers[name] = fn; }, querySelectorAll: () => cells}; +const dialog = {addEventListener: (name, fn) => { handlers[name] = fn; }, + querySelectorAll: (sel) => sel === ".cpu-usage" ? cells : cells.flatMap((c, i) => [c, lines[i]])}; const page = { document: {getElementById: () => dialog}, - $: {getJSON: (url) => { + $: {get: (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); @@ -39,27 +44,22 @@ const page = { window: {location: {}}, }; vm.runInNewContext(SCRIPT, vm.createContext(page)); -const state = () => cells.map((c) => [c.bar.style.width, c.meter.attrs["aria-valuenow"] || null, c.label.textContent]); +const state = () => [cells.map((c) => [c.bar.style.width, c.meter.attrs["aria-valuenow"] || null, c.label.textContent]), + lines.map((l) => l.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); +answer(1, "stale"); out.hidden = [state(), requests.length]; handlers["show.bs.modal"](); -answer(2, {cpudata: 0}); -answer(3, {cpudata: null}); +answer(2, {cpudata: 41.6}); +answer(3, "\\nGiven 1.\\n"); +answer(4, null); +answer(5, 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)); """ @@ -93,25 +93,28 @@ class CreateDialogCpuTestCase(TestCase): 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): + def test_the_dialog_reads_host_by_host_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)) + cpu = [reverse("compute_graph", args=[c.id]) for c in self.computes] + line = [reverse("compute_capacity", args=[c.id]) for c in self.computes] + js = RUN.replace("SCRIPT", json.dumps(script)).replace("CPU_URLS", json.dumps(cpu)).replace("LINE_URLS", json.dumps(line)) 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]) + waiting = [[["0%", None, "…"]] * 3, ["", "", ""]] + self.assertEqual(result["loading"], waiting) # 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) + self.assertEqual(result["hidden"], [waiting, 2]) + # a failed CPU read is a dash, a failed line stays empty + self.assertEqual(result["filled"], [[["42%", "42", "42%"], ["0%", None, "—"], ["0%", None, "…"]], + ["Given 1.", "", ""]]) + # the CPU and the line of one host, then the next host; two at a time + self.assertEqual(result["all"], [cpu[0], line[0], cpu[0], line[0], cpu[1], line[1], cpu[2], line[2]]) + + def test_each_host_carries_its_capacity_line_url(self): + urls = html.fromstring(self.page()).xpath("//*[@id='AddInstance']//*[contains(@class, 'compute-capacity')]/@data-url") + self.assertEqual(urls, [reverse("compute_capacity", args=[c.id]) for c in self.computes]) class CreateDialogHostsTestCase(TestCase):