mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-11 12:46:49 +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
|
|
@ -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]]},
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue