diff --git a/instances/tests/js/table_filter.test.js b/instances/tests/js/table_filter.test.js
index a5285a3c..ffd601e3 100644
--- a/instances/tests/js/table_filter.test.js
+++ b/instances/tests/js/table_filter.test.js
@@ -16,7 +16,7 @@ function plainRow(text) {
return {textContent: text, dataset: {}, hidden: false, hasAttribute: () => false, querySelector: () => null};
}
function group(rows, host) {
- return {dataset: host ? {host} : {}, querySelectorAll: (s) => (s === "tr" ? rows : [])};
+ return {dataset: host ? {host} : {}, querySelectorAll: (s) => (s === "tr, [data-search], [data-group-header]" ? rows : [])};
}
function page(groups, text, state, noMatch, extra = {}) {
// a list without a state list (state undefined) has no such element
@@ -57,6 +57,13 @@ assert.deepStrictEqual(visible(a), ["host-a Connected", "db-1 Off"], "the state
filterInstances(page(groups, "Connected", ""));
assert.deepStrictEqual(visible(a), [], "a host row's other text is not its name");
+// the interfaces list: a type's heading shows while any of its cards does
+const bridges = [hostRow("Bridges 2"), vm("x", undefined, "br-v101"), vm("x", undefined, "br-v102")];
+const vlans = [hostRow("VLANs 1"), vm("x", undefined, "bond1.101")];
+filterInstances(page([group(bridges), group(vlans)], "v102"));
+assert.deepStrictEqual(bridges.map((r) => r.hidden), [false, true, false], "a card is found by its data-search");
+assert.deepStrictEqual(vlans.map((r) => r.hidden), [true, true], "a type without a match is hidden, its heading too");
+
// any other list: no state list, no groups; its rows are filtered by their text
const plain = [plainRow("alice Active"), plainRow("bob (vda) Blocked"), plainRow("carol Active")];
filterInstances(page([group(plain)], "ACTIVE"));
diff --git a/interfaces/templates/create_iface_block.html b/interfaces/templates/create_iface_block.html
index 432b8c78..ece6d9c9 100644
--- a/interfaces/templates/create_iface_block.html
+++ b/interfaces/templates/create_iface_block.html
@@ -1,150 +1,168 @@
{% load i18n %}
+{% load static %}
{% load bootstrap_icons %}
+{% load form_fields %}
{% if request.user.is_superuser %}
-
- {% bs_icon 'plus' %}
-
+
-
-
-
-
+ {# one form: a card per type, the fields of the chosen one (the others hidden and disabled, so not sent) #}
+
+
+
+
+
+{% endif %}
diff --git a/interfaces/templates/interface.html b/interfaces/templates/interface.html
index d27e0b96..8c073974 100644
--- a/interfaces/templates/interface.html
+++ b/interfaces/templates/interface.html
@@ -4,80 +4,174 @@
{% block title %}{% trans "Interface" %} - {{ iface }}{% endblock %}
-{% block page_heading %}{% trans "Interface" %}: {{ iface }}{% endblock page_heading %}
+{% block page_heading %}{{ compute.name }} - {% trans "Interface" %}: {{ iface }}{% endblock page_heading %}
+
+{% block page_heading_extra %}
+{% if changeable and info %}
+{# the interface's own actions where the host's libvirt can change interfaces: start or stop, and delete a stopped one #}
+
- {% include "computes/host_nav.html" with section="interfaces" detail=True %}
-
-
+ {% include "computes/host_nav.html" with section="interfaces" detail=True %}
{% if changeable is False %}{% include 'interfaces_unchangeable.html' %}{% endif %}
-
-
- {% if info %}
-
-
{% trans "Interface" %}
-
{{ iface }}
-
{% trans "IPv4" %}{% if info.ipv4.method %} ({{ info.ipv4.method }}){% endif %}
-
{{ info.ipv4.addresses|join:", "|default:"–" }}
-
{% trans "IPv6" %}{% if info.ipv6.method %} ({{ info.ipv6.method }}){% endif %}
-
{{ info.ipv6.addresses|join:", "|default:"–" }}
-
{% trans "MAC" %}
-
{{ info.mac|default:"–" }}
-
{% trans "Interface Type" %}
-
{{ info.type }}
-
{% trans "Boot Mode" %}
-
{{ info.start_mode|default:"–" }}
-
{% trans "State" %}
-
- {% if changeable %}
-
- {% else %}
- {% if state == 0 %}{% trans "Inactive" %}{% else %}{% trans "Active" %}{% endif %}
- {% endif %}
-
-
+ {% if info %}
+
+
{% trans "Interface" %}
+
+
+ {% trans "Type" %}{{ info.type|capfirst }}
+
+
+ {% trans "State" %}
+
+ {# libvirt's own state where it manages the interface; the link as the kernel reports it everywhere #}
+ {% if changeable %}{% if state %}{% trans "Active" %}{% else %}{% trans "Inactive" %}{% endif %}{% endif %}
+ {% include "link_badge.html" with link=info.link %}
+
+
+ {% if info.start_mode %}
+
+ {% trans "Starts" %}
+ {% if info.start_mode == "onboot" %}{% trans "On boot" %}{% elif info.start_mode == "hotplug" %}{% trans "When plugged in" %}{% else %}{% trans "Only by hand" %}{% endif %}
+
{% endif %}
-
-
- {% if info.members %}
-
-
{% trans 'Members' %}
-
-
-
-
-
{% trans 'MAC' %}
-
{% trans 'Name' %}
-
{% trans 'Type' %}
-
{% trans 'Speed' %}
-
{% trans 'State' %}
-
-
-
- {% for member in info.members %}
-
-
{{ member.mac|default:"–" }}
-
{{ member.name }}
-
{{ member.type }}
-
{{ member.speed|default:"–" }}
-
{{ member.link|default:"–" }}
-
- {% endfor %}
-
-
-
-
- {% endif %}
+
+ {% trans "MAC" %}{{ info.mac|default:"–" }}
+
+ {% if info.mtu %}
+
+ {% trans "MTU" %}{{ info.mtu }}
+
+ {% endif %}
+ {% if info.speed %}
+
+ {% trans "Speed" %}{{ info.speed }} Mb/s
+
+ {% endif %}
+ {% if info.stp %}
+
+ {% trans "Spanning tree" %}
+ {# not its delay: netcf tells it in seconds, the udev backend in hundredths #}
+ {% if info.stp == "on" %}{% trans "On" %}{% else %}{% trans "Off" %}{% endif %}
+
- {% include "computes/host_nav.html" with section="interfaces" %}
-
-
+ {% include "computes/host_nav.html" with section="interfaces" %}
{% if changeable is False %}{% include 'interfaces_unchangeable.html' %}{% endif %}
-
- {% if not ifaces_all %}
-
-
- {% bs_icon 'exclamation-triangle' %} {% trans "Warning" %}: {% trans "Hypervisor doesn't have any Interfaces" %}
+ {% if not ifaces_all %}
+
+ {% bs_icon 'exclamation-triangle' %} {% trans "libvirt lists no interfaces of this host." %}
+
+ {% else %}
+
+ {# a card per interface, as the networks', under its type; a type's heading shows while any of its cards does #}
+ {% for title, ifaces in iface_groups %}{% if ifaces %}
+
+
{{ title }} {{ ifaces|length }}
+
+ {% for iface in ifaces %}
+
+
+
+ {{ iface.name }}
+ {% include "link_badge.html" with link=iface.link %}
+
+
+ {# the same rows on every card of a type, a dash for what is not known #}
+
+ {% trans "MAC" %}
+ {{ iface.mac|default:"–" }}
+
+
+ {% trans "MTU" %}{{ iface.mtu|default:"–" }}
+
+ {% if iface.type == "ethernet" or iface.type == "bond" %}
+
+ {% trans "Speed" %}{% if iface.speed %}{{ iface.speed }} Mb/s{% else %}–{% endif %}
+
+ {% endif %}
{% endblock %}
+
{% block script %}
-
-{% endblock %}
\ No newline at end of file
+ {% if ifaces_all %}{% endif %}
+{% endblock %}
diff --git a/interfaces/templates/interfaces_unchangeable.html b/interfaces/templates/interfaces_unchangeable.html
index 5377657a..55240daf 100644
--- a/interfaces/templates/interfaces_unchangeable.html
+++ b/interfaces/templates/interfaces_unchangeable.html
@@ -1,5 +1,5 @@
{% load i18n %}
{% load bootstrap_icons %}
-
- {% bs_icon 'info-circle' %} {% trans "This host's libvirt does not support changing network interfaces. Configure them directly on the host, for example with NetworkManager." %}
+
+ {% bs_icon 'info-circle' %} {% trans "This host's libvirt only reads network interfaces and does not tell their addresses: change them on the host, for example with NetworkManager." %}
diff --git a/interfaces/templates/link_badge.html b/interfaces/templates/link_badge.html
new file mode 100644
index 00000000..d48afb0c
--- /dev/null
+++ b/interfaces/templates/link_badge.html
@@ -0,0 +1 @@
+{% load i18n %}{# a link state as libvirt reports it: up, down, another (dormant, lowerlayerdown...) or none #}{% if link == "up" %}{% trans "Link up" %}{% elif link == "down" %}{% trans "Link down" %}{% elif link %}{% blocktrans %}Link {{ link }}{% endblocktrans %}{% else %}{% trans "Link unknown" %}{% endif %}
diff --git a/interfaces/tests/test_forms.py b/interfaces/tests/test_forms.py
index 893ed208..6c92ef39 100644
--- a/interfaces/tests/test_forms.py
+++ b/interfaces/tests/test_forms.py
@@ -8,7 +8,7 @@ from computes.models import Compute
from django.contrib.auth import get_user_model
from django.test import SimpleTestCase, TestCase
from django.urls import reverse
-from lxml import etree, html
+from lxml import etree
from vrtManager.interface import wvmInterfaces
from interfaces.forms import AddInterface
@@ -334,6 +334,24 @@ class CreateViewTestCase(TestCase):
)
self.assertEqual(conn.create_iface.called, created)
+ def test_a_device_taken_meanwhile_is_chosen_again(self):
+ # not swapped for the first device the dialog still offers
+ admin = get_user_model().objects.create_superuser("iface_admin3", "c@example.com", "x")
+ compute = Compute.objects.create(name="iface-c3", hostname="127.0.0.1:1", login="root", password="", type=1)
+ self.client.force_login(admin)
+ for select, values in (("iface_netdev", {"netdev": "eth1"}), ("iface_device", {"itype": "ethernet", "name": "eth1", "netdev": "eth0"})):
+ with self.subTest(**values), patch("interfaces.views.wvmInterfaces") as conn_cls:
+ conn = conn_cls.return_value
+ conn.get_ifaces_info.return_value = []
+ conn.can_change_interfaces.return_value = True
+ conn.get_net_devices.return_value = ["eth0", "eth1"]
+ conn.get_member_candidates.return_value = ["eth0"]
+ page = self.client.post(reverse("interfaces", args=[compute.id]),
+ {**BASE, **values, "ipv6_type": "none", "create": ""}).content.decode()
+ self.assertFalse(conn.create_iface.called)
+ chosen = etree.HTML(page).xpath(f"//select[@id='{select}']/option[@selected]/@value")
+ self.assertEqual(chosen, [""])
+
def test_unreadable_host_devices_are_reported_not_blamed_on_the_input(self):
admin = get_user_model().objects.create_superuser("iface_admin2", "b@example.com", "x")
compute = Compute.objects.create(name="iface-c2", hostname="127.0.0.1:1", login="root", password="", type=1)
@@ -353,11 +371,25 @@ class CreateViewTestCase(TestCase):
self.assertFalse(any("not on this host" in m for m in messages), messages)
+class InterfaceListTestCase(TestCase):
+ def test_the_interfaces_are_grouped_by_type(self):
+ self.client.force_login(get_user_model().objects.create_superuser("iface_list", "l@example.com", "x"))
+ compute = Compute.objects.create(name="iface-list", hostname="127.0.0.1:1", login="root", password="", type=1)
+ ifaces = [{"name": name, "type": kind, "mac": None, "link": "up", "members": []}
+ for name, kind in (("br0", "bridge"), ("eth0", "ethernet"), ("eth1", "ethernet"))]
+ with patch("interfaces.views.wvmInterfaces") as conn_cls:
+ conn_cls.return_value.get_ifaces_info.return_value = ifaces
+ conn_cls.return_value.can_change_interfaces.return_value = False
+ page = etree.HTML(self.client.get(reverse("interfaces", args=[compute.id])).content.decode())
+ # no heading for a type the host has none of
+ self.assertEqual([" ".join(h.itertext()).split() for h in page.xpath("//h2")], [["Bridges", "1"], ["Ethernet", "2"]])
+
+
class UnchangeableHostTestCase(TestCase):
"""A host whose libvirt cannot change interfaces (udev backend) gets no
create form and no start/stop/delete, only a note; posts change nothing."""
- NOTE = "does not support changing network interfaces"
+ NOTE = "only reads network interfaces"
def setUp(self):
self.client.force_login(get_user_model().objects.create_superuser("iface_ro", "ro@example.com", "x"))
@@ -397,17 +429,19 @@ class UnchangeableHostTestCase(TestCase):
return conn, response
def test_the_interface_page_shows_the_state_without_actions(self):
- for state, shown in ((1, "Active"), (0, "Inactive")):
+ # libvirt's own state is told where it manages the interface only; the link always
+ for state in (1, 0):
with self.subTest(state=state):
- _conn, response = self.interface_page(False, state)
+ conn, response = self.interface_page(False, state)
page = response.content.decode()
self.assertNotIn('name="start"', page)
self.assertNotIn('name="stop"', page)
- state_cell = html.fromstring(page).xpath("//dt[normalize-space()='State']/following-sibling::dd[1]")[0]
- self.assertEqual(state_cell.text_content().strip(), shown)
+ self.assertNotIn(">Active<", page)
self.assertIn(self.NOTE, page)
_conn, response = self.interface_page(True, 0)
- self.assertIn('name="start"', response.content.decode())
+ page = response.content.decode()
+ self.assertIn('name="start"', page)
+ self.assertIn(">Inactive<", page)
def test_a_host_that_could_not_be_read_offers_nothing(self):
# not knowing whether the host can change interfaces is not a yes
diff --git a/interfaces/tests/test_live_interfaces.py b/interfaces/tests/test_live_interfaces.py
index 0a7efe51..2a557c9c 100644
--- a/interfaces/tests/test_live_interfaces.py
+++ b/interfaces/tests/test_live_interfaces.py
@@ -39,4 +39,4 @@ class LiveInterfacesTestCase(TestCase):
)
page = self.client.get(reverse("interfaces", args=[compute.id])).content.decode()
self.assertEqual("#AddInterface" in page, changeable)
- self.assertEqual("does not support changing network interfaces" in page, not changeable)
+ self.assertEqual("only reads network interfaces" in page, not changeable)
diff --git a/interfaces/tests/test_reads.py b/interfaces/tests/test_reads.py
index 367f0159..05ee0c29 100644
--- a/interfaces/tests/test_reads.py
+++ b/interfaces/tests/test_reads.py
@@ -90,3 +90,30 @@ class ReadTests(SimpleTestCase):
xml = ""
conn.iface = MagicMock(**{"name.return_value": "eth0", "MACString.return_value": "", "XMLDesc.return_value": xml})
self.assertEqual(conn.get_info()["ipv4"]["addresses"], ["192.0.2.10"])
+
+
+class BridgeUseTests(SimpleTestCase):
+ def test_networks_and_running_vms_on_a_bridge(self):
+ from vrtManager.interface import networks_by_device
+
+ nets = [MagicMock(**{"name.return_value": name, "XMLDesc.return_value": xml}) for name, xml in (
+ ("default", ""),
+ ("lan", ""),
+ ("direct", ""),
+ ("sriov", ""))]
+ self.assertEqual(networks_by_device(MagicMock(**{"listAllNetworks.return_value": nets})),
+ {"virbr0": ["default"], "br0": ["lan"], "eth0": ["direct"], "eth1": ["direct"], "eth2": ["sriov"]})
+ doms = [MagicMock(**{"name.return_value": name, "XMLDesc.return_value": xml}) for name, xml in (
+ ("web", ""
+ ""),
+ ("db", ""
+ ""),
+ ("other", ""
+ ""))]
+ conn = wvmInterface.__new__(wvmInterface)
+ conn.iface = MagicMock(**{"name.return_value": "br0"})
+ conn.wvm = MagicMock(**{"listAllDomains.return_value": doms})
+ self.assertEqual(conn.get_vms(["lan"]), [
+ {"vm": "db", "tap": "vnet7", "mac": "52:54:00:00:00:02"},
+ {"vm": "web", "tap": "vnet3", "mac": "52:54:00:00:00:01"},
+ ])
diff --git a/interfaces/views.py b/interfaces/views.py
index a3abee67..db3baad0 100644
--- a/interfaces/views.py
+++ b/interfaces/views.py
@@ -5,9 +5,10 @@ from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from libvirt import libvirtError
from computes.models import Compute
+from instances.models import Instance
from interfaces.forms import AddInterface
from logs.audit import audit
-from vrtManager.interface import wvmInterface, wvmInterfaces
+from vrtManager.interface import networks_by_device, wvmInterface, wvmInterfaces
from admin.decorators import superuser_only
@@ -19,7 +20,18 @@ def interfaces(request, compute_id):
try:
conn = wvmInterfaces(compute.hostname, compute.login, compute.password, compute.type)
ifaces_all = conn.get_ifaces_info()
+ defined_names = [iface["name"] for iface in ifaces_all]
changeable = conn.can_change_interfaces()
+ used_by = networks_by_device(conn.wvm)
+ for iface in ifaces_all:
+ iface["networks"] = used_by.get(iface["name"], [])
+ # the list by type, in libvirt's four, a type the host has none of left out
+ iface_groups = [(title, [iface for iface in ifaces_all if iface["type"] == kind])
+ for kind, title in (("bridge", _("Bridges")), ("bond", _("Bonds")),
+ ("vlan", _("VLANs")), ("ethernet", _("Ethernet")))]
+ form = AddInterface()
+ # the new interface dialog's type: the one sent back after a refusal
+ if_kind = request.POST.get("itype", "bridge")
# the host's devices are only the create form's choices
netdevs, members, netdev_error = [], [], None
@@ -27,6 +39,7 @@ def interfaces(request, compute_id):
try:
netdevs = conn.get_net_devices()
members = conn.get_member_candidates(netdevs)
+ ethernet_devices = [dev for dev in members if dev not in defined_names]
except Exception as err: # a host without the node device driver too
netdev_error = err
messages.error(request, _("The network devices of this host could not be read: %(error)s") % {"error": err})
@@ -35,19 +48,21 @@ def interfaces(request, compute_id):
# interfaces; without the host's devices nothing can be checked
if request.method == "POST" and "create" in request.POST and changeable and netdev_error is None:
form = AddInterface(request.POST, netdevs=netdevs, members=members,
- defined=[iface["name"] for iface in ifaces_all])
+ defined=defined_names)
if form.is_valid():
data = form.cleaned_data
- with audit(request, compute, f"Interface {data['name']}", "add"):
- conn.create_iface(
- data["name"], data["itype"], data["start_mode"], data["netdev"],
- data["ipv4_type"], data["ipv4_addr"], data["ipv4_gw"],
- data["ipv6_type"], data["ipv6_addr"], data["ipv6_gw"],
- data["stp"], data["delay"],
- )
- return HttpResponseRedirect(reverse("interfaces", args=[compute_id]))
- for msg_err in form.errors.values():
- messages.error(request, msg_err.as_text())
+ try:
+ with audit(request, compute, f"Interface {data['name']}", "add"):
+ conn.create_iface(
+ data["name"], data["itype"], data["start_mode"], data["netdev"],
+ data["ipv4_type"], data["ipv4_addr"], data["ipv4_gw"],
+ data["ipv6_type"], data["ipv6_addr"], data["ipv6_gw"],
+ data["stp"], data["delay"],
+ )
+ except libvirtError as err: # shown in the dialog, which opens again
+ form.add_error(None, str(err))
+ else:
+ return HttpResponseRedirect(reverse("interface", args=[compute_id, data["name"]]))
conn.close()
except libvirtError as lib_err:
messages.error(request, lib_err)
@@ -86,7 +101,14 @@ def interface(request, compute_id, iface):
return HttpResponseRedirect(here)
info = conn.get_info()
+ info["addressing"] = [("IPv4", info["ipv4"]), ("IPv6", info["ipv6"])]
state = conn.is_active()
+ networks = networks_by_device(conn.wvm).get(iface, [])
+ if info["type"] == "bridge":
+ vms = conn.get_vms(networks)
+ vm_ids = dict(Instance.objects.filter(compute=compute, name__in=[vm["vm"] for vm in vms]).values_list("name", "id"))
+ for vm in vms:
+ vm["id"] = vm_ids.get(vm["vm"])
conn.close()
except libvirtError as lib_err:
messages.error(request, lib_err)
diff --git a/static/js/table_filter.js b/static/js/table_filter.js
index 0d039354..cfba1717 100644
--- a/static/js/table_filter.js
+++ b/static/js/table_filter.js
@@ -1,8 +1,9 @@
// 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. 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
+// A row (a table row, or another element with data-search, such as a card)
+// shows when its text contains the search, in any case: plain text, 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
@@ -23,7 +24,7 @@ function filterTable(doc) {
var host = group.dataset.host;
var hostMatches = host !== undefined && host.toLowerCase().includes(text);
var header = null, shown = false;
- group.querySelectorAll("tr").forEach(function (row) {
+ group.querySelectorAll("tr, [data-search], [data-group-header]").forEach(function (row) {
if (row.hasAttribute("data-group-header")) {
header = row;
return;
diff --git a/vrtManager/interface.py b/vrtManager/interface.py
index a93a6126..53293326 100644
--- a/vrtManager/interface.py
+++ b/vrtManager/interface.py
@@ -3,6 +3,8 @@ from functools import cached_property
from xml.etree import ElementTree
from libvirt import (
+ VIR_CONNECT_LIST_DOMAINS_ACTIVE,
+ VIR_ERR_NO_DOMAIN,
VIR_ERR_NO_INTERFACE,
VIR_ERR_XML_ERROR,
VIR_INTERFACE_XML_INACTIVE,
@@ -64,8 +66,8 @@ def attribute(element, path, name):
def interface_info(iface, tree):
"""What an interface (its libvirt object, which holds its name and MAC)
- and its XML tell: type, MAC, MTU, link state and speed, and the members
- of a bridge or bond, or a VLAN's device, as reported."""
+ and its XML tell: type, MAC, MTU, link state and speed, the members of a
+ bridge or bond, or a VLAN's device, as reported, and a bridge's STP."""
state, speed = link_of(tree)
members = []
for member in tree.findall("bridge/interface") + tree.findall("bond/interface") + tree.findall("vlan/interface"):
@@ -73,7 +75,24 @@ def interface_info(iface, tree):
members.append({"name": member.get("name"), "type": member.get("type"), "mac": attribute(member, "mac", "address"),
"link": member_state, "speed": member_speed})
return {"name": iface.name(), "type": tree.get("type"), "mac": iface.MACString() or None,
- "mtu": attribute(tree, "mtu", "size"), "link": state, "speed": speed, "members": members}
+ "mtu": attribute(tree, "mtu", "size"), "link": state, "speed": speed, "members": members,
+ "stp": attribute(tree, "bridge", "stp")}
+
+
+def networks_by_device(conn):
+ """The host's libvirt networks by the devices they use: their bridge, or
+ every interface or physical function they forward to. One listing, one
+ XML read per network."""
+ by_device = {}
+ for net in conn.listAllNetworks(0):
+ tree = ElementTree.fromstring(net.XMLDesc(0))
+ devices = {attribute(tree, "bridge", "name")}
+ for forward in tree.findall("forward/interface") + tree.findall("forward/pf"):
+ devices.add(forward.get("dev"))
+ devices.discard(None)
+ for device in devices:
+ by_device.setdefault(device, []).append(net.name())
+ return by_device
class wvmInterfaces(wvmConnect):
@@ -214,6 +233,25 @@ class wvmInterface(wvmConnect):
info[family] = {"method": method, "addresses": addresses}
return info
+ def get_vms(self, networks=()):
+ """The running VMs' network cards on this bridge, directly or through
+ the libvirt networks of networks (which use it): VM, tap device and
+ the VM's MAC. One listing, one XML read per running VM."""
+ name = self.iface.name()
+ cards = []
+ for dom in self.wvm.listAllDomains(VIR_CONNECT_LIST_DOMAINS_ACTIVE):
+ try:
+ tree = ElementTree.fromstring(dom.XMLDesc(0))
+ except libvirtError as err: # a VM stopped or undefined meanwhile is not on it
+ if err.get_error_code() != VIR_ERR_NO_DOMAIN:
+ raise
+ continue
+ for card in tree.findall("devices/interface"):
+ source = card.find("source")
+ if source is not None and (source.get("bridge") == name or source.get("network") in networks):
+ cards.append({"vm": dom.name(), "tap": attribute(card, "target", "dev"), "mac": attribute(card, "mac", "address")})
+ return sorted(cards, key=lambda card: (card["vm"], card["tap"] or ""))
+
def stop_iface(self):
self.iface.destroy()
diff --git a/webvirtcloud/tests/test_status_contrast.py b/webvirtcloud/tests/test_status_contrast.py
index a975e38e..3188c78d 100644
--- a/webvirtcloud/tests/test_status_contrast.py
+++ b/webvirtcloud/tests/test_status_contrast.py
@@ -1,7 +1,7 @@
-"""Status colours keep their contrast on every theme: an interface card's
-header, and a status word, is the theme's pale status background with its
-dark status text, a pair that does not depend on the page's background
-(dark themes keep light-mode emphasis colours)."""
+"""Status colours keep their contrast on every theme: a status word is the
+theme's pale status background with its dark status text, a pair that does
+not depend on the page's background (dark themes keep light-mode emphasis
+colours)."""
from unittest.mock import PropertyMock, patch
@@ -26,17 +26,6 @@ class StatusContrastTests(TestCase):
self.assertEqual(response.status_code, 200)
return html.fromstring(response.content)
- def test_card_headers_are_pale_with_dark_text(self):
- for name in ("interfaces",):
- with self.subTest(page=name):
- headers = self.page(name).xpath("//div[contains(@class, 'card-header')]")
- self.assertTrue(headers)
- for header in headers:
- classes = header.get("class").split()
- self.assertTrue({"bg-success-subtle", "bg-danger-subtle"} & set(classes), classes)
- link = header.xpath(".//a")[0].get("class").split()
- self.assertTrue({"text-success-emphasis", "text-danger-emphasis"} & set(link), link)
-
def test_status_words_are_pale_badges_with_dark_text(self):
# the test driver has no node memory statistics for the host row
with patch.object(Compute, "ram_usage", new_callable=PropertyMock, return_value=1):