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:
parent
321749f8fa
commit
2d80f21a4a
9 changed files with 114 additions and 37 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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." %}"
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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])))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]]},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue