mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 19:26:48 +00:00
feat(instances): the VM list filters by state, and a host's name finds its VMs
The list gets a state selector beside the search box. The search now
matches plain text in any case (a '(' no longer breaks it), and in the
grouped list a host's name shows all of its VMs while a host row shows
only while one of its VMs does. The filter moves to
static/js/instance_filter.js with a Node test; the other lists keep
filter-table.js. Collapsing a host now sets a class on its table body
instead of each row's display, so a collapsed host and the filter no
longer undo each other.
This commit is contained in:
parent
70e5c317ff
commit
47fadea53c
10 changed files with 152 additions and 11 deletions
|
|
@ -12,6 +12,14 @@
|
|||
{% include 'create_inst_block.html' %}
|
||||
{% endif %}
|
||||
{% include 'search_block.html' %}
|
||||
<div class="float-end search">
|
||||
<select id="state-filter" class="form-select" aria-label="{% trans 'State' %}">
|
||||
<option value="">{% trans 'All states' %}</option>
|
||||
{% for state in states %}
|
||||
<option>{{ state }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endblock page_heading_extra %}
|
||||
|
||||
{% block content %}
|
||||
|
|
@ -40,7 +48,7 @@
|
|||
{% endblock content %}
|
||||
{% block script %}
|
||||
<script src="{% static 'js/sortable.min.js' %}"></script>
|
||||
<script src="{% static 'js/filter-table.js' %}"></script>
|
||||
<script src="{% static 'js/instance_filter.js' %}"></script>
|
||||
{% if request.user.is_superuser %}
|
||||
<script>
|
||||
// The new-VM dialog's CPU bars: each compute's load over one second,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
</tr>
|
||||
</thead>
|
||||
{% for compute in computes %}
|
||||
<tbody class="searchable">
|
||||
<tbody class="searchable" data-host="{{ compute.name }}">
|
||||
{% if compute.instance_set.count > 0 %}
|
||||
<tr class="table-secondary fw-bold border-bottom border-dark">
|
||||
<td>
|
||||
|
|
@ -97,17 +97,15 @@
|
|||
|
||||
{% block script %}
|
||||
<script>
|
||||
// a collapsed host's VM rows are hidden by its table body's class, so the
|
||||
// search (instance_filter.js, the hidden attribute) can still hide them too
|
||||
function hide_all_host_instances() {
|
||||
var rows = $('table tr');
|
||||
all_host_rows = rows.filter('[host]');
|
||||
all_host_rows.toggle();
|
||||
$('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");
|
||||
}
|
||||
function hide_host_instances(host) {
|
||||
var rows = $('table tr');
|
||||
host_rows = rows.filter("[host='"+host+"']");
|
||||
host_rows.toggle();
|
||||
$("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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
{% load vm_states %}
|
||||
{% if instance.info %}{% vm_state instance.info.0 as state %}{% else %}{% vm_state None as state %}{% endif %}
|
||||
<span class="text-{{ state.1 }}">{{ state.0 }}</span>
|
||||
<span class="text-{{ state.1 }}" data-state="{{ state.0 }}">{{ state.0 }}</span>
|
||||
|
|
|
|||
51
instances/tests/js/instance_filter.test.js
Normal file
51
instances/tests/js/instance_filter.test.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// node instance_filter.test.js path/to/static/js/instance_filter.js
|
||||
const assert = require("assert");
|
||||
const {filterInstances} = require(process.argv[2]);
|
||||
|
||||
// A minimal DOM: table bodies of rows; a VM row holds its state mark.
|
||||
function vm(text, state) {
|
||||
const mark = {dataset: {state}};
|
||||
return {textContent: text, hidden: false, querySelector: (s) => (s === "[data-state]" ? mark : null)};
|
||||
}
|
||||
function hostRow(text) {
|
||||
return {textContent: text, hidden: false, querySelector: () => null};
|
||||
}
|
||||
function group(rows, host) {
|
||||
return {dataset: host ? {host} : {}, querySelectorAll: (s) => (s === "tr" ? rows : [])};
|
||||
}
|
||||
function page(groups, text, state) {
|
||||
const inputs = {filter: {value: text}, "state-filter": {value: state}};
|
||||
return {
|
||||
getElementById: (id) => inputs[id],
|
||||
querySelectorAll: (s) => (s === ".searchable" ? groups : []),
|
||||
};
|
||||
}
|
||||
const visible = (rows) => rows.filter((r) => !r.hidden).map((r) => r.textContent);
|
||||
|
||||
// the flat list: search and state together
|
||||
let rows = [vm("web-1 Active", "Active"), vm("inactive-db Off", "Off"), vm("Web-2 Off", "Off")];
|
||||
filterInstances(page([group(rows)], "", ""));
|
||||
assert.deepStrictEqual(visible(rows), ["web-1 Active", "inactive-db Off", "Web-2 Off"], "no filter shows all");
|
||||
filterInstances(page([group(rows)], "WEB", ""));
|
||||
assert.deepStrictEqual(visible(rows), ["web-1 Active", "Web-2 Off"], "search ignores case");
|
||||
filterInstances(page([group(rows)], "", "Active"));
|
||||
assert.deepStrictEqual(visible(rows), ["web-1 Active"], "state is the VM's, not a word in its name");
|
||||
filterInstances(page([group(rows)], "web", "Off"));
|
||||
assert.deepStrictEqual(visible(rows), ["Web-2 Off"], "both apply");
|
||||
filterInstances(page([group(rows)], "web-1 (", ""));
|
||||
assert.deepStrictEqual(visible(rows), [], "the search is text, not a pattern");
|
||||
|
||||
// the grouped list: a host row shows while any of its VMs does
|
||||
const header1 = hostRow("host-a Connected"), a = [header1, vm("web-1 Active", "Active"), vm("db-1 Off", "Off")];
|
||||
const header2 = hostRow("host-b Connected"), b = [header2, vm("web-9 Off", "Off")];
|
||||
const groups = [group(a, "host-a"), group(b, "host-b")];
|
||||
filterInstances(page(groups, "db", ""));
|
||||
assert.deepStrictEqual(visible(a), ["host-a Connected", "db-1 Off"]);
|
||||
assert.deepStrictEqual(visible(b), [], "a host without a match is hidden");
|
||||
filterInstances(page(groups, "HOST-B", ""));
|
||||
assert.deepStrictEqual(visible(b), ["host-b Connected", "web-9 Off"], "a host's name shows all its VMs");
|
||||
assert.deepStrictEqual(visible(a), []);
|
||||
filterInstances(page(groups, "host-a", "Off"));
|
||||
assert.deepStrictEqual(visible(a), ["host-a Connected", "db-1 Off"], "the state applies to a host's VMs too");
|
||||
filterInstances(page(groups, "Connected", ""));
|
||||
assert.deepStrictEqual(visible(a), [], "a host row's other text is not its name");
|
||||
23
instances/tests/test_instance_filter_js.py
Normal file
23
instances/tests/test_instance_filter_js.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"""The instance list's search box and state list (static/js/instance_filter.js).
|
||||
Its behaviour is checked with Node."""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
BASE = Path(settings.BASE_DIR)
|
||||
|
||||
|
||||
class InstanceFilterTestCase(unittest.TestCase):
|
||||
@unittest.skipUnless(shutil.which("node"), "node is not installed")
|
||||
def test_instance_filter_js(self):
|
||||
result = subprocess.run(
|
||||
["node", str(BASE / "instances/tests/js/instance_filter.test.js"), str(BASE / "static/js/instance_filter.js")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
|
@ -10,6 +10,7 @@ from django.contrib.auth import get_user_model
|
|||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from instances.models import Instance
|
||||
from lxml import html
|
||||
from vrtManager.util import OperationError
|
||||
|
||||
ERROR = "Connection Failed: No route to host"
|
||||
|
|
@ -69,7 +70,8 @@ class UnreachableListTests(TestCase):
|
|||
self.assertContains(response, vm.name)
|
||||
self.assertNotContains(response, reverse("instances:poweron", args=[vm.id]))
|
||||
self.assertNotContains(response, reverse("instances:poweroff", args=[vm.id]))
|
||||
self.assertContains(response, "Unknown", count=len(instances))
|
||||
states = html.fromstring(response.content).xpath("//span[@data-state]/@data-state")
|
||||
self.assertEqual(states.count("Unknown"), len(instances))
|
||||
|
||||
def test_nongrouped_index_lists_the_vms_of_a_down_host(self):
|
||||
AppSettings.objects.filter(key="VIEW_INSTANCES_LIST_STYLE").update(value="nongrouped")
|
||||
|
|
|
|||
|
|
@ -119,6 +119,16 @@ class StateTests(TestCase):
|
|||
self.assertEqual(self.offered(page, vm), set())
|
||||
|
||||
|
||||
def test_the_list_offers_each_state_and_marks_each_row_and_host(self):
|
||||
# static/js/instance_filter.js filters by these (test_instance_filter_js)
|
||||
AppSettings.objects.filter(key="VIEW_INSTANCES_LIST_STYLE").update(value="grouped")
|
||||
page = html.fromstring(self.get(self.admin, reverse("instances:index")))
|
||||
options = page.xpath("//select[@id='state-filter']/option/text()")
|
||||
self.assertEqual(options, ["All states", *LABELS.values(), "Unknown"])
|
||||
marks = page.xpath("//tbody[@data-host='st']//tr//@data-state")
|
||||
self.assertEqual(marks, list(LABELS.values()))
|
||||
|
||||
|
||||
class DetailStateTests(TestCase):
|
||||
"""The VM page names every state and offers a forced power off where only that helps."""
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ from instances.models import Instance
|
|||
from . import utils
|
||||
from .forms import ConsoleForm, FlavorForm, NewVMForm
|
||||
from .models import Flavor
|
||||
from .states import STATES, UNKNOWN
|
||||
|
||||
# the VM page waits this long (seconds) for the compute's drbdadm status
|
||||
DRBD_STATUS_TIMEOUT = 10
|
||||
|
|
@ -85,7 +86,8 @@ def index(request):
|
|||
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]},
|
||||
"reachable_computes": [c for c in computes if c.status is True],
|
||||
"states": [label for label, _color in STATES.values()] + [UNKNOWN[0]]},
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ body {
|
|||
height: 350px;
|
||||
}
|
||||
|
||||
.host-collapsed tr[host] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search {
|
||||
margin: 5px;
|
||||
}
|
||||
|
|
|
|||
43
static/js/instance_filter.js
Normal file
43
static/js/instance_filter.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
// The instance list's search box and state list.
|
||||
//
|
||||
// A VM row shows when its text contains the search, in any case, and the VM is
|
||||
// in the chosen state. In the grouped list a search for a host's name shows
|
||||
// all of its VMs, and the host's row shows while any of its VMs does.
|
||||
function filterInstances(doc) {
|
||||
var text = doc.getElementById("filter").value.toLowerCase();
|
||||
var state = doc.getElementById("state-filter").value;
|
||||
doc.querySelectorAll(".searchable").forEach(function (group) {
|
||||
var host = group.dataset.host;
|
||||
var hostMatches = host !== undefined && host.toLowerCase().includes(text);
|
||||
var header = null, shown = false;
|
||||
group.querySelectorAll("tr").forEach(function (row) {
|
||||
var mark = row.querySelector("[data-state]");
|
||||
if (!mark) {
|
||||
header = row;
|
||||
return;
|
||||
}
|
||||
row.hidden = !((!state || mark.dataset.state === state) &&
|
||||
(hostMatches || row.textContent.toLowerCase().includes(text)));
|
||||
shown = shown || !row.hidden;
|
||||
});
|
||||
if (header) {
|
||||
header.hidden = !shown;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = {filterInstances: filterInstances};
|
||||
} else {
|
||||
// the search is remembered for a day, as on the other lists
|
||||
var search = document.getElementById("filter");
|
||||
search.value = Cookies.get(document.title + "_filter") || "";
|
||||
search.addEventListener("input", function () {
|
||||
Cookies.set(document.title + "_filter", search.value, {expires: 1});
|
||||
filterInstances(document);
|
||||
});
|
||||
document.getElementById("state-filter").addEventListener("change", function () {
|
||||
filterInstances(document);
|
||||
});
|
||||
filterInstances(document);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue