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

feat(computes): the Computes page shows what each host has given its VMs

Each host is a row of what it has committed, read live from the host (in
parallel, by a 10 s deadline):

- Host: a status dot, the name, its address and details; when it cannot
  be reached, why.
- vCPUs: the overcommit ratio, large (warning above 2:1, red above
  4:1), over "N vCPUs on M CPUs" and the running VMs' share.
- Memory: memory given to all VMs against the host's memory, as one bar:
  running VMs dark, shut-off VMs light (they take their memory again
  when they start), and a line where the host's memory ends; red when
  the VMs were given more than the host has.
- Storage: the default pool (or the first) with its used share and free
  space; the others, a host may have dozens, open on a click.

A host that cannot be reached, or does not answer in time, says so in
one line instead. vCPUs and Memory sort by ratio and share. A host whose
read hangs is not read again until that read ends, so a stuck host keeps
at most one thread. The table's hidden status texts stay inside it, so
the page does not widen on a phone.
This commit is contained in:
catborise 2026-10-25 10:36:18 +03:00
parent 6b51c30dd6
commit 1436ed13af
6 changed files with 371 additions and 25 deletions

82
computes/capacity.py Normal file
View file

@ -0,0 +1,82 @@
"""
What each compute has given its VMs: vCPUs and memory of all VMs (a
shut-off VM takes its share again when it starts) and of the running ones,
against the host's CPUs and memory, and the free space of its active
storage pools. Read live from libvirt for the Computes page.
"""
import threading
import time
from concurrent import futures
from libvirt import VIR_CONNECT_LIST_STORAGE_POOLS_ACTIVE, VIR_DOMAIN_SHUTOFF, libvirtError
MAX_WORKERS = 8
# A libvirt call may hang: its thread outlives the page. Until it ends, its
# compute is not read again, so the threads stay at one per compute.
_reading = set()
_reading_lock = threading.Lock()
def read(compute):
"""The compute's capacity, or None when it cannot be reached. A VM that
goes while it is read is skipped."""
if compute.status is not True:
return None
conn = compute.connection
_arch, memory_mib, cpus, *_ = conn.getInfo()
vcpus = {"all": 0, "running": 0}
assigned = {"all": 0, "running": 0}
for dom in conn.listAllDomains(0):
try:
state, _max_memory, memory_kib, vcpu_count, _cpu_time = dom.info()
except libvirtError:
continue
for share in ("all", "running") if state != VIR_DOMAIN_SHUTOFF else ("all",):
vcpus[share] += vcpu_count
assigned[share] += memory_kib * 1024
pools = []
for pool in conn.listAllStoragePools(VIR_CONNECT_LIST_STORAGE_POOLS_ACTIVE):
try:
_state, size, _allocation, available = pool.info()
except libvirtError:
continue
pools.append((pool.name(), size, available))
return {"cpus": cpus, "memory": memory_mib * 1024 * 1024, "vcpus": vcpus, "assigned": assigned,
"pools": sorted(pools)}
def _read_once(compute):
try:
return read(compute)
finally:
with _reading_lock:
_reading.discard(compute.id)
def read_all(computes, deadline=10.0):
"""{compute id: capacity} of the computes read by the deadline (seconds),
in parallel; a compute down, failing, late, or still being read for an
earlier page is left out."""
with _reading_lock:
computes = [compute for compute in computes if compute.id not in _reading]
_reading.update(compute.id for compute in computes)
pool = futures.ThreadPoolExecutor(max_workers=MAX_WORKERS)
end = time.monotonic() + deadline
try:
reads = {pool.submit(_read_once, compute): compute.id for compute in computes}
done, _late = futures.wait(reads, timeout=max(0, end - time.monotonic()))
found = {}
for future in done:
try:
result = future.result()
except libvirtError:
continue
if result is not None:
found[reads[future]] = result
return found
finally:
pool.shutdown(wait=False, cancel_futures=True)
with _reading_lock: # a read cancelled before it started ends nothing itself
_reading.difference_update(cid for future, cid in reads.items() if future.cancelled())

View file

