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

fix(instances): the VM list searches its VMs, collapses its hosts together and says when it is empty

- The search read a row's whole text, so a word on a button or in a
  state label matched every row. VM rows now carry data-search (the
  name, title and owners, and the host in the flat list), and
  table_filter.js searches that text when a row has it; the state has
  its own filter.
- Nothing matching left an empty table. table_filter.js shows the
  page's #no-match while no row does; the VM list's says so and offers
  "Clear the filters". A user with no VMs at all is told so instead of
  an empty table.
- The grouped list's "#" toggled each host on its own: a host
  collapsed by hand opened while the others closed. It now collapses
  every host, or opens them all once all are collapsed.
- One alert names every host the panel cannot reach, with why, in
  place of one dismissible alert per host; users who see no hosts keep
  their own message.
- A host's VM list showed a VM's maximum memory, the Instances page
  its current memory: both show the current memory now.
- The owners' users are prefetched with the VMs; the flat list's
  Bootstrap 4 font-italic is fst-italic.
This commit is contained in:
catborise 2026-10-27 22:20:50 +03:00
parent 321749f8fa
commit 2d80f21a4a
9 changed files with 114 additions and 37 deletions

View file

@ -72,7 +72,7 @@
{% include 'instance_state.html' %}
</td>
<td data-sort="{{ instance.info.3 }}">{{ instance.info.3 }}</td>
<td data-sort="{{ instance.info.1 }}"><p class="text-end">{% if instance.info %}{% widthratio instance.info.1 1024 1 %} MiB{% endif %}</p></td>
<td data-sort="{{ instance.info.2 }}"><p class="text-end">{% if instance.info %}{% widthratio instance.info.2 1024 1 %} MiB{% endif %}</p></td>
<td class="text-nowrap">
{% include 'instance_actions.html' %}
</td>

View file

@ -26,14 +26,18 @@
{% block content %}
{% if show_hosts %}
{% for compute in computes %}
{% if compute.status is not True %}
<div class="alert alert-danger alert-dismissible fade show" role="alert">
{% trans 'Problem occurred with host' %} {{ compute.name }}: {{ compute.connection_error }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
{% endfor %}
{% if unreachable_computes %}
{# one alert for every host the panel cannot reach, each with why #}
<div class="alert alert-warning alert-dismissible fade show" role="alert">
<div class="fw-semibold">{% blocktrans count counter=unreachable_computes|length %}The panel cannot reach {{ counter }} host:{% plural %}The panel cannot reach {{ counter }} hosts:{% endblocktrans %}</div>
<ul class="mb-0">
{% for compute in unreachable_computes %}
<li><span class="fw-semibold">{{ compute.name }}</span>: {{ compute.connection_error }}</li>
{% endfor %}
</ul>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="{% trans "Close" %}"></button>
</div>
{% endif %}
{% elif own_hosts_down %}
<div class="alert alert-warning alert-dismissible fade show" role="alert">
{% trans 'A host of some of your virtual machines cannot be reached right now.' %}
@ -41,11 +45,18 @@
</div>
{% endif %}
<div class="col-lg-12">
{% if app_settings.VIEW_INSTANCES_LIST_STYLE == 'grouped' and request.user.is_superuser %}
{% if not instances.exists %}
<p class="text-center text-body-secondary py-4">{% trans "There are no VMs here yet." %}</p>
{% elif app_settings.VIEW_INSTANCES_LIST_STYLE == 'grouped' and request.user.is_superuser %}
{% include 'allinstances_index_grouped.html' %}
{% else %}
{% include 'allinstances_index_nongrouped.html' %}
{% endif %}
{# table_filter.js shows it while no row matches #}
<div id="no-match" class="text-center text-body-secondary py-4" hidden>
{% trans "No VM matches the search and the state." %}
<button type="button" class="btn btn-link" id="clear-filters">{% trans "Clear the filters" %}</button>
</div>
</div>
<div class="modal fade" id="find-address" tabindex="-1" aria-labelledby="find-address-title" aria-hidden="true"
data-url="{% url 'instances:find_address' %}" data-failed="{% trans "The search did not go through. Try again." %}"

View file

@ -4,7 +4,7 @@
<table class="table table-hover align-middle" data-sortable>
<thead>
<tr>
<th scope="col" data-sort-method="none"><a class="link-primary" href="#" id="hide-all-instances" onclick="hide_all_host_instances()">#</a></th>
<th scope="col" data-sort-method="none"><a class="link-primary" href="#" id="hide-all-instances" onclick="hide_all_host_instances(); return false;">#</a></th>
<th scope="col">{% trans "Name" %}<br>{% trans "Description" %}</th>
<th scope="col" class="d-none d-sm-table-cell">{% trans "User"%}</th>
<th scope="col">{% trans "Status" %}</th>
@ -55,7 +55,8 @@
{% endif %}
</tr>
{% for instance in compute.instance_set.all %}
<tr host="{{ compute.id }}">
{# searched for its name, title and owners, not its buttons #}
<tr host="{{ compute.id }}" data-search="{{ instance.name }}{% if instance.info %} {{ instance.title }}{% endif %}{% for owner in instance.userinstance_set.all %} {{ owner.user }}{% endfor %}">
<td></td>
<td>
<a class="link-primary" href="{% url 'instances:instance' instance.id %}">{{ instance.name }}</a>
@ -97,15 +98,20 @@
<script>
// a collapsed host's VM rows are hidden by its table body's class, so the
// search (table_filter.js, the hidden attribute) can still hide them too
function set_host_collapsed(tbody, collapsed) {
$(tbody).toggleClass('host-collapsed', collapsed);
$(tbody).find('span[id^=collapse_host_instances_]').toggleClass('bi-chevron-down', collapsed).toggleClass('bi-chevron-up', !collapsed);
$(tbody).find('span[id^=inst_count_badge_]').toggleClass('d-none', !collapsed);
}
// every host the same way: collapsed, unless all already are
function hide_all_host_instances() {
$('tbody.searchable').toggleClass('host-collapsed');
$('span[id^=collapse_host_instances_]').toggleClass("bi bi-chevron-down ").toggleClass("bi bi-chevron-up");
$('span[id^=inst_count_badge_]').toggleClass("d-none");
var bodies = $('tbody.searchable');
var collapse = bodies.not('.host-collapsed').length > 0;
bodies.each(function () { set_host_collapsed(this, collapse); });
}
function hide_host_instances(host) {
$("span[id='collapse_host_instances_"+host+"']").closest('tbody').toggleClass('host-collapsed');
$("span[id='collapse_host_instances_"+host+"']").toggleClass("bi bi-chevron-down ").toggleClass("bi bi-chevron-up");
$("span[id='inst_count_badge_"+host+"']").toggleClass("d-none");
var tbody = $('#collapse_host_instances_' + host).closest('tbody');
set_host_collapsed(tbody, !tbody.hasClass('host-collapsed'));
}
</script>
{% endblock %}

View file

@ -18,13 +18,14 @@
</thead>
<tbody class="searchable">
{% for instance in instances %}
<tr>
{# searched for its name, title, host and owners, not its buttons #}
<tr data-search="{{ instance.name }}{% if instance.info %} {{ instance.title }}{% endif %}{% if show_hosts %} {{ instance.compute.name }}{% for owner in instance.userinstance_set.all %} {{ owner.user }}{% endfor %}{% endif %}">
<td>
<a class="link-primary" href="{% url 'instances:instance' instance.id %}">
{{ instance.name }}
</a>
<br>
{% if instance.info %}<p class="m-0 small font-italic">{{ instance.title }}</p>{% endif %}
{% if instance.info %}<p class="m-0 small fst-italic">{{ instance.title }}</p>{% endif %}
</td>
{% if show_hosts %}
<td>

View file

@ -4,23 +4,24 @@ const {filterTable: filterInstances} = require(process.argv[2]);
// A minimal DOM: table bodies of rows; a VM row holds its state mark, a
// group's header row is marked as one.
function vm(text, state) {
function vm(text, state, search) {
const mark = {dataset: {state}};
return {textContent: text, hidden: false, hasAttribute: () => false,
return {textContent: text, dataset: search === undefined ? {} : {search}, hidden: false, hasAttribute: () => false,
querySelector: (s) => (s === "[data-state]" ? mark : null)};
}
function hostRow(text) {
return {textContent: text, hidden: false, hasAttribute: (a) => a === "data-group-header", querySelector: () => null};
return {textContent: text, dataset: {}, hidden: false, hasAttribute: (a) => a === "data-group-header", querySelector: () => null};
}
function plainRow(text) {
return {textContent: text, hidden: false, hasAttribute: () => false, querySelector: () => null};
return {textContent: text, dataset: {}, hidden: false, hasAttribute: () => false, querySelector: () => null};
}
function group(rows, host) {
return {dataset: host ? {host} : {}, querySelectorAll: (s) => (s === "tr" ? rows : [])};
}
function page(groups, text, state) {
function page(groups, text, state, noMatch) {
// a list without a state list (state undefined) has no such element
const inputs = {filter: {value: text}, "state-filter": state === undefined ? undefined : {value: state}};
const inputs = {filter: {value: text}, "state-filter": state === undefined ? undefined : {value: state},
"no-match": noMatch};
return {
getElementById: (id) => inputs[id],
querySelectorAll: (s) => (s === ".searchable" ? groups : []),
@ -64,3 +65,17 @@ filterInstances(page([group(plain)], "(vda"));
assert.deepStrictEqual(visible(plain), ["bob (vda) Blocked"], "a bracket is text there too");
filterInstances(page([group(plain)], ""));
assert.deepStrictEqual(visible(plain), ["alice Active", "bob (vda) Blocked", "carol Active"]);
// a row with data-search is searched there only: its menus' words are not the VM's
const menus = [vm("web-1 Active Force off Power cycle", "Active", "web-1 alice"), vm("db-1 Off Start", "Off", "db-1 bob")];
filterInstances(page([group(menus)], "off", ""));
assert.deepStrictEqual(visible(menus), [], "a menu's 'Force off' is not a match");
filterInstances(page([group(menus)], "BOB", ""));
assert.deepStrictEqual(visible(menus), ["db-1 Off Start"], "an owner is searched");
// the page says so when nothing matches, and not otherwise
const noMatch = {hidden: true};
filterInstances(page([group(menus)], "nothing-like-this", "", noMatch));
assert.strictEqual(noMatch.hidden, false, "no row: the message shows");
filterInstances(page([group(menus)], "web", "", noMatch));
assert.strictEqual(noMatch.hidden, true, "a row: the message goes");

View file

@ -65,12 +65,12 @@ class ListInfoCallTests(TestCase):
self.assertEqual(response.status_code, 200)
return response
def assert_one_info_per_vm(self, response, memory="2048 MiB"):
def assert_one_info_per_vm(self, response):
self.assertEqual(len(self.domains), 2, "one proxy per VM row")
for domain in self.domains:
self.assertEqual(domain.info_calls, 1)
self.assertEqual(domain.xml_calls, 1, "only the title reads the XML")
self.assertContains(response, memory, count=2)
self.assertContains(response, "2048 MiB", count=2)
self.assertContains(response, "Active")
def test_nongrouped_index(self):
@ -83,4 +83,5 @@ class ListInfoCallTests(TestCase):
def test_compute_instances(self):
# this page shows the maximum memory, as before
self.assert_one_info_per_vm(self.get(reverse("instances", args=[self.compute.id])), memory="4096 MiB")
# the VM's current memory, as on the Instances page, not its maximum
self.assert_one_info_per_vm(self.get(reverse("instances", args=[self.compute.id])))

View file

@ -133,6 +133,20 @@ class StateTests(TestCase):
self.assertEqual([r.xpath("td[@data-sort]/@data-sort") for r in rows[1:2]], [["2", "2097152"]])
def test_rows_are_searched_for_their_vm_not_their_buttons(self):
# table_filter.js searches data-search: a menu's "Force off" would match "off" in every row
for style in ("grouped", "nongrouped"):
AppSettings.objects.filter(key="VIEW_INSTANCES_LIST_STYLE").update(value=style)
page = html.fromstring(self.get(self.admin, reverse("instances:index")))
searched = page.xpath("//tbody//tr/@data-search")
with self.subTest(style=style):
self.assertEqual(len(searched), len(self.vms))
for vm, text in zip(self.vms.values(), searched):
self.assertTrue(text.startswith(vm.name), text)
self.assertIn("st-owner", text)
self.assertNotIn("Force", text)
self.assertTrue(page.xpath("//div[@id='no-match'][@hidden]//button[@id='clear-filters']"))
def test_the_lists_show_the_vms_by_name(self):
# the first VM made comes last by name: the database's own order is not the list's
Instance.objects.filter(pk=self.vms[0].pk).update(name="st-vm-9")
@ -211,3 +225,11 @@ class TemplateCloneTests(TestCase):
self.assertEqual(allowed_actions(cloner, vm, 5), {"clone"})
self.assertEqual(allowed_actions(admin, vm, 5), {"clone"})
self.assertEqual(allowed_actions(owner, vm, 5), set()) # no clone permission
class EmptyListTests(TestCase):
def test_a_user_without_vms_is_told_so(self):
self.client.force_login(get_user_model().objects.create_user("st-nobody", password="pw"))
response = self.client.get(reverse("instances:index"))
self.assertContains(response, "There are no VMs here yet.")
self.assertNotContains(response, "searchable") # no VM table

View file

@ -74,16 +74,16 @@ def index(request):
utils.refr(compute)
# each host's VMs by name: the database keeps no order of its own
prefetch_related_objects(computes, Prefetch("instance_set", queryset=Instance.objects.order_by("name")),
"instance_set__userinstance_set")
"instance_set__userinstance_set__user")
show_hosts = request.user.is_superuser or request.user.has_perm("instances.view_instances")
own_hosts_down = False
if show_hosts:
instances = Instance.objects.order_by("name").prefetch_related("userinstance_set")
instances = Instance.objects.order_by("name").prefetch_related("userinstance_set__user")
else:
instances = Instance.objects.filter(
userinstance__user=request.user
).order_by("name").prefetch_related("userinstance_set")
).order_by("name").prefetch_related("userinstance_set__user")
# other users get no host names or connection errors, only whether
# a host of their own VMs is down
own = set(instances.values_list("compute_id", flat=True))
@ -94,6 +94,7 @@ def index(request):
"allinstances.html",
{"computes": computes, "instances": instances, "show_hosts": show_hosts, "own_hosts_down": own_hosts_down,
"reachable_computes": [c for c in computes if c.status is True],
"unreachable_computes": [c for c in computes if c.status is not True],
"states": [label for label, _color in STATES.values()] + [UNKNOWN[0]]},
)

View file

@ -1,14 +1,17 @@
// The search box of a list page, and the instance list's state list.
//
// A row shows when its text contains the search, in any case: plain text,
// not a pattern. With a state list, the row's [data-state] must be the
// chosen state too. In a grouped list a search for a group's host shows all
// of its rows, and its header row (data-group-header) shows while any of its
// rows does. The search is remembered for a day, per page.
// not a pattern. A row with data-search is searched in that text only, not
// in its buttons and menus. With a state list, the row's [data-state] must
// be the chosen state too. In a grouped list a search for a group's host
// shows all of its rows, and its header row (data-group-header) shows while
// any of its rows does. A page's #no-match shows while no row does. The
// search is remembered for a day, per page.
function filterTable(doc) {
var text = doc.getElementById("filter").value.toLowerCase();
var states = doc.getElementById("state-filter");
var state = states ? states.value : "";
var anyShown = false;
doc.querySelectorAll(".searchable").forEach(function (group) {
var host = group.dataset.host;
var hostMatches = host !== undefined && host.toLowerCase().includes(text);
@ -19,14 +22,20 @@ function filterTable(doc) {
return;
}
var mark = row.querySelector("[data-state]");
var searched = row.dataset.search !== undefined ? row.dataset.search : row.textContent;
row.hidden = !((!state || (mark && mark.dataset.state === state)) &&
(hostMatches || row.textContent.toLowerCase().includes(text)));
(hostMatches || searched.toLowerCase().includes(text)));
shown = shown || !row.hidden;
});
if (header) {
header.hidden = !shown;
}
anyShown = anyShown || shown;
});
var noMatch = doc.getElementById("no-match");
if (noMatch) {
noMatch.hidden = anyShown;
}
}
if (typeof module !== "undefined") {
@ -44,5 +53,16 @@ if (typeof module !== "undefined") {
filterTable(document);
});
}
var clear = document.getElementById("clear-filters");
if (clear) {
clear.addEventListener("click", function () {
search.value = "";
Cookies.remove(document.title + "_filter");
if (states) {
states.value = "";
}
filterTable(document);
});
}
filterTable(document);
}