1
0
Fork 0
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:
catborise 2026-10-24 16:48:41 +03:00
parent 70e5c317ff
commit 47fadea53c
10 changed files with 152 additions and 11 deletions

View file

@ -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,

View file

@ -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");
}

View file

@ -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>

View 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");

View 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)

View file

@ -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")

View file

@ -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."""

View file

@ -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]]},
)

View file

@ -53,6 +53,10 @@ body {
height: 350px;
}
.host-collapsed tr[host] {
display: none;
}
.search {
margin: 5px;
}

View 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);
}