@ -21,32 +21,70 @@
</div>
{% else %}
<div class="col-lg-12">
<div class="table-responsive">
<table class="table table-striped table-hover" data-sortable>
{# position-relative: the hidden status texts are positioned within it, not the page #}
<div class="table-responsive position-relative">
<table class="table align-middle compute-list" data-sortable>
<thead>
<tr>
<th span="col" class="col-sm-3">{% trans "Name" %}</th>
<th span="col" class="col-sm-2">{% trans "Status" %}</th>
<th span="col" class="col-sm-5">{% trans "Details" %}</th>
<th span="col" class="col-sm-2 text-center" data-sort-method="none">{% trans "Actions" %}</th>
<th scope="col">{% trans "Host" %}</th>
<th scope="col" data-sort-method="number">{% trans "vCPUs" %}</th>
<th scope="col" data-sort-method="number">{% trans "Memory" %}</th>
<th scope="col" data-sort-method="none">{% trans "Storage" %}</th>
<th scope="col" data-sort-method="none"><span class="visually-hidden">{% trans "Actions" %}</span></th>
</tr>
</thead>
<tbody class="searchable">
{% for compute in computes %}
{% with given=compute.capacity %}
<tr>
<td class="col-sm-3">
{{ compute.name }}
</td>
<td class="col-sm-2">
{% if compute.status is True %}{% trans "Connected" %}{% else %}{% trans "Not Connected" %}
<br><small class="text-danger">{{ compute.connection_error }}</small>
<td>
<div class="d-flex align-items-baseline gap-2">
{% if compute.status is True %}
<span class="compute-dot bg-success" title="{% trans "Connected" %}"></span>
<span class="visually-hidden">{% trans "Connected" %}</span>
{% else %}
<span class="compute-dot bg-danger" title="{% trans "Not Connected" %}"></span>
<span class="visually-hidden">{% trans "Not Connected" %}</span>
{% endif %}
<span class="fw-semibold">{{ compute.name }}</span>
</div>
<div class="small text-body-secondary ms-3">{{ compute.hostname }}{% if compute.details %}<br>{{ compute.details }}{% endif %}</div>
{% if compute.status is not True %}
<div class="small text-danger ms-3">{{ compute.connection_error }}</div>
{% endif %}
</td>
<td class="col-sm-5">
{{ compute.details|default:"" }}
{% if given %}
<td data-sort="{{ given.ratio|stringformat:".2f" }}">
<div class="compute-ratio {% if given.ratio > 4 %}text-danger{% elif given.ratio > 2 %}text-warning-emphasis{% endif %}">{{ given.ratio|floatformat:1 }}:1</div>
<div class="small text-body-secondary text-nowrap">{% blocktrans with all=given.vcpus.all cpus=given.cpus %}{{ all }} vCPUs on {{ cpus }} CPUs{% endblocktrans %}</div>
<div class="small text-body-secondary">{% blocktrans with running=given.vcpus.running %}{{ running }} running{% endblocktrans %}</div>
</td>
<td class="col-sm-2">
<div class="float-end btn-group">
<td data-sort="{{ given.memory_percent }}" class="compute-memory">
<div class="mb-1 text-nowrap">{% blocktrans with all=given.assigned.all|filesizeformat memory=given.memory|filesizeformat %}{{ all }} given of {{ memory }}{% endblocktrans %}</div>
<div class="cap-bar" role="img" aria-label="{% blocktrans with percent=given.memory_percent %}{{ percent }}% of the host's memory given{% endblocktrans %}"
title="{% trans "Dark: running VMs. Light: shut-off VMs, which take their memory again when they start. Line: the host's memory." %}">
<div class="cap-running {% if given.bar.over %}bg-danger{% endif %}" style="width: {{ given.bar.running|stringformat:".1f" }}%"></div>
<div class="cap-stopped {% if given.bar.over %}bg-danger{% endif %}" style="width: {{ given.bar.stopped|stringformat:".1f" }}%"></div>
<div class="cap-host" style="left: {{ given.bar.host|stringformat:".1f" }}%"></div>
</div>
<div class="small text-body-secondary mt-1">{% blocktrans with running=given.assigned.running|filesizeformat %}{{ running }} of it running{% endblocktrans %}</div>
</td>
<td class="compute-storage">
{% if given.pool %}{% include "computes/pool_bar.html" with pool=given.pool %}{% endif %}
{% if given.more_pools %}
<details class="mt-2">
<summary class="small text-body-secondary">{% blocktrans count counter=given.more_pools|length %}{{ counter }} more pool{% plural %}{{ counter }} more pools{% endblocktrans %}</summary>
{% for pool in given.more_pools %}
<div class="mt-2">{% include "computes/pool_bar.html" %}</div>
{% endfor %}
</details>
{% endif %}
</td>
{% else %}
<td colspan="3" class="small text-body-secondary">{% if compute.status is True %}{% trans "The capacity could not be read in time." %}{% else %}{% trans "No capacity while the host cannot be reached." %}{% endif %}</td>
{% endif %}
<td class="text-end">
<div class="btn-group">
{% if compute.status is True %}
<a class="btn btn-success" title="{%trans "Overview" %}" href="{% url 'overview' compute.id %}">{% bs_icon 'eye-fill' %}</a>
{% else %}
@ -57,6 +95,7 @@
</div>
</td>
</tr>
{% endwith %}
{% endfor %}
</tbody>
</table>

View file

@ -0,0 +1,6 @@
{% load i18n %}
<div class="small">{{ pool.name }}</div>
<div class="cap-bar cap-thin my-1" role="img" aria-label="{% blocktrans with name=pool.name percent=pool.used_percent|floatformat:0 %}{{ name }} {{ percent }}% used{% endblocktrans %}">
<div class="cap-running {% if pool.used_percent > 90 %}bg-danger{% elif pool.used_percent > 75 %}bg-warning{% endif %}" style="width: {{ pool.used_percent|stringformat:".1f" }}%"></div>
</div>
<div class="small text-body-secondary text-nowrap">{% blocktrans with free=pool.free|filesizeformat size=pool.size|filesizeformat %}{{ free }} free of {{ size }}{% endblocktrans %}</div>

View file

@ -0,0 +1,144 @@
"""What each compute has given its VMs (computes/capacity.py) and the
Computes page that shows it."""
import threading
import time
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from computes import capacity
from computes.models import Compute
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from libvirt import libvirtError
from lxml import html
GIB = 1024 ** 3
def domain(state, memory_mib, vcpus):
info = [state, memory_mib * 1024, memory_mib * 1024, vcpus, 0]
return SimpleNamespace(info=lambda: info)
def pool(name, capacity_gib, available_gib):
return SimpleNamespace(name=lambda: name, info=lambda: [2, capacity_gib * GIB, 0, available_gib * GIB])
def compute(name, domains=(), pools=(), cpus=16, memory_mib=65536, down=False, pk=1):
connection = SimpleNamespace(
getInfo=lambda: ["x86_64", memory_mib, cpus, 2400, 1, 1, 8, 2],
listAllDomains=lambda flags=0: list(domains),
listAllStoragePools=lambda flags=0: list(pools),
)
return SimpleNamespace(id=pk, name=name, status=Exception("down") if down else True, connection=connection)
class ReadTests(unittest.TestCase):
def setUp(self):
capacity._reading.clear() # a stuck read of an earlier test may not have ended yet
def test_a_host_gives_its_vms_cpus_and_memory(self):
c = compute("c1", [domain(1, 4096, 4), domain(1, 2048, 2), domain(5, 8192, 8)],
[pool("default", 500, 120), pool("iso", 100, 90)])
result = capacity.read(c)
self.assertEqual(result["cpus"], 16)
self.assertEqual(result["memory"], 64 * GIB)
self.assertEqual(result["vcpus"], {"all": 14, "running": 6})
self.assertEqual(result["assigned"], {"all": 14 * GIB, "running": 6 * GIB})
self.assertEqual(result["pools"], [("default", 500 * GIB, 120 * GIB), ("iso", 100 * GIB, 90 * GIB)])
def test_a_vm_that_goes_meanwhile_is_skipped(self):
def gone():
raise libvirtError("Domain not found")
c = compute("c1", [SimpleNamespace(info=gone), domain(1, 1024, 1)])
self.assertEqual(capacity.read(c)["vcpus"], {"all": 1, "running": 1})
def test_a_down_host_has_no_capacity(self):
self.assertIsNone(capacity.read(compute("c1", down=True)))
def test_hosts_are_read_together_by_the_deadline(self):
slow = threading.Event()
try:
stuck = compute("c2", pk=2)
stuck.connection.getInfo = lambda: slow.wait() and None
started = time.monotonic()
found = capacity.read_all([compute("c1"), stuck], deadline=0.3)
self.assertLess(time.monotonic() - started, 1)
self.assertEqual(set(found), {1})
finally:
slow.set()
def test_a_host_still_being_read_is_not_read_again(self):
# a stuck read keeps its thread; a second page load must not add another
slow, calls = threading.Event(), []
try:
stuck = compute("c2", pk=2)
stuck.connection.getInfo = lambda: calls.append(1) or slow.wait() and None
for _ in range(2):
self.assertEqual(set(capacity.read_all([compute("c1"), stuck], deadline=0.2)), {1})
self.assertEqual(len(calls), 1)
finally:
slow.set()
def test_a_host_whose_read_never_started_is_read_next_time(self):
# more hosts than threads: a read still queued at the deadline is cancelled
slow = threading.Event()
try:
stuck = compute("c1")
stuck.connection.getInfo = lambda: slow.wait() and None
with patch.object(capacity, "MAX_WORKERS", 1):
self.assertEqual(capacity.read_all([stuck, compute("c2", pk=2)], deadline=0.2), {})
self.assertEqual(set(capacity.read_all([compute("c2", pk=2)], deadline=1)), {2})
finally:
slow.set()
class ComputesPageTests(TestCase):
def test_the_list_shows_what_each_compute_has_given(self):
admin = get_user_model().objects.create_superuser("cap-admin", "cap@example.com", "pw")
self.client.force_login(admin)
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},
"pools": [("default", 500 * GIB, 120 * GIB)]}}
with patch("computes.views.capacity.read_all", return_value=found), \
patch.object(Compute, "status", new=True):
page = html.fromstring(self.client.get(reverse("computes")).content)
headers = [" ".join(th.text_content().split()) for th in page.xpath("//thead//th")]
self.assertEqual(headers, ["Host", "vCPUs", "Memory", "Storage", "Actions"])
one, two = page.xpath("//tbody/tr")
cells = [" ".join(td.text_content().split()) for td in one.xpath("td")]
self.assertIn("host-one", cells[0])
self.assertIn("2.5:1", cells[1])
self.assertIn("40 vCPUs on 16 CPUs 24 running", cells[1])
self.assertIn("48.0 GB given of 64.0 GB", cells[2])
self.assertIn("32.0 GB of it running", cells[2])
self.assertIn("120.0 GB free of 500.0 GB", cells[3])
# host-two answered nothing in time: one cell says so
self.assertIn("could not be read in time", " ".join(two.text_content().split()))
def test_the_default_pool_shows_and_the_others_open_on_a_click(self):
admin = get_user_model().objects.create_superuser("cap-pools", "pools@example.com", "pw")
self.client.force_login(admin)
c1 = Compute.objects.create(name="host-one", hostname="192.0.2.1", login="u", password="p", type=1)
pools = [("backup", 900 * GIB, 800 * GIB), ("default", 500 * GIB, 120 * GIB),
("iso", 100 * GIB, 90 * GIB), ("nfs", 2000 * GIB, 1000 * GIB)]
found = {c1.id: {"cpus": 16, "memory": 64 * GIB, "vcpus": {"all": 8, "running": 8},
"assigned": {"all": 8 * GIB, "running": 8 * GIB}, "pools": pools}}
with patch("computes.views.capacity.read_all", return_value=found), \
patch.object(Compute, "status", new=True):
page = html.fromstring(self.client.get(reverse("computes")).content)
(storage,) = page.xpath("//tbody/tr/td[4]")
shown = " ".join(" ".join(storage.xpath("./div//text()")).split())
self.assertIn("default", shown)
self.assertNotIn("backup", shown)
(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"])

View file

@ -23,7 +23,7 @@ from computes.forms import (
)
from computes.models import Compute
from . import utils
from . import capacity, utils
# The host CPU graph sends back the CPU sample of its previous answer; usage
# is taken since then when it is this recent, otherwise over one second.
@ -33,13 +33,27 @@ CPU_SAMPLE_SIGNER = signing.TimestampSigner(salt="computes.cpu-sample")
@superuser_only
def computes(request):
"""
:param request:
:return:
"""
computes = Compute.objects.filter().order_by("name")
"""The computes, with what each has given its VMs (computes/capacity.py)."""
computes = list(Compute.objects.order_by("name"))
found = capacity.read_all(computes)
for compute in computes:
compute.capacity = given = found.get(compute.id)
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"]
scale = max(assigned["all"], memory) or 1
given["bar"] = {"running": assigned["running"] * 100 / scale,
"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 = [{"name": name, "size": size, "free": free,
"used_percent": (size - free) * 100 / size if size else 0}
for name, size, free in given["pools"]]
# 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"]]
return render(request, "computes/list.html", {"computes": computes})

View file

@ -222,4 +222,65 @@ a {
font-size: 12px;
line-height: 1.5; /* If Placeholder of the input is moved up, rem/modify this. */
border-radius: 2px;
}
}
/* the Computes list: what each host has given its VMs */
.compute-list td {
padding-top: 1rem;
padding-bottom: 1rem;
}
.compute-dot {
display: inline-block;
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
flex-shrink: 0;
}
.compute-ratio {
font-size: 1.5rem;
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.compute-memory {
min-width: 11rem;
}
.compute-storage {
min-width: 9rem;
}
/* a bar of given capacity: running VMs solid, shut-off VMs faint, and a
mark where the host's own capacity ends */
.cap-bar {
position: relative;
display: flex;
height: 0.6rem;
border-radius: 0.3rem;
background: var(--bs-secondary-bg);
}
.cap-bar.cap-thin {
height: 0.3rem;
}
.cap-running {
background: var(--bs-primary);
border-radius: 0.3rem 0 0 0.3rem;
}
.cap-stopped {
background: var(--bs-primary);
opacity: 0.35;
}
.cap-host {
position: absolute;
top: -0.2rem;
bottom: -0.2rem;
width: 2px;
margin-left: -1px;
background: var(--bs-body-color);
}