1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-09 03:36:48 +00:00

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/<id>/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.
This commit is contained in:
catborise 2026-10-25 12:41:55 +03:00
parent d9f9ebab58
commit e7361561aa
8 changed files with 136 additions and 74 deletions

View file

@ -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):

View file

@ -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 %}

View file

@ -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.")

View file

@ -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/<str:pool>/volumes/", get_volumes, name="volumes"),

View file

@ -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)

View file

@ -98,11 +98,12 @@
<script src="{% static 'js/find_address.js' %}"></script>
{% if request.user.is_superuser %}
<script>
// 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.
// The new-VM dialog's CPU bars and capacity lines, read when the
// dialog opens, two reads at a time: a CPU read holds a worker for
// a second, a capacity read while it reads the host's VMs. Answers
// of an earlier opening are ignored.
const addInstance = document.getElementById("AddInstance");
let cpuOpening = 0, cpuQueue = [], cpuReading = 0;
let opening = 0, queue = [], reading = 0;
function setCpuUsage(cell, percent, text) {
const meter = cell.querySelector(".progress");
@ -115,30 +116,37 @@
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(); });
// data: the answer, or null when the read failed
function show(element, data) {
if (element.classList.contains("compute-capacity")) {
element.textContent = data === null ? "" : data.trim();
return;
}
const usage = data !== null && typeof data.cpudata === "number" ? Math.round(data.cpudata) : null;
setCpuUsage(element, usage, usage === null ? "—" : usage + "%");
}
function readNext() {
while (reading < 2 && queue.length) {
const element = queue.shift(), current = opening;
reading++;
$.get(element.dataset.url)
.done((data) => { if (current === opening) show(element, data); })
.fail(() => { if (current === opening) show(element, null); })
.always(() => { reading--; readNext(); });
}
}
addInstance.addEventListener("show.bs.modal", function () {
cpuOpening++;
cpuQueue = Array.from(addInstance.querySelectorAll(".cpu-usage"));
cpuQueue.forEach((cell) => setCpuUsage(cell, null, "…"));
readCpuUsage();
opening++;
addInstance.querySelectorAll(".cpu-usage").forEach((cell) => setCpuUsage(cell, null, "…"));
// host by host, as the list shows them
queue = Array.from(addInstance.querySelectorAll(".cpu-usage, .compute-capacity"));
readNext();
});
addInstance.addEventListener("hide.bs.modal", function () {
cpuOpening++;
cpuQueue = [];
opening++;
queue = [];
});
</script>
{% endif %}

View file

@ -40,6 +40,7 @@
</div>
</div>
<div class="col-md-1 d-none d-md-block text-end" aria-hidden="true">{% bs_icon 'chevron-right' %}</div>
<div class="col-12 col-md-8 offset-md-3 small text-body-secondary compute-capacity" data-url="{% url 'compute_capacity' compute.id %}"></div>
</div>
</a>
{% empty %}

View file

@ -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):