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

feat(computes): the Computes list on Bootstrap's cards, badges, bars and menus

- Totals above the list: hosts connected of all, VMs running of all,
  vCPUs given on the CPUs, memory given of the memory of the hosts the
  panel reads, as cards; the connected count is red while a host is
  not.
- A host's name links its overview; its state is a Connected or Not
  connected badge (the dot goes), beside a badge of how the panel
  reaches it (TCP, SSH, TLS, Socket); its address in monospace.
- A VMs column: the host's VMs, linked to its Instances, and how many
  run.
- The vCPUs have a bar like the memory's: running VMs' and shut-off
  VMs' vCPUs against the CPUs, amber over 2:1 and red over 4:1 as the
  ratio is; the overview's vCPU card shares it.
- The three coloured buttons of each row are one menu: Overview, Edit
  connection and Remove from panel (its popper fixed, as the table's
  scrolling box would cut it off).
- The four green add buttons are one "Add host" menu that says what
  each kind of connection is.
- The table is table-hover; a host the panel cannot reach has a
  table-warning row.
This commit is contained in:
catborise 2026-10-27 21:41:06 +03:00
parent a0a31cd3f2
commit 321749f8fa
10 changed files with 207 additions and 88 deletions

View file

@ -1,5 +1,18 @@
{% load i18n %}
{# the vCPUs a host has given its VMs: given, what its list cell and its overview show #}
{# the vCPUs a host has given its VMs: given, from views.shown_capacity #}
<div class="fs-4 lh-sm {% 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">{% blocktrans count counter=given.vcpus.all %}{{ counter }} vCPU{% plural %}{{ counter }} vCPUs{% endblocktrans %} {% blocktrans count counter=given.cpus %}on {{ counter }} CPU{% plural %}on {{ counter }} CPUs{% endblocktrans %}</div>
<div class="small text-body-secondary">{% blocktrans with running=given.vcpus.running %}{{ running }} running{% endblocktrans %}</div>
<div class="small text-body-secondary mb-1">{% blocktrans count counter=given.vcpus.all %}{{ counter }} vCPU{% plural %}{{ counter }} vCPUs{% endblocktrans %} {% blocktrans count counter=given.cpus %}on {{ counter }} CPU{% plural %}on {{ counter }} CPUs{% endblocktrans %}</div>
{# running VMs' vCPUs, then shut-off VMs', on a scale that holds the CPUs or all given, whichever is more #}
<div class="progress-stacked" style="--bs-progress-height: 0.6rem;" title="{% trans "Dark: running VMs. Light: shut-off VMs, which take their vCPUs again when they start." %}">
<div class="progress" role="progressbar" style="width: {{ given.cpu_bar.running|stringformat:".1f" }}%"
aria-label="{% trans "vCPUs of running VMs" %}" aria-valuenow="{{ given.cpu_bar.running|floatformat:0 }}" aria-valuemin="0" aria-valuemax="100"
aria-valuetext="{% blocktrans with running=given.vcpus.running cpus=given.cpus %}{{ running }} of the host's {{ cpus }} CPUs{% endblocktrans %}">
<div class="progress-bar {% if given.ratio > 4 %}bg-danger{% elif given.ratio > 2 %}bg-warning{% else %}bg-primary{% endif %}"></div>
</div>
<div class="progress" role="progressbar" style="width: {{ given.cpu_bar.stopped|stringformat:".1f" }}%"
aria-label="{% trans "vCPUs of shut-off VMs" %}" aria-valuenow="{{ given.cpu_bar.stopped|floatformat:0 }}" aria-valuemin="0" aria-valuemax="100"
aria-valuetext="{% blocktrans with stopped=given.vcpus.stopped cpus=given.cpus %}{{ stopped }} of the host's {{ cpus }} CPUs{% endblocktrans %}">
<div class="progress-bar {% if given.ratio > 4 %}bg-danger{% elif given.ratio > 2 %}bg-warning{% else %}bg-primary{% endif %} bg-opacity-50"></div>
</div>
</div>
<div class="small text-body-secondary mt-1">{% blocktrans with running=given.vcpus.running %}{{ running }} running{% endblocktrans %}</div>

View file

@ -1,7 +1,6 @@
{% extends "base.html" %}
{% load i18n %}
{% load static %}
{% load common_tags %}
{% load bootstrap_icons %}
{% block title %}{% trans "Computes" %}{% endblock %}
{% block content %}
@ -12,73 +11,110 @@
<h3 class="mb-3">{% trans "Computes" %}</h3>
</div>
</div>
<div class="row">
{% if not computes %}
<div class="col-lg-12">
<div class="alert alert-warning shadow-sm">
{% bs_icon 'exclamation-triangle'%} <strong>{% trans "Warning" %}:</strong> {% trans "You don't have any computes" %}
</div>
{% if not computes %}
<div class="alert alert-warning shadow-sm">
{% bs_icon 'exclamation-triangle'%} <strong>{% trans "Warning" %}:</strong> {% trans "You don't have any computes" %}
</div>
{% else %}
{# the totals of the hosts the panel reaches #}
<div class="row row-cols-2 row-cols-lg-4 g-3 mb-4">
<div class="col">
<div class="card h-100"><div class="card-body">
<div class="small text-body-secondary">{% bs_icon 'hdd-rack' %} {% trans "Hosts" %}</div>
<div class="fs-4 lh-sm">{{ totals.connected }} / {{ totals.hosts }}</div>
<div class="small {% if totals.connected < totals.hosts %}text-danger{% else %}text-body-secondary{% endif %}">{% trans "connected" %}</div>
</div></div>
</div>
{% else %}
<div class="col-lg-12">
{# position-relative: the hidden status texts are positioned within it, not the page #}
<div class="table-responsive position-relative">
<table class="table align-middle" data-sortable>
<thead>
<tr>
<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"><span class="visually-hidden">{% trans "Actions" %}</span></th>
</tr>
</thead>
<tbody class="searchable">
{% for compute in computes %}
{% with given=compute.shown_capacity %}
<tr>
<td class="py-3">
<div class="d-flex align-items-baseline gap-2">
{% if compute.status is True %}
<span class="d-inline-block rounded-circle p-1 flex-shrink-0 bg-success" title="{% trans "Connected" %}" aria-hidden="true"></span>
<span class="visually-hidden">{% trans "Connected" %}</span>
{% else %}
<span class="d-inline-block rounded-circle p-1 flex-shrink-0 bg-danger" title="{% trans "Not Connected" %}" aria-hidden="true"></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>
{% if given %}
<td class="py-3" data-sort="{{ given.ratio|stringformat:".2f" }}">
{% include "computes/capacity_vcpus.html" %}
</td>
<td class="py-3" data-sort="{{ given.memory_percent }}">
{% include "computes/capacity_memory.html" %}
</td>
<div class="col">
<div class="card h-100"><div class="card-body">
<div class="small text-body-secondary">{% bs_icon 'server' %} {% trans "VMs" %}</div>
<div class="fs-4 lh-sm">{{ totals.running }} / {{ totals.vms }}</div>
<div class="small text-body-secondary">{% trans "running" %}</div>
</div></div>
</div>
<div class="col">
<div class="card h-100"><div class="card-body">
<div class="small text-body-secondary">{% bs_icon 'cpu' %} {% trans "vCPUs given" %}</div>
<div class="fs-4 lh-sm">{{ totals.vcpus }}</div>
<div class="small text-body-secondary">{% blocktrans count counter=totals.cpus %}on {{ counter }} CPU{% plural %}on {{ counter }} CPUs{% endblocktrans %}</div>
</div></div>
</div>
<div class="col">
<div class="card h-100"><div class="card-body">
<div class="small text-body-secondary">{% bs_icon 'memory' %} {% trans "Memory given" %}</div>
<div class="fs-4 lh-sm">{{ totals.assigned|filesizeformat }}</div>
<div class="small text-body-secondary">{% blocktrans with memory=totals.memory|filesizeformat %}of {{ memory }}{% endblocktrans %}</div>
</div></div>
</div>
</div>
{# position-relative: the hidden status texts are positioned within it, not the page #}
<div class="table-responsive position-relative">
<table class="table table-hover align-middle" data-sortable>
<thead>
<tr>
<th scope="col">{% trans "Host" %}</th>
<th scope="col" data-sort-method="number">{% trans "VMs" %}</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"><span class="visually-hidden">{% trans "Actions" %}</span></th>
</tr>
</thead>
<tbody class="searchable">
{% for compute in computes %}
{% with given=compute.shown_capacity %}
<tr{% if compute.status is not True %} class="table-warning"{% endif %}>
<td class="py-3">
<div class="d-flex flex-wrap align-items-center gap-2">
<a class="fw-semibold" href="{% url 'overview' compute.id %}">{{ compute.name }}</a>
{% if compute.status is True %}
<span class="badge text-bg-success">{% trans "Connected" %}</span>
{% else %}
<td colspan="2" class="py-3 small text-body-secondary">{% if compute.status is True %}{% trans "The capacity could not be read." %}{% else %}{% trans "No capacity while the host cannot be reached." %}{% endif %}</td>
<span class="badge text-bg-danger">{% trans "Not connected" %}</span>
{% endif %}
<td class="py-3 text-end">
<div class="btn-group">
{# a host the panel cannot reach opens too: its page says why #}
<a class="btn {% if compute.status is True %}btn-success{% else %}btn-light{% endif %}" title="{% trans "Overview" %}" href="{% url 'overview' compute.id %}">{% bs_icon 'eye-fill' %}</a>
<a class="btn btn-primary" title="{%trans "Edit" %}" href="{% url 'compute_update' compute.id %}">{% bs_icon 'pencil-fill' %}</a>
<a class="btn btn-danger" title="{%trans "Delete" %}" href="{% url 'compute_delete' compute.id %}">{% bs_icon 'x-circle-fill' %}</a>
</div>
</td>
</tr>
{% endwith %}
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
</div>
<span class="badge text-bg-light border">{{ compute.connection_name }}</span>
</div>
<div class="small text-body-secondary font-monospace mt-1">{{ compute.hostname }}</div>
{% if compute.details %}<div class="small text-body-secondary">{{ compute.details }}</div>{% endif %}
{% if compute.status is not True %}
<div class="small text-danger">{{ compute.connection_error }}</div>
{% endif %}
</td>
{% if given %}
<td class="py-3" data-sort="{{ given.vm_count }}">
<a class="fs-4 lh-sm text-decoration-none" href="{% url 'instances' compute.id %}">{{ given.vm_count }}</a>
<div class="small text-body-secondary">{% blocktrans with running=given.vms.running %}{{ running }} running{% endblocktrans %}</div>
</td>
<td class="py-3" data-sort="{{ given.ratio|stringformat:".2f" }}">
{% include "computes/capacity_vcpus.html" %}
</td>
<td class="py-3" data-sort="{{ given.memory_percent }}">
{% include "computes/capacity_memory.html" %}
</td>
{% else %}
<td colspan="3" class="py-3 small text-body-secondary">{% if compute.status is True %}{% trans "The capacity could not be read." %}{% else %}{% trans "No capacity while the host cannot be reached." %}{% endif %}</td>
{% endif %}
<td class="py-3 text-end">
{# fixed: the menu would be cut off by the table's scrolling box #}
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="dropdown" aria-expanded="false"
data-bs-popper-config='{"strategy": "fixed"}'
aria-label="{% blocktrans with name=compute.name %}Actions for {{ name }}{% endblocktrans %}">{% bs_icon 'three-dots' %}</button>
<ul class="dropdown-menu dropdown-menu-end shadow">
<li><a class="dropdown-item" href="{% url 'overview' compute.id %}">{% bs_icon 'eye' %} {% trans "Overview" %}</a></li>
<li><a class="dropdown-item" href="{% url 'compute_update' compute.id %}">{% bs_icon 'pencil' %} {% trans "Edit connection" %}</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="{% url 'compute_delete' compute.id %}">{% bs_icon 'x-circle' %} {% trans "Remove from panel" %}</a></li>
</ul>
</div>
</td>
</tr>
{% endwith %}
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock content %}
{% block script %}

View file

@ -1,11 +1,25 @@
{% load i18n %}
{% load django_bootstrap5 %}
{% load bootstrap_icons %}
<div class="btn-group float-end mt-1" role="group" aria-label="Add host button group">
<a href="{% url 'add_tcp_host' %}" class="btn btn-success">{% trans "TCP" %}</a>
<a href="{% url 'add_ssh_host' %}" class="btn btn-success">{% trans "SSH" %}</a>
<a href="{% url 'add_tls_host' %}" class="btn btn-success">{% trans "TLS" %}</a>
<a href="{% url 'add_socket_host' %}" class="btn btn-success">{% trans "Local" %}</a>
<a href="#" class="btn btn-success disabled" title="{% trans "Add new host" %}">{% bs_icon "plus-circle-fill" %}</a>
<div class="dropdown float-end mt-1 ms-1">
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
{% bs_icon 'plus-lg' %} {% trans "Add host" %}
</button>
<ul class="dropdown-menu dropdown-menu-end shadow" style="min-width: 18rem;">
<li><a class="dropdown-item" href="{% url 'add_tcp_host' %}">
<div class="fw-semibold">TCP</div>
<div class="small text-body-secondary text-wrap">{% trans "libvirt over TCP, with a SASL login" %}</div>
</a></li>
<li><a class="dropdown-item" href="{% url 'add_ssh_host' %}">
<div class="fw-semibold">SSH</div>
<div class="small text-body-secondary text-wrap">{% trans "libvirt over SSH, with the panel's SSH key" %}</div>
</a></li>
<li><a class="dropdown-item" href="{% url 'add_tls_host' %}">
<div class="fw-semibold">TLS</div>
<div class="small text-body-secondary text-wrap">{% trans "libvirt over TLS, with certificates" %}</div>
</a></li>
<li><a class="dropdown-item" href="{% url 'add_socket_host' %}">
<div class="fw-semibold">{% trans "Local" %}</div>
<div class="small text-body-secondary text-wrap">{% trans "the libvirt of the host the panel runs on, by its socket" %}</div>
</a></li>
</ul>
</div>

View file

@ -50,14 +50,20 @@ class ComputesPageTests(TestCase):
page = html.fromstring(self.client.get(reverse("computes")).content)
headers = [" ".join(th.text_content().split()) for th in page.xpath("//thead//th")]
# no pools: measuring their space scans each pool of each host (views.host_pools)
self.assertEqual(headers, ["Host", "vCPUs", "Memory", "Actions"])
self.assertEqual(headers, ["Host", "VMs", "vCPUs", "Memory", "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("75 % 48.0 GB / 64.0 GB", cells[2])
self.assertIn("32.0 GB running", cells[2])
self.assertIn("host-one Connected TCP 192.0.2.1", cells[0])
self.assertEqual(cells[1], "6 3 running")
self.assertIn("2.5:1", cells[2])
self.assertIn("40 vCPUs on 16 CPUs", cells[2])
self.assertIn("24 running", cells[2])
self.assertIn("75 % 48.0 GB / 64.0 GB", cells[3])
self.assertIn("32.0 GB running", cells[3])
# the totals of the hosts read: host-two's capacity could not be
totals = [" ".join(card.text_content().split()) for card in page.xpath("//div[@class='card-body']")]
self.assertEqual(totals, ["Hosts 2 / 2 connected", "VMs 3 / 6 running", "vCPUs given 40 on 16 CPUs",
"Memory given 48.0 GB of 64.0 GB"])
# host-two could not be read: one cell says so
self.assertIn("capacity could not be read", " ".join(two.text_content().split()))
@ -72,7 +78,7 @@ class SingularTests(TestCase):
with patch.object(Compute, "capacity", new=given), patch.object(Compute, "status", new=True):
page = html.fromstring(self.client.get(reverse("computes")).content)
line = self.client.get(reverse("compute_capacity", args=[c.id])).content.decode()
(cell,) = page.xpath("//tbody/tr/td[2]")
(cell,) = page.xpath("//tbody/tr/td[3]")
self.assertIn("1 vCPU on 1 CPU", " ".join(cell.text_content().split()))
self.assertEqual(" ".join(line.split()), "Given to VMs: 1 vCPU (1.0:1), 1.0 GB memory.")

View file

@ -117,4 +117,5 @@ class OverviewTestCase(TestCase):
def test_the_list_links_the_overview_of_a_host_it_cannot_reach(self):
with patch.object(Compute, "status", new_callable=PropertyMock, return_value=False):
page = html.fromstring(self.client.get(reverse("computes")).content)
self.assertTrue(page.xpath(f"//a[@title='Overview'][@href='{self.url}']"))
# its name and its menu's Overview
self.assertEqual(len(page.xpath(f"//tbody//a[@href='{self.url}']")), 2)

View file

@ -22,5 +22,5 @@ class OverviewStatusTests(TestCase):
Compute, "connection_error", new_callable=PropertyMock, return_value=ERROR
):
response = self.client.get(reverse("computes"))
self.assertContains(response, "Not Connected")
self.assertContains(response, "Not connected")
self.assertContains(response, ERROR)

View file

@ -12,6 +12,7 @@ from instances.models import Instance
from libvirt import libvirtError
from logs.audit import audit, changed_fields, snapshot
from operations import claims
from vrtManager.connection import CONN_SOCKET, CONN_SSH, CONN_TCP, CONN_TLS
from vrtManager.hostdetails import wvmHostDetails
from vrtManager.storage import wvmStorages
@ -44,6 +45,11 @@ def shown_capacity(compute):
given["bar"] = {"running": assigned["running"] * 100 / scale, "stopped": assigned["stopped"] * 100 / scale,
"over": assigned["all"] > memory}
given["memory_percent"] = round(assigned["all"] * 100 / memory) if memory else 0
# the vCPU bar likewise: running and shut-off VMs' vCPUs against the CPUs
vcpus, cpus = given["vcpus"], given["cpus"]
vcpus["stopped"] = vcpus["all"] - vcpus["running"]
cpu_scale = max(vcpus["all"], cpus) or 1
given["cpu_bar"] = {"running": vcpus["running"] * 100 / cpu_scale, "stopped": vcpus["stopped"] * 100 / cpu_scale}
given["vm_count"] = sum(given["vms"].values())
return given
@ -63,13 +69,29 @@ def host_pools(compute):
return {"pool": pools[0] if pools else None, "more_pools": pools[1:]}
# how the panel reaches a compute, by Compute.type
CONNECTIONS = {CONN_TCP: "TCP", CONN_SSH: "SSH", CONN_TLS: "TLS", CONN_SOCKET: "Socket"}
@superuser_only
def computes(request):
"""The computes, with what each has given its VMs."""
"""The computes, with what each has given its VMs, and the totals of the
ones the panel reaches."""
computes = list(Compute.objects.order_by("name"))
totals = {"hosts": len(computes), "connected": 0, "vms": 0, "running": 0,
"vcpus": 0, "cpus": 0, "assigned": 0, "memory": 0}
for compute in computes:
compute.shown_capacity = shown_capacity(compute)
return render(request, "computes/list.html", {"computes": computes})
compute.connection_name = CONNECTIONS.get(compute.type, "")
compute.shown_capacity = given = shown_capacity(compute)
totals["connected"] += compute.status is True
if given:
totals["vms"] += given["vm_count"]
totals["running"] += given["vms"]["running"]
totals["vcpus"] += given["vcpus"]["all"]
totals["cpus"] += given["cpus"]
totals["assigned"] += given["assigned"]["all"]
totals["memory"] += given["memory"]
return render(request, "computes/list.html", {"computes": computes, "totals": totals})
@superuser_only

View file

@ -0,0 +1,11 @@
<?xml version="1.0" ?>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-hdd-rack" viewBox="0 0 16 16">
<path d="M4.5 5a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1M3 4.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0m2 7a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0m-2.5.5a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1"/>
<path d="M2 2a2 2 0 0 0-2 2v1a2 2 0 0 0 2 2h1v2H2a2 2 0 0 0-2 2v1a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-1a2 2 0 0 0-2-2h-1V7h1a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2zm13 2v1a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1m0 7v1a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1m-3-4v2H4V7z"/>
</svg>

After

Width:  |  Height:  |  Size: 635 B

View file

@ -0,0 +1,8 @@
<?xml version="1.0" ?>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus-lg" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2"/>
</svg>

After

Width:  |  Height:  |  Size: 298 B

View file

@ -0,0 +1,8 @@
<?xml version="1.0" ?>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-three-dots" viewBox="0 0 16 16">
<path d="M3 9.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3m5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3m5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3"/>
</svg>

After

Width:  |  Height:  |  Size: 303 B