From e7361561aacc9cf3492d863a15253156abd42c6c Mon Sep 17 00:00:00 2001 From: catborise Date: Sun, 25 Oct 2026 12:41:55 +0300 Subject: [PATCH] feat(instances): the new-VM dialog shows what each host has given its VMs Under each host's CPU and memory load, the dialog shows one line: Given to VMs: 24 vCPUs (1.5:1), 48.0 GB memory. Pool default: 120.0 GB free. The line is read when the dialog opens, from computes//capacity/, as plain text, so the VM list itself reads no VM definitions. The CPU bars and the lines share one queue, host by host and two reads at a time, as each read holds a worker. A host that cannot be read says so. Compute.capacity now also gives the vCPUs per CPU and its pools with the default one first, so the Computes page and the dialog share them. --- computes/models.py | 9 ++- computes/templates/computes/capacity_line.txt | 6 ++ computes/tests/test_capacity.py | 46 ++++++++++-- computes/urls.py | 1 + computes/views.py | 20 +++-- instances/templates/allinstances.html | 52 +++++++------ instances/templates/create_inst_block.html | 1 + instances/tests/test_create_dialog_cpu.py | 75 ++++++++++--------- 8 files changed, 136 insertions(+), 74 deletions(-) create mode 100644 computes/templates/computes/capacity_line.txt 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):