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:
parent
d9f9ebab58
commit
e7361561aa
8 changed files with 136 additions and 74 deletions
|
|
@ -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):
|
||||
|
|
|
|||
6
computes/templates/computes/capacity_line.txt
Normal file
6
computes/templates/computes/capacity_line.txt
Normal 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 %}
|
||||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue