1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00

feat(networks): the network pages on Bootstrap's cards, tables and dialogs

As the storage pages; reviewed with Codex.

- The list is a card per network: its name with Active or Stopped and
  Autostart badges, its type (network_mode: Isolated, NAT, Routed,
  Open, Bridge, Macvtap, SR-IOV), its device, and each address with
  its DHCP range; a bridge or macvtap network's addresses come from
  the outside network.
- A network's page: its heading starts or stops it, the menu sets
  autostart and deletes a stopped one. A Network card tells its state,
  type, bridge or interface, MAC and what the type does; a card per
  address (every <ip>, each with its DHCP range dialog, which libvirt
  takes on a stopped network); its fixed addresses of every subnet in
  one table, searched, each with Edit and Delete, one dialog adding
  and editing them; the VMs whose definition names it, connected or
  configured, linked (wvmNetwork.get_vms, one domain listing, a VM
  undefined meanwhile left out); its
  bandwidth limits where libvirt sets them (not on macvtap or SR-IOV
  networks), in KiB/s, from the saved definition with a note that a
  running network takes them when restarted; its XML in an accordion.
  The page reads the running and the saved definition once each, and
  only to render itself: its actions, which all redirect, read nothing
  of it first.
- The New network dialog is one form, as the New pool one: the type
  as radio cards (the radio_card.html include, now shared by both),
  the chosen type's fields only, IPv6 with its box, a bridge or
  interface suggested from the host's (get_host_interfaces, not the
  bridges libvirt made for its own networks); a refused network or
  one libvirt refuses opens the dialog again with what was entered,
  the reasons beside the fields and focus on the first.
- What the New pool and New network dialogs share is one script,
  static/js/choice_dialog.js: the radio cards show their fieldsets
  (data-kinds) and a refused form opens again focused on its first
  refused field; each dialog keeps only its own changes.
- The fixed-address dialogs per family go to the page's own dialog.
This commit is contained in:
catborise 2026-10-28 20:25:00 +03:00
parent 0c9a002ef4
commit 0ee03c0971
15 changed files with 947 additions and 757 deletions

View file

@ -1,88 +1,151 @@
{% load i18n %}
{% load static %}
{% load bootstrap_icons %}
{% load form_fields %}
{% if request.user.is_superuser %}
<a href="#AddNetPool" type="button" class="btn btn-success float-end" title="{% trans "Add New Network" %}" data-bs-toggle="modal">
<span aria-hidden="true">{% bs_icon 'plus' %} </span>
</a>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#AddNetPool">
<span aria-hidden="true">{% bs_icon 'plus-lg' %}</span> {% trans "New network" %}
</button>
<!-- Modal pool -->
<div class="modal fade" id="AddNetPool" tabindex="-1" role="dialog" aria-labelledby="AddNetPoolLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
{# one form: a card per mode, the fields of the chosen one (the others hidden and disabled, so not sent) #}
<div class="modal fade" id="AddNetPool" tabindex="-1" aria-labelledby="AddNetPoolLabel" aria-hidden="true"
{% if form.errors %}data-open{% endif %}>
<div class="modal-dialog modal-lg modal-dialog-scrollable modal-fullscreen-sm-down">
<form method="post" class="modal-content" id="network-form">{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title">{% trans "Add New Network" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
<div>
<h5 class="modal-title" id="AddNetPoolLabel">{% trans "New network" %}</h5>
<div class="small text-body-secondary">{% blocktrans with host=compute.name %}On {{ host }}{% endblocktrans %}</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% trans "Close" %}"></button>
</div>
<form method="post" action="" role="form" aria-label="Add New Network form" novalidate>{% csrf_token %}
<div class="modal-body">
<div class="row">
<label for="net_name" class="col-sm-4 col-form-label">{% trans "Name" %}</label>
<div class="modal-body">
{% include "instances/form_errors.html" %}
<fieldset class="mb-3">
<legend class="form-label fs-6">{% trans "Type" %}</legend>
<div class="row row-cols-1 row-cols-sm-2 g-2">
{% trans "NAT" as title %}{% trans "VMs reach out through the host's address, on a private subnet." as help %}
{% include "radio_card.html" with name="forward" value="nat" chosen=net_mode %}
{% trans "Routed" as title %}{% trans "The host routes the subnet; the upstream router needs a route back." as help %}
{% include "radio_card.html" with name="forward" value="route" chosen=net_mode %}
{% trans "Isolated" as title %}{% trans "VMs reach each other and the host only." as help %}
{% include "radio_card.html" with name="forward" value="none" chosen=net_mode %}
{% trans "Bridge" as title %}{% trans "VMs join a bridge of the host, on its outside network." as help %}
{% include "radio_card.html" with name="forward" value="bridge" chosen=net_mode %}
{% trans "Macvtap" as title %}{% trans "VMs share an interface of the host; the host cannot reach them through it." as help %}
{% include "radio_card.html" with name="forward" value="macvtap" chosen=net_mode %}
</div>
</fieldset>
<div class="mb-3">
<label for="net_name" class="form-label">{% trans "Network name" %}</label>
<input id="net_name" type="text" class="form-control{{ form.name|invalid }}" name="name" value="{{ form.name.value|default:"" }}"
placeholder="lab-net" maxlength="20" required pattern="[a-zA-Z0-9._\-]+" {% error_attrs form.name "net_name" "net_name_help" %}>
<div class="form-text" id="net_name_help">{% trans "Letters, digits, dots, dashes and underscores; up to 20." %}</div>
{% field_error form.name "net_name" %}
</div>
<fieldset data-kinds="nat route none">
<legend class="visually-hidden">{% trans "Addresses" %}</legend>
<div class="row g-3 mb-3">
<div class="col-sm-6">
<input id="net_name" type="text" class="form-control" name="name" placeholder="{% trans "default" %}" required pattern="[a-zA-Z0-9_]+">
<label for="net_subnet" class="form-label">{% trans "IPv4 subnet" %}</label>
<input id="net_subnet" type="text" class="form-control font-monospace{{ form.subnet|invalid }}" name="subnet" value="{{ form.subnet.value|default:"" }}"
placeholder="192.168.100.0/24" {% error_attrs form.subnet "net_subnet" "net_subnet_help" %}>
<div class="form-text" id="net_subnet_help">{% trans "The host takes its first address." %}</div>
{% field_error form.subnet "net_subnet" %}
</div>
<div class="col-sm-6">
<span class="form-label d-block">{% trans "DHCP" %}</span>
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" role="switch" id="net_dhcp4" name="dhcp4" value="true"{% if not form.is_bound or form.dhcp4.value %} checked{% endif %}>
<label class="form-check-label" for="net_dhcp4">{% trans "Give the VMs addresses from the subnet" %}</label>
</div>
</div>
</div>
<div class="row">
<label for="forward_select" class="col-sm-4 col-form-label">{% trans "Type forwarding" %}</label>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="net_ipv6" name="enable_ipv6" value="true"{% if form.enable_ipv6.value %} checked{% endif %}>
<label class="form-check-label" for="net_ipv6">{% trans "IPv6 too" %}</label>
</div>
<div class="row g-3 mb-3" id="net_ipv6_fields">
<div class="col-sm-6">
<select id="forward_select" class="form-select" name="forward">
<option value="nat">{% trans "NAT" %}</option>
<option value="route">{% trans "ROUTE" %}</option>
<option value="none">{% trans "ISOLATE" %}</option>
<option value="bridge">{% trans "BRIDGE" %}</option>
<option value="macvtap">{% trans "MACVTAP" %}</option>
</select>
<label for="net_subnet6" class="form-label">{% trans "IPv6 subnet" %}</label>
<input id="net_subnet6" type="text" class="form-control font-monospace{{ form.subnet6|invalid }}" name="subnet6" value="{{ form.subnet6.value|default:"" }}"
placeholder="fd00:dead:baba:1::/64" {% error_attrs form.subnet6 "net_subnet6" "net_subnet6_help" %}>
<div class="form-text" id="net_subnet6_help">{% trans "A /64." %}</div>
{% field_error form.subnet6 "net_subnet6" %}
</div>
<div class="col-sm-6">
<span class="form-label d-block">{% trans "DHCPv6" %}</span>
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" role="switch" id="net_dhcp6" name="dhcp6" value="true"{% if form.dhcp6.value %} checked{% endif %}>
<label class="form-check-label" for="net_dhcp6">{% trans "Give the VMs IPv6 addresses" %}</label>
</div>
</div>
</div>
<div class="row bridge_name_form_group_dhcp">
<label for="net_subnet" class="col-sm-4 col-form-label">{% trans "IPv4 Subnet pool" %}</label>
<div class="col-sm-6">
<input id="net_subnet" type="text" class="form-control" name="subnet" value="" placeholder="192.168.100.0/24" required pattern="[0-9\/\.]+">
</div>
</fieldset>
<fieldset data-kinds="bridge">
<legend class="visually-hidden">{% trans "Bridge" %}</legend>
<div class="mb-3">
<label for="net_bridge" class="form-label">{% trans "Host bridge" %}</label>
<input id="net_bridge" type="text" class="form-control font-monospace{% if net_mode == "bridge" %}{{ form.bridge_name|invalid }}{% endif %}" name="bridge_name"
value="{% if net_mode == "bridge" %}{{ form.bridge_name.value|default:"" }}{% endif %}" list="host-bridges" placeholder="br0" maxlength="20" required
{% if net_mode == "bridge" %}{% error_attrs form.bridge_name "net_bridge" "net_bridge_help" %}{% else %}aria-describedby="net_bridge_help"{% endif %}>
<datalist id="host-bridges">
{% for iface in host_ifaces %}{% if iface.type == "bridge" %}<option value="{{ iface.name }}">{% endif %}{% endfor %}
</datalist>
<div class="form-text" id="net_bridge_help">{% trans "A bridge the host already has; the field suggests the ones libvirt lists." %}</div>
{% if net_mode == "bridge" %}{% field_error form.bridge_name "net_bridge" %}{% endif %}
</div>
<div class="row bridge_name_form_group_dhcp">
<label for="net_dhcp4" class="col-sm-4 col-form-label">{% trans "DHCPv4" %}</label>
<div class="col-sm-6">
<input id="net_dhcp4" type="checkbox" name="dhcp4" value="true" checked>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="net_ovs" name="openvswitch" value="true"{% if form.openvswitch.value %} checked{% endif %}>
<label class="form-check-label" for="net_ovs">{% trans "It is an Open vSwitch bridge" %}</label>
</div>
<div class="row bridge_name_form_group_dhcp">
<label for="enable_ipv6" class="col-sm-4 col-form-label">{% trans "Enable IPv6" %}</label>
<div class="col-sm-6">
<input type="checkbox" id="enable_ipv6" name="enable_ipv6" value="true">
</div>
</div>
<div class="row ipv6_group">
<label for="net_subnet6" class="col-sm-4 col-form-label">{% trans "IPv6 Subnet pool" %}</label>
<div class="col-sm-6">
<input id="net_subnet6" type="text" class="form-control" name="subnet6" value="" placeholder="fd00:dead:baba:1::/64" required pattern="[0-9a-fA-F:\/]+">
</div>
</div>
<div class="row ipv6_group">
<label for="net_dhcp6" class="col-sm-4 col-form-label">{% trans "DHCPv6" %}</label>
<div class="col-sm-6">
<input id="net_dhcp6" type="checkbox" name="dhcp6" value="true">
</div>
</div>
<div class="row bridge_name_form_group">
<label for="bridge_name" class="col-sm-4 col-form-label" id="bridge_label">{% trans "Bridge Name" %}</label>
<div class="col-sm-6">
<input type="text" class="form-control" name="bridge_name" id="bridge_name" placeholder="br0" pattern="[a-z0-9\-_:]+">
</div>
</div>
<div class="row bridge_name_form_group openvswitch">
<label for="net_openvswitch" class="col-sm-4 col-form-label">{% trans "Open vSwitch" %}</label>
<div class="col-sm-6">
<input id="net_openvswitch" type="checkbox" name="openvswitch" value="true">
</div>
</fieldset>
<fieldset data-kinds="macvtap">
<legend class="visually-hidden">{% trans "Macvtap" %}</legend>
<div class="mb-3">
<label for="net_iface" class="form-label">{% trans "Host interface" %}</label>
<input id="net_iface" type="text" class="form-control font-monospace{% if net_mode == "macvtap" %}{{ form.bridge_name|invalid }}{% endif %}" name="bridge_name"
value="{% if net_mode == "macvtap" %}{{ form.bridge_name.value|default:"" }}{% endif %}" list="host-ifaces" placeholder="eth0" maxlength="20" required
{% if net_mode == "macvtap" %}{% error_attrs form.bridge_name "net_iface" "net_iface_help" %}{% else %}aria-describedby="net_iface_help"{% endif %}>
<datalist id="host-ifaces">
{% for iface in host_ifaces %}{% if iface.type != "bridge" %}<option value="{{ iface.name }}">{% endif %}{% endfor %}
</datalist>
<div class="form-text" id="net_iface_help">{% trans "The interface the VMs share, such as the host's uplink." %}</div>
{% if net_mode == "macvtap" %}{% field_error form.bridge_name "net_iface" %}{% endif %}
</div>
</fieldset>
<p class="small text-body-secondary mb-0">{% bs_icon 'info-circle' %} {% trans "The network starts at once, and with the host from then on." %}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Close" %}</button>
<button type="submit" class="btn btn-primary" name="create">{% trans "Create" %}</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Cancel" %}</button>
<button type="submit" class="btn btn-primary" name="create">{% trans "Create network" %}</button>
</div>
</form>
</div> <!-- /.modal-content -->
</div> <!-- /.modal-dialog -->
</div> <!-- /.modal -->
</form>
</div>
</div>
<script src="{% static 'js/choice_dialog.js' %}"></script>
<script>
// the IPv6 fields show with their box; a NAT or routed network needs a
// subnet (after the page's scripts, Bootstrap's among them)
document.addEventListener('DOMContentLoaded', function () {
var ipv6 = document.getElementById('net_ipv6');
function showIpv6() {
var fields = document.getElementById('net_ipv6_fields');
fields.hidden = !ipv6.checked;
fields.querySelectorAll('input').forEach(function (input) { input.disabled = !ipv6.checked; });
document.getElementById('net_subnet6').required = ipv6.checked;
}
ipv6.addEventListener('change', showIpv6);
choiceDialog(document.getElementById('AddNetPool'), 'forward', function (mode) {
document.getElementById('net_subnet').required = mode === 'nat' || mode === 'route';
showIpv6();
});
});
</script>
{% endif %}

View file

@ -1,52 +0,0 @@
{% load i18n %}
{% load bootstrap_icons %}
{% if request.user.is_superuser %}
<a href="#AddFixedNet4" type="button" class="btn btn-success float-end" title="{% trans "Add IPv4 Fixed Address" %}" data-bs-toggle="modal">
<span aria-hidden="true">{% bs_icon 'plus' %} </span>
</a>
<!-- Modal pool -->
<div class="modal fade" id="AddFixedNet4" tabindex="-1" role="dialog" aria-labelledby="AddFixedNet4Label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{% trans "Add IPv4 Fixed Address" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" action="" role="form" aria-label="Add IPV6 Fixed Address">{% csrf_token %}
<div class="modal-body">
<div class="row">
<label for="ipv4_fixed_subnet" class="col-sm-4 col-form-label">{% trans "Subnet Pool" %}</label>
<div class="col-sm-6">
<input id="ipv4_fixed_subnet" type="text" readonly class="form-control" name="subnet" value="{{ ipv4.subnet }}" required pattern="[0-9\/\.]+">
</div>
</div>
<div class="row">
<label for="ipv4_fixed_mac" class="col-sm-4 col-form-label">{% trans "MAC" %}</label>
<div class="col-sm-6">
<input id="ipv4_fixed_mac" type="text" class="form-control" name="mac" required pattern="[0-9a-fA-F:]+">
</div>
</div>
<div class="row">
<label for="ipv4_fixed_name" class="col-sm-4 col-form-label">{% trans "Name" %}</label>
<div class="col-sm-6">
<input id="ipv4_fixed_name" type="text" class="form-control" name="name" pattern="[a-zA-Z0-9_.\-]+">
</div>
</div>
<div class="row">
<label for="ipv4_fixed_address" class="col-sm-4 col-form-label">{% trans "Address" %}</label>
<div class="col-sm-6">
<input id="ipv4_fixed_address" type="text" class="form-control" name="address" required pattern="[0-9\/\.]+">
</div>
</div>
</div>
<div class="modal-footer">
<input name="ip_index" value="{{ ipv4.index }}" hidden/>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Close" %}</button>
<button type="submit" class="btn btn-primary" name="modify_fixed_address">{% trans "Create" %}</button>
</div>
</form>
</div> <!-- /.modal-content -->
</div> <!-- /.modal-dialog -->
</div> <!-- /.modal -->
{% endif %}

View file

@ -1,52 +0,0 @@
{% load i18n %}
{% load bootstrap_icons %}
{% if request.user.is_superuser %}
<a href="#AddFixedNet6" type="button" class="btn btn-success float-end" title="{% trans "Add IPV6 Fixed Address" %}" data-bs-toggle="modal">
<span aria-hidden="true">{% bs_icon 'plus' %} </span>
</a>
<!-- Modal pool -->
<div class="modal fade" id="AddFixedNet6" tabindex="-1" role="dialog" aria-labelledby="AddFixedNet6Label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{% trans "Add IPV6 Fixed Address" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" action="" role="form" aria-label="Add IPV6 Fixed Address form">{% csrf_token %}
<div class="modal-body">
<div class="row">
<label for="ipv6_fixed_subnet" class="col-sm-4 col-form-label">{% trans "Subnet Pool" %}</label>
<div class="col-sm-6">
<input id="ipv6_fixed_subnet" type="text" readonly class="form-control" name="subnet" value="{{ ipv6.subnet }}" required pattern="[0-9\/\.]+">
</div>
</div>
<div class="row">
<label for="ipv6_fixed_id" class="col-sm-4 col-form-label">{% trans "ID" %}</label>
<div class="col-sm-6">
<input id="ipv6_fixed_id" type="text" class="form-control" name="id" required pattern="[0-9a-fA-F\/\:]+">
</div>
</div>
<div class="row">
<label for="ipv6_fixed_name" class="col-sm-4 col-form-label">{% trans "Name" %}</label>
<div class="col-sm-6">
<input id="ipv6_fixed_name" type="text" class="form-control" name="name" pattern="[a-zA-Z0-9_.\-]+">
</div>
</div>
<div class="row">
<label for="ipv6_fixed_address" class="col-sm-4 col-form-label">{% trans "Address" %}</label>
<div class="col-sm-6">
<input id="ipv6_fixed_address" type="text" class="form-control" name="address" required pattern="[a-fA-F0-9\/\:]+">
</div>
</div>
</div>
<div class="modal-footer">
<input name="ip_index" value="{{ ipv6.index }}" hidden/>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Close" %}</button>
<button type="submit" class="btn btn-primary" name="modify_fixed_address">{% trans "Create" %}</button>
</div>
</form>
</div> <!-- /.modal-content -->
</div> <!-- /.modal-dialog -->
</div> <!-- /.modal -->
{% endif %}

View file

@ -5,435 +5,432 @@
{% block title %}{% trans "Network" %} - {{ pool }}{% endblock %}
{% block page_heading %}{% trans "Network" %}: {{ pool }}{% endblock page_heading %}
{% block page_heading %}{{ compute.name }} - {% trans "Network" %}: {{ pool }}{% endblock page_heading %}
{% block page_heading_extra %}
{# the network's own actions: starting or stopping it, the others in a menu #}
<div class="btn-group" role="group" aria-label="{% blocktrans %}Actions for network {{ pool }}{% endblocktrans %}">
<form method="post" class="btn-group">{% csrf_token %}
{% if state %}
<button type="submit" class="btn btn-outline-secondary" name="stop"
data-confirm="{% blocktrans with name=pool %}Stop network {{ name }}? Running VMs on it lose their connection through it at once.{% endblocktrans %}"
onclick="return confirm(this.dataset.confirm)">{% bs_icon 'stop-fill' %} {% trans "Stop" %}</button>
{% else %}
<button type="submit" class="btn btn-outline-secondary" name="start">{% bs_icon 'play-fill' %} {% trans "Start" %}</button>
{% endif %}
</form>
<div class="btn-group">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="dropdown" aria-expanded="false" aria-label="{% trans "More actions" %}">{% bs_icon 'three-dots' %}</button>
<ul class="dropdown-menu dropdown-menu-end shadow">
<li><form method="post">{% csrf_token %}
{% if autostart %}
<button type="submit" class="dropdown-item" name="unset_autostart"
data-confirm="{% blocktrans with name=pool %}Disable autostart of network {{ name }}? After the host restarts it stays stopped, and VMs that use it do not start.{% endblocktrans %}"
onclick="return confirm(this.dataset.confirm)">{% bs_icon 'toggle-off' %} {% trans "Disable autostart" %}</button>
{% else %}
<button type="submit" class="dropdown-item" name="set_autostart">{% bs_icon 'toggle-on' %} {% trans "Enable autostart" %}</button>
{% endif %}
</form></li>
{% if not state %}
<li><hr class="dropdown-divider"></li>
<li><form method="post">{% csrf_token %}
<button type="submit" class="dropdown-item text-danger" name="delete"
data-confirm="{% blocktrans with name=pool %}Delete network {{ name }}? VMs that use it no longer start.{% endblocktrans %}"
onclick="return confirm(this.dataset.confirm)">{% bs_icon 'trash' %} {% trans "Delete network" %}</button>
</form></li>
{% endif %}
</ul>
</div>
</div>
{% endblock page_heading_extra %}
{% block content %}
<div class="row">
<div class="col-lg-12">
{% include "computes/host_nav.html" with section="networks" detail=True %}
{% include "computes/host_nav.html" with section="networks" detail=True %}
</div>
<div class="card mb-3">
<div class="card-header">{% trans "Network" %}</div>
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-baseline gap-3">
<span class="text-body-secondary">{% trans "State" %}</span>
<span class="d-flex flex-wrap justify-content-end gap-1">
{% if state %}<span class="badge text-bg-success">{% trans "Active" %}</span>{% else %}<span class="badge text-bg-secondary">{% trans "Stopped" %}</span>{% endif %}
{% if autostart %}<span class="badge text-bg-light border">{% trans "Autostart" %}</span>{% endif %}
</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-baseline gap-3">
<span class="text-body-secondary">{% trans "Type" %}</span>
<span class="badge text-bg-light border">{{ mode_label }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-baseline gap-3">
<span class="text-body-secondary">{% if mode == "macvtap" %}{% trans "Host interface" %}{% elif mode == "bridge" %}{% trans "Host bridge" %}{% else %}{% trans "Bridge" %}{% endif %}</span>
<span class="font-monospace text-break text-end">{{ device|default:"–" }}</span>
</li>
{% if net_mac %}
<li class="list-group-item d-flex justify-content-between align-items-baseline gap-3">
<span class="text-body-secondary">{% trans "MAC" %}</span>
<span class="font-monospace">{{ net_mac }}</span>
</li>
{% endif %}
</ul>
<div class="card-footer small text-body-secondary">{{ mode_help }}</div>
</div>
<div class="row">
<div class="col-12">
<dl class="ms-3 row">
<dt class="col-6">{% trans "Network name" %}</dt>
<dd class="col-6">{{ pool }}</dd>
<dt class="col-6">{% trans "Device" %}</dt>
<dd class="col-6">{{ device }}</dd>
<dt class="col-6">{% trans "State" %}</dt>
<dd class="col-6">
<form action="" method="post" role="form" aria-label="Network start/stop/delete form">{% csrf_token %}
{% if state == 0 %}
<input type="submit" class="btn btn-sm btn-success" name="start" value="{% trans "Start" %}">
<input type="submit" class="btn btn-sm btn-danger" name="delete" value="{% trans "Delete" %}" data-confirm="{% blocktrans with name=pool %}Delete network {{ name }}? VMs that use it no longer start.{% endblocktrans %}" onclick="return confirm(this.dataset.confirm)">
{% else %}
<input type="submit" class="btn btn-sm btn-secondary" name="stop" value="{% trans "Stop" %}" data-confirm="{% blocktrans with name=pool %}Stop network {{ name }}? Running VMs on it lose their connection through it at once.{% endblocktrans %}" onclick="return confirm(this.dataset.confirm)">
{% if external %}
<div class="alert alert-secondary">{% bs_icon 'info-circle' %} {% trans "The VMs on this network take their addresses from the outside network: libvirt gives none." %}</div>
{% else %}
<div class="row row-cols-1 row-cols-md-2 g-3 mb-3">
{% for ip in ip_networks %}
<div class="col">
<div class="card h-100">
<div class="card-header d-flex flex-wrap align-items-center gap-2">
<span class="me-auto">{% if ip.family == "ipv6" %}IPv6{% else %}IPv4{% endif %}</span>
{% if ip.range %}
{# a running network keeps its range: libvirt changes it while it is stopped #}
<span {% if state %}title="{% trans "Stop the network to change its range" %}"{% endif %}>
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#DhcpRange"
data-ip-index="{{ ip.index }}" data-start="{{ ip.range.0 }}" data-end="{{ ip.range.1 }}" data-subnet="{{ ip.subnet }}"
{% if state %}disabled{% endif %}>{% bs_icon 'pencil' %} {% trans "DHCP range" %}</button>
</span>
{% endif %}
</form>
</dd>
<dt class="col-6">{% trans "Autostart" %}</dt>
<dd class="col-6">
<form action="" method="post" role="form" aria-label="Network enable/disable autostart form">{% csrf_token %}
{% if autostart == 0 %}
<input type="submit" class="btn btn-sm btn-success" name="set_autostart" value="{% trans "Enable" %}">
{% else %}
<input type="submit" class="btn btn-sm btn-secondary" name="unset_autostart" data-confirm="{% blocktrans with name=pool %}Disable autostart of network {{ name }}? After the host restarts it stays stopped, and VMs that use it do not start.{% endblocktrans %}" onclick="return confirm(this.dataset.confirm)" value="{% trans "Disable" %}">
{% endif %}
</form>
</dd>
</dl>
</div>
</div>
<div class="d-grid col-lg-12">
<button class="btn btn-light" role="button" data-bs-toggle="collapse" data-bs-target="#collapseXML" aria-expanded="true" aria-controls="collapseXML">
<span>{% bs_icon 'chevron-down' %} </span>
{% trans 'XML' %}
</button>
<div class="collapse" id="collapseXML">
<div class="card card-body">
<form method="post" role="form" aria-label="Network edit with XML form">{% csrf_token %}
<div class="xmlheight">
<input type="hidden" name="edit_xml"/>
<textarea id="edit_editor">{{ xml }}</textarea>
</div>
<button type="submit" class="btn btn-primary float-end" name="edit_network">
{% trans "Edit" %}
</button>
</form>
</div>
</div>
</div>
<h5 class="mb-3">{% trans "IPv4 Configuration" %}</h5>
<dl class="ms-3 row">
<dt class="col-6">{% trans "IPv4 Forwarding" %}</dt>
<dd class="col-6">
{% if net_forward.0 == 'nat' %}
{% trans "NAT" %}
{% endif %}
{% if net_forward.0 == 'route' %}
{% trans "ROUTE" %}
{% endif %}
{% if net_forward.0 == 'bridge' %}
{% trans "BRIDGE" %}
{% endif %}
{% if not net_forward.0 %}
{% trans "ISOLATE" %}
{% endif %}
</dd>
<dt class="col-6">{% trans "Network" %}</dt>
<dd class="col-6">{{ ipv4.subnet|default:"-" }}</dd>
<dt class="col-6">{% trans "DHCP" %}:</dt>
<dd class="col-6">
{% if ipv4.dhcp %}
<span class="badge bg-success-subtle text-success-emphasis">{% trans "ON" %}</span>
{% else %}
<span class="badge bg-danger-subtle text-danger-emphasis">{% trans "OFF" %}</span>
{% endif %}
</dd>
{% if ipv4.range %}
{% if state %}
<dt class="col-6">{% trans "Start" %}</dt>
<dd class="col-6">{{ ipv4.range.0 }}</dd>
<dt class="col-6">{% trans "End" %}</dt>
<dd class="col-6">{{ ipv4.range.1 }}</dd>
{% else %}
<form class="col-lg-12 ps-0" method="post" role="form" aria-label="Edit dhcp range form">{% csrf_token %}
<dt class="col-6">{% trans "Start" %}</dt>
<dd class="col-6"><input name="range_start" value="{{ ipv4.range.0 }}"/></dd>
<dt class="col-6">{% trans "End" %}</dt>
<dd class="col-6"><input name="range_end" value="{{ ipv4.range.1 }}"/></dd>
<dt class="col-6"></dt>
<dd class="col-6 ">
<input type="submit"
class="btn btn-primary"
value="Apply"
name="modify_dhcp_range"
title="{% trans "Edit DHCP Range" %}"
/>
</dd>
<input hidden name="ip_index" value="{{ ipv4.index }}"/>
</form>
{% endif %}
{% endif %}
</dl>
{% if ipv4.dhcp %}
{% include 'modify_ipv4_fixed_address.html' %}
<h5 class="mb-3">{% trans "IPv4 Fixed Addresses" %}</h5>
{% endif %}
{% if ipv4.hosts %}
<div class="d-grid col-12">
<button class="btn btn-light" role="button" data-bs-toggle="collapse" data-bs-target="#collapseipv4fix" aria-expanded="true" aria-controls="collapseXML">
<span>{% bs_icon 'chevron-down' %} </span>
{% trans 'Show' %}
</button>
<div id="collapseipv4fix" class="collapse">
<div class="col-4 float-end mt-2">
<div class="input-group ">
<input type="text" class="form-control" id="filter_input">
<input type="button" class="btn btn-secondary" id="filter_button" value="Filter">
<button type="button" class="btn btn-secondary" id="filter_clear">{% trans 'Clear' %}</button>
</div>
</div>
<div class="table-responsive">
<table id="ipv4_table" class="table table-hover">
<thead>
<tr>
<th scope="col">{% trans "MAC" %}</th>
<th scope="col">{% trans "Address" %}</th>
<th scope="col">{% trans "Name" %}</th>
<th scope="col">{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for fix4 in ipv4.hosts %}
<tr>
<form method="post" role="form" aria-label="IPv4 edit/delete form">{% csrf_token %}
<td><input class="form-control" value="{{ fix4.mac|default_if_none:"" }}" name="mac" readonly/></td>
<td><input class="form-control" value="{{ fix4.ip }}" name="address" /></td>
<td><input class="form-control" value="{{ fix4.name }}" name="name" /></td>
<td class="text-nowrap">
<input hidden name="ip_index" value="{{ ipv4.index }}"/>
<input hidden name="original" value="{{ fix4.ip }}"/>
{% if fix4.mac %}{# an entry is changed by its MAC #}
<button type="submit" class="btn btn-sm btn-primary"
name="modify_fixed_address"
title="{% trans "Edit entry" %}">
{% bs_icon 'save' %}
</button>
{% endif %}
<button type="submit" class="btn btn-sm btn-danger"
name="delete_fixed_address"
title="{% trans "Delete entry" %}" data-confirm="{% blocktrans with mac=fix4.mac|default:fix4.ip %}Delete the fixed address of {{ mac }}? The VM may get another address when its lease ends.{% endblocktrans %}" onclick="return confirm(this.dataset.confirm)">
{% bs_icon 'trash' %}
</button>
</td>
</form>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<h5 class="mb-3">{% trans "IPv6 Configuration" %}</h5>
<dl class="ms-3 row">
<dt class="col-6">{% trans "IPv6 Forwarding" %}</dt>
<dd class="col-6">
{% if not net_forward.0 or net_forward.0 == "none" %}
{% trans "ISOLATE" %}
{% elif net_forward.0 == "nat" or net_forward.0 == "route" or net_forward.0 == "open" %}
{% trans "ROUTE" %}
{% else %}
{% trans "From the outside network" %}
{% endif %}
</dd>
<dt class="col-6">{% trans "Network" %}</dt>
<dd class="col-6">{{ ipv6.subnet|default:"-" }}</dd>
<dt class="col-6">{% trans "DHCP" %}</dt>
<dd class="col-6">
{% if ipv6.dhcp %}
<span class="badge bg-success-subtle text-success-emphasis">{% trans "ON" %}</span>
{% else %}
<span class="badge bg-danger-subtle text-danger-emphasis">{% trans "OFF" %}</span>
{% endif %}
</dd>
{% if ipv6.range %}
{% if state %}
<dt class="col-6">{% trans "Start" %}</dt>
<dd class="col-6">{{ ipv6.range.0 }}</dd>
<dt class="col-6">{% trans "End" %}</dt>
<dd class="col-6">{{ ipv6.range.1 }}</dd>
{% else %}
<form class="col-lg-12 ps-0" method="post" role="form" aria-label="Edit dhcp range form">{% csrf_token %}
<dt class="col-6">{% trans "Start" %}</dt>
<dd class="col-6"><input name="range_start" value="{{ ipv6.range.0 }}"/></dd>
<dt class="col-6">{% trans "End" %}</dt>
<dd class="col-6"><input name="range_end" value="{{ ipv6.range.1 }}"/></dd>
<dt class="col-6"></dt>
<dd class="d-grid col-6">
<input
type="submit"
class="btn btn-primary"
value="Apply"
name="modify_dhcp_range"
title="{% trans "Edit DHCP Range" %}"
/>
</dd>
<input hidden name="ip_index" value="{{ ipv6.index }}"/>
</form>
{% endif %}
{% endif %}
</dl>
{% if ipv6.dhcp %}
{% include 'modify_ipv6_fixed_address.html' %}
<h5 class="mb-3">{% trans "IPv6 Fixed Addresses" %}</h5>
{% endif %}
{% if ipv6.hosts %}
<div class="d-grid col-12">
<button class="btn btn-light" role="button" data-bs-toggle="collapse" data-bs-target="#collapseipv6fix" aria-expanded="true" aria-controls="collapseipv6fix">
<span>{% bs_icon 'chevron-down' %} </span>
{% trans 'Show' %}
</button>
<div id="collapseipv6fix" class="collapse">
<div class="col-4 float-end mt-2">
<div class="input-group">
<input type="text" class="form-control" id="filter_input_ipv6">
<input type="button" class="btn btn-secondary" id="filter_button_ipv6" value="Filter">
<button type="button" class="btn btn-secondary" id="filter_clear_ipv6">{% trans 'Clear' %}</button>
</div>
</div>
<div class="table-responsive">
<table id="ipv6_table" class="table table-hover">
<thead>
<tr>
<th scope="col">{% trans "ID" %}</th>
<th scope="col">{% trans "Address" %}</th>
<th scope="col">{% trans "Name" %}</th>
<th scope="col">{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for fix6 in ipv6.hosts %}
<tr>
<form method="post" role="form" aria-label="IP v6 edit/delete form">{% csrf_token %}
<td><input class="form-control" value="{{ fix6.id|default_if_none:"" }}" name="id" readonly/></td>
<td><input class="form-control" value="{{ fix6.ip }}" name="address" /></td>
<td><input class="form-control" value="{{ fix6.name }}" name="name" /></td>
<td class="text-nowrap">
<input hidden name="ip_index" value="{{ ipv6.index }}"/>
<input hidden name="original" value="{{ fix6.ip }}"/>
{% if fix6.id %}{# an entry is changed by its DUID #}
<button type="submit" class="btn btn-sm btn-primary"
name="modify_fixed_address"
title="{% trans "Edit entry" %}">
<span>{% bs_icon 'save' %} </span>
</button>
{% endif %}
<button type="submit" class="btn btn-sm btn-danger"
name="delete_fixed_address"
title="{% trans "Delete entry" %}" data-confirm="{% blocktrans with id=fix6.id|default:fix6.ip %}Delete the fixed address of {{ id }}? The VM may get another address when its lease ends.{% endblocktrans %}" onclick="return confirm(this.dataset.confirm)">
<span>{% bs_icon 'trash' %} </span>
</button>
</td>
</form>
</tr>
{% endfor %}
</tbody>
</table>
<ul class="list-group list-group-flush flex-grow-1">
<li class="list-group-item d-flex justify-content-between align-items-baseline gap-3">
<span class="text-body-secondary">{% trans "Subnet" %}</span><span class="font-monospace text-break text-end">{{ ip.subnet|default:"–" }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-baseline gap-3">
<span class="text-body-secondary">{% trans "Host's address" %}</span><span class="font-monospace text-break text-end">{{ ip.address }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-baseline gap-3">
<span class="text-body-secondary">{% trans "DHCP" %}</span>
<span class="text-end">
{% if ip.range %}<span class="font-monospace text-break">{{ ip.range.0 }} – {{ ip.range.1 }}</span>
{% elif ip.dhcp %}{% trans "Fixed addresses only" %}
{% else %}<span class="badge text-bg-secondary">{% trans "Off" %}</span>{% endif %}
</span>
</li>
</ul>
</div>
</div>
{% empty %}
<div class="col">
<div class="card h-100"><div class="card-body small text-body-secondary">{% trans "The network has no address of the host: its VMs reach each other only." %}</div></div>
</div>
{% endfor %}
{% if ip_networks and "ipv6" not in families %}
<div class="col">
<div class="card h-100">
<div class="card-header">IPv6</div>
<div class="card-body small text-body-secondary">{% trans "The network has no IPv6 address; the XML definition below can give it one." %}</div>
</div>
</div>
{% endif %}
</div>
{% endif %}
{% if net_forward.0 == 'route' or net_forward.0 == 'nat' or not net_forward.0 %}{# an isolated network has no forward #}
{% if state == 0 and qos.items|length != 2%}
<form method="post" name="set_qos" role="form" aria-label="Create network qos form">{% csrf_token %}
{% include 'add_network_qos.html' %}
</form>
{% endif %}
<h5 class="mb-3">{% trans "QoS Configuration" %}</h5>
<div class="col-sm-12">
<div class="card mb-3">
<div class="card-header d-flex flex-wrap align-items-center gap-2">
<span class="me-auto">{% trans "Fixed addresses" %}</span>
{% if fixed_addresses %}
<input id="filter" class="form-control form-control-sm w-auto" type="search" placeholder="{% trans "Search" %}" aria-label="{% trans "Search fixed addresses" %}">
{% endif %}
{% if dhcp_networks %}
<button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#FixedAddress">
{% bs_icon 'plus-lg' %} {% trans "Add" %}
</button>
{% endif %}
</div>
{% if fixed_addresses %}
<div class="table-responsive">
<table class="table table-hover align-middle">
<table class="table table-hover align-middle mb-0" data-sortable>
<thead>
<tr>
<th scope="col">{% trans "Direction" %}</th>
<th scope="col">{% trans "Average" %}</th>
<th scope="col">{% trans "Peak" %}</th>
<th scope="col">{% trans "Burst" %}</th>
<th scope="col">{% trans "Actions" %}</th>
<th scope="col">{% trans "Address" %}</th>
<th scope="col">{% trans "MAC / DUID" %}</th>
<th scope="col">{% trans "Name" %}</th>
<th scope="col" data-sort-method="none"><span class="visually-hidden">{% trans "Actions" %}</span></th>
</tr>
</thead>
<tbody>
{% for q, att in qos.items %}
<form method="post" role="form" aria-label="QoS configuration form">{% csrf_token %}
<tr>
<td><label class="col-form-label">{{ q | capfirst }}</label></td>
<td><input id="qos_average" class="form-control" name="qos_average"
value="{{ att.average|default:'' }}"/>
</td>
<td><input id="qos_peak" class="form-control" name="qos_peak"
value="{{ att.peak|default:'' }}"/>
</td>
<td><input id="qos_burst" class="form-control" name="qos_burst"
value="{{ att.burst|default:'' }}"/>
</td>
<td class="text-nowrap">
<input name="qos_direction" value="{{ q }}" hidden/>
<button type="submit" class="btn btn-sm btn-primary"
name="set_qos"
title="{% trans "Edit QoS" %}">
{% bs_icon 'save' %}
</button>
<button type="submit" class="btn btn-sm btn-danger"
name="unset_qos"
title="{% trans "Delete QoS" %}" data-confirm="{% blocktrans with direction=q name=pool %}Remove the {{ direction }} limit of network {{ name }}? It stays in force until the network is stopped and started.{% endblocktrans %}" onclick="return confirm(this.dataset.confirm)">
{% bs_icon 'trash' %}
</button>
<tbody class="searchable">
{% for host in fixed_addresses %}
<tr data-search="{{ host.ip }} {{ host.mac|default:"" }}{{ host.id|default:"" }} {{ host.name }}">
<td class="font-monospace text-nowrap">{{ host.ip }}</td>
<td class="font-monospace text-nowrap">{{ host.mac|default:host.id|default:"–" }}</td>
<td class="text-break">{{ host.name|default:"–" }}</td>
<td class="text-end text-nowrap">
<div class="btn-group" role="group" aria-label="{% blocktrans with address=host.ip %}Actions for {{ address }}{% endblocktrans %}">
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#FixedAddress"
data-ip-index="{{ host.ip_index }}" data-original="{{ host.ip }}" data-address="{{ host.ip }}"
data-mac="{{ host.mac|default:host.id|default:"" }}" data-name="{{ host.name }}" aria-label="{% trans "Edit" %}">{% bs_icon 'pencil' %} <span class="d-none d-sm-inline">{% trans "Edit" %}</span></button>
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="dropdown" aria-expanded="false"
data-bs-popper-config='{"strategy": "fixed"}' aria-label="{% trans "More actions" %}">{% bs_icon 'three-dots' %}</button>
<ul class="dropdown-menu dropdown-menu-end shadow">
<li><form method="post">{% csrf_token %}
<input type="hidden" name="ip_index" value="{{ host.ip_index }}">
<input type="hidden" name="original" value="{{ host.ip }}">
<button type="submit" class="dropdown-item text-danger" name="delete_fixed_address"
data-confirm="{% blocktrans with address=host.ip %}Delete the fixed address {{ address }}? The VM may get another address when its lease ends.{% endblocktrans %}"
onclick="return confirm(this.dataset.confirm)">{% bs_icon 'trash' %} {% trans "Delete" %}</button>
</form></li>
</ul>
</div>
</div>
</td>
</tr>
</form>
{% endfor %}
</tbody>
</table>
</div>
<div role="status">
<div id="no-match" class="text-center text-body-secondary py-3" hidden>{% trans "No fixed address matches the search." %}</div>
</div>
{% elif dhcp_networks %}
<div class="card-body small text-body-secondary">{% trans "No fixed addresses: every VM gets one from the DHCP range." %}</div>
{% else %}
<div class="card-body small text-body-secondary">{% trans "The network serves no DHCP, so it has no fixed addresses." %}</div>
{% endif %}
</div>
{% endif %}
<div class="card mb-3">
<div class="card-header">{% trans "VMs on this network" %}</div>
{% if vms %}
<ul class="list-group list-group-flush">
{% for vm in vms %}
<li class="list-group-item d-flex justify-content-between align-items-center gap-3">
{% if vm.id %}<a href="{% url 'instances:instance' vm.id %}">{{ vm.name }}</a>{% else %}<span>{{ vm.name }}</span>{% endif %}
{% if vm.connected %}<span class="badge text-bg-success">{% trans "Connected" %}</span>{% else %}<span class="badge text-bg-light border">{% trans "Configured" %}</span>{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<div class="card-body small text-body-secondary">{% trans "No VM is defined on this network." %}</div>
{% endif %}
</div>
{% if qos_supported %}
<div class="card mb-3">
<div class="card-header d-flex flex-wrap align-items-center gap-2">
<span class="me-auto">{% trans "Bandwidth limits" %}</span>
{% if qos.items|length < 2 %}
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#QosLimit">{% bs_icon 'plus-lg' %} {% trans "Add limit" %}</button>
{% endif %}
</div>
{% if qos %}
<div class="table-responsive">
<table class="table align-middle mb-0">
<thead>
<tr>
<th scope="col">{% trans "Direction" %}</th>
<th scope="col">{% trans "Average" %}</th>
<th scope="col">{% trans "Peak" %}</th>
<th scope="col">{% trans "Burst" %}</th>
<th scope="col"><span class="visually-hidden">{% trans "Actions" %}</span></th>
</tr>
</thead>
<tbody>
{% for direction, limit in qos.items %}
<tr>
<td>{% if direction == "inbound" %}{% trans "Inbound" %}{% else %}{% trans "Outbound" %}{% endif %}</td>
<td>{% if limit.average %}{{ limit.average }} KiB/s{% else %}–{% endif %}</td>
<td>{% if limit.peak %}{{ limit.peak }} KiB/s{% else %}–{% endif %}</td>
<td>{% if limit.burst %}{{ limit.burst }} KiB{% else %}–{% endif %}</td>
<td class="text-end text-nowrap">
<div class="btn-group" role="group" aria-label="{% trans "Actions for the limit" %}">
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#QosLimit"
data-direction="{{ direction }}" data-average="{{ limit.average|default:"" }}" data-peak="{{ limit.peak|default:"" }}"
data-burst="{{ limit.burst|default:"" }}" aria-label="{% trans "Edit" %}">{% bs_icon 'pencil' %} <span class="d-none d-sm-inline">{% trans "Edit" %}</span></button>
<form method="post" class="btn-group">{% csrf_token %}
<input type="hidden" name="qos_direction" value="{{ direction }}">
<button type="submit" class="btn btn-sm btn-outline-secondary" name="unset_qos" aria-label="{% trans "Remove" %}"
data-confirm="{% blocktrans with name=pool %}Remove the {{ direction }} limit of network {{ name }}? It stays in force until the network is stopped and started.{% endblocktrans %}"
onclick="return confirm(this.dataset.confirm)">{% bs_icon 'trash' %}</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card-body small text-body-secondary">{% trans "No limits: the network's VMs share the host's bandwidth freely." %}</div>
{% endif %}
{% if state %}
<div class="card-footer small text-body-secondary">{% trans "A change takes effect when the network is stopped and started again." %}</div>
{% endif %}
</div>
{% endif %}
<section class="accordion mb-3" aria-labelledby="xml-heading">
<div class="accordion-item">
<h2 class="accordion-header" id="xml-heading">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#network-xml" aria-expanded="false" aria-controls="network-xml">
{% trans "XML definition" %}
</button>
</h2>
<div class="accordion-collapse collapse" id="network-xml">
<form method="post" class="accordion-body" aria-label="{% trans "Change the network's XML" %}">{% csrf_token %}
<p class="small text-body-secondary">{% trans "The saved definition: a running network takes a change when it is stopped and started again." %}</p>
<input type="hidden" name="edit_xml">
<div class="xmlheight mb-3"><textarea id="edit_editor" aria-label="{% trans "Network XML" %}">{{ xml }}</textarea></div>
<div class="d-flex justify-content-end">
<button type="submit" class="btn btn-primary" name="edit_network">{% trans "Save" %}</button>
</div>
</form>
</div>
</div>
</section>
{# one dialog for every fixed address: Add opens it empty, a row's Edit with its entry, which stays on its subnet #}
{% if dhcp_networks %}
<div class="modal fade" id="FixedAddress" tabindex="-1" aria-labelledby="FixedAddressLabel" aria-hidden="true">
<div class="modal-dialog">
<form method="post" class="modal-content">{% csrf_token %}
<input type="hidden" name="original">
<div class="modal-header">
<h5 class="modal-title" id="FixedAddressLabel">{% trans "Fixed address" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% trans "Close" %}"></button>
</div>
<div class="modal-body">
<div class="mb-3" id="fixed_subnet_row">
<label for="fixed_ip_index" class="form-label">{% trans "Subnet" %}</label>
<select id="fixed_ip_index" name="ip_index" class="form-select">
{% for ip in dhcp_networks %}<option value="{{ ip.index }}">{{ ip.subnet }}</option>{% endfor %}
</select>
</div>
<div class="mb-3">
<label for="fixed_address" class="form-label">{% trans "Address" %}</label>
<input id="fixed_address" name="address" class="form-control font-monospace" required>
</div>
<div class="mb-3">
<label for="fixed_mac" class="form-label">{% trans "MAC (IPv4) or DUID (IPv6)" %}</label>
<input id="fixed_mac" name="mac" class="form-control font-monospace" pattern="[0-9a-fA-F:]+" aria-describedby="fixed_mac_help">
<div class="form-text" id="fixed_mac_help">{% trans "The VM's network card; without it, the VM is known by its name." %}</div>
</div>
<div class="mb-3">
<label for="fixed_name" class="form-label">{% trans "Name" %}</label>
<input id="fixed_name" name="name" class="form-control" pattern="[a-zA-Z0-9_.\-]+">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Cancel" %}</button>
<button type="submit" class="btn btn-primary" name="modify_fixed_address">{% trans "Save" %}</button>
</div>
</form>
</div>
</div>
{% endif %}
<div class="modal fade" id="DhcpRange" tabindex="-1" aria-labelledby="DhcpRangeLabel" aria-hidden="true">
<div class="modal-dialog">
<form method="post" class="modal-content">{% csrf_token %}
<input type="hidden" name="ip_index">
<div class="modal-header">
<h5 class="modal-title" id="DhcpRangeLabel">{% trans "DHCP range" %} <span class="text-body-secondary font-monospace" id="dhcp_subnet"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% trans "Close" %}"></button>
</div>
<div class="modal-body row g-3">
<div class="col-sm-6">
<label for="range_start" class="form-label">{% trans "From" %}</label>
<input id="range_start" name="range_start" class="form-control font-monospace" required>
</div>
<div class="col-sm-6">
<label for="range_end" class="form-label">{% trans "To" %}</label>
<input id="range_end" name="range_end" class="form-control font-monospace" required>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Cancel" %}</button>
<button type="submit" class="btn btn-primary" name="modify_dhcp_range">{% trans "Save" %}</button>
</div>
</form>
</div>
</div>
{% if qos_supported %}
<div class="modal fade" id="QosLimit" tabindex="-1" aria-labelledby="QosLimitLabel" aria-hidden="true">
<div class="modal-dialog">
<form method="post" class="modal-content">{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title" id="QosLimitLabel">{% trans "Bandwidth limit" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% trans "Close" %}"></button>
</div>
<div class="modal-body row g-3">
<div class="col-12">
<label for="qos_direction" class="form-label">{% trans "Direction" %}</label>
<select id="qos_direction" name="qos_direction" class="form-select">
{# a direction with a limit is changed by its own Edit button #}
<option value="inbound"{% if "inbound" in qos %} data-taken{% endif %}>{% trans "Inbound: to the VMs" %}</option>
<option value="outbound"{% if "outbound" in qos %} data-taken{% endif %}>{% trans "Outbound: from the VMs" %}</option>
</select>
</div>
<div class="col-sm-4">
<label for="qos_average" class="form-label">{% trans "Average (KiB/s)" %}</label>
<input id="qos_average" name="qos_average" type="number" min="1" class="form-control" required>
</div>
<div class="col-sm-4">
<label for="qos_peak" class="form-label">{% trans "Peak (KiB/s)" %}</label>
<input id="qos_peak" name="qos_peak" type="number" min="0" class="form-control" aria-describedby="qos_peak_help">
</div>
<div class="col-sm-4">
<label for="qos_burst" class="form-label">{% trans "Burst (KiB)" %}</label>
<input id="qos_burst" name="qos_burst" type="number" min="0" class="form-control">
</div>
<div class="col-12 form-text mt-1" id="qos_peak_help">{% trans "Linux applies a peak to inbound traffic only." %}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Cancel" %}</button>
<button type="submit" class="btn btn-primary" name="set_qos">{% trans "Save" %}</button>
</div>
</form>
</div>
</div>
{% endif %}
{% endblock %}
{% block script %}
<script>
$.expr[':'].Contains = $.expr.createPseudo(function (arg) {
return function (elem) {
return $(elem).text().toUpperCase().indexOf(arg.toUpperCase()) >= 0;
};
});
$(document).ready(function () {
// add event button labeled "filter"
$('#filter_button').click(function (event) {
// get value
let filter_val = $('#filter_input').val();
if (filter_val == '') {
// show all
$('#ipv4_table tbody tr').show();
} else {
// hide non-matching items
let row_not_contains4 = $('#ipv4_table tbody tr input:not([value*=\'' + filter_val + '\'])');
row_not_contains4.closest('tr').hide();
// show only matches
let row_contains4 = $('#ipv4_table tbody tr input[value*=\'' + filter_val + '\']');
row_contains4.closest('tr').show();
}
});
// add event button labeled "clear"
$('#filter_clear').click(function (event) {
$('#filter_input').val('');
$('#filter_button').click();
});
// trigger filter when enter key pressed
$('#filter_input').keyup(function (event) {
if (event.keyCode == 13) {
$('#filter_button').click();
}
});
// add event button labeled "filter"
$('#filter_button_ipv6').click(function (event) {
// get value
let filter_val = $('#filter_input_ipv6').val();
if (filter_val == '') {
// show all
$('#ipv6_table tbody tr').show();
} else {
// hide non-matching items
let row_not_contains6 = $('#ipv6_table tbody tr input:not([value*=\'' + filter_val + '\'])');
row_not_contains6.closest('tr').hide();
// show only matches
let row_contains6 = $('#ipv6_table tbody tr input[value*=\'' + filter_val + '\']');
row_contains6.closest('tr').show();
}
});
// add event button labeled "clear"
$('#filter_clear_ipv6').click(function (event) {
$('#filter_input_ipv6').val('');
$('#filter_button_ipv6').click();
});
// trigger filter when enter key pressed
$('#filter_input_ipv6').keyup(function (event) {
if (event.keyCode == 13) {
$('#filter_button_ipv6').click();
}
});
});
</script>
{% if fixed_addresses %}<script src="{% static 'js/table_filter.js' %}"></script>{% endif %}
<script src="{% static "js/ace/ace.js" %}"></script>
<script>
var editor = ace.edit("edit_editor");
editor.getSession().setMode("ace/mode/xml");
var edit_input = $('input[name="edit_xml"]');
var editInput = $('input[name="edit_xml"]');
editor.getSession().on("change", function () {
edit_input.val(editor.getSession().getValue());
editInput.val(editor.getSession().getValue());
});
// the editor measures itself when its section is shown
document.getElementById("network-xml").addEventListener("shown.bs.collapse", () => editor.resize());
// a dialog opened by a row's button gets that row's values, one opened by an Add button starts empty
function fillFrom(dialogId, fill) {
var dialog = document.getElementById(dialogId);
if (dialog) dialog.addEventListener("show.bs.modal", function (event) {
fill(dialog.querySelector("form"), event.relatedTarget.dataset);
});
}
fillFrom("FixedAddress", function (form, data) {
form.elements.original.value = data.original || "";
form.elements.address.value = data.address || "";
form.elements.mac.value = data.mac || "";
form.elements.name.value = data.name || "";
if (data.ipIndex) form.elements.ip_index.value = data.ipIndex;
document.getElementById("fixed_subnet_row").hidden = Boolean(data.original);
});
fillFrom("DhcpRange", function (form, data) {
form.elements.ip_index.value = data.ipIndex;
form.elements.range_start.value = data.start;
form.elements.range_end.value = data.end;
document.getElementById("dhcp_subnet").textContent = data.subnet;
});
fillFrom("QosLimit", function (form, data) {
var direction = form.elements.qos_direction;
direction.querySelectorAll("option").forEach(function (option) {
option.disabled = data.direction ? option.value !== data.direction : option.hasAttribute("data-taken");
});
direction.value = data.direction || direction.querySelector("option:not(:disabled)").value;
form.elements.qos_average.value = data.average || "";
form.elements.qos_peak.value = data.peak || "";
form.elements.qos_burst.value = data.burst || "";
});
</script>
{% endblock %}
{% endblock %}

View file

@ -9,60 +9,65 @@
{% block page_heading_extra %}{% include 'create_net_block.html' %}{% endblock page_heading_extra %}
{% block content %}
<div class="row">
<div class="col-lg-12">
{% include "computes/host_nav.html" with section="networks" %}
</div>
</div>
{% include "computes/host_nav.html" with section="networks" %}
<div class="row">
{% if not networks %}
<div class="col-lg-12">
<div class="alert alert-warning shadow-sm">
{% bs_icon 'exclamation-triangle' %} <strong>{% trans "Warning" %} :</strong> {% trans "Hypervisor doesn't have any Network" %}
{% if not networks %}
<div class="alert alert-warning shadow-sm">
{% bs_icon 'exclamation-triangle' %} {% trans "This host has no networks." %}
</div>
{% else %}
{# a card per network, as the host Overview's Hardware and Software cards #}
<div class="row row-cols-1 row-cols-md-2 g-3 mb-4">
{% for net in networks %}
<div class="col">
<div class="card h-100">
<div class="card-header d-flex flex-wrap align-items-center gap-2">
<a class="fw-semibold me-auto text-break" href="{% url 'network' compute.id net.name %}">{{ net.name }}</a>
{% if net.status %}
<span class="badge text-bg-success">{% trans "Active" %}</span>
{% else %}
<span class="badge text-bg-secondary">{% trans "Stopped" %}</span>
{% endif %}
{% if net.autostart %}<span class="badge text-bg-light border">{% trans "Autostart" %}</span>{% endif %}
</div>
<ul class="list-group list-group-flush flex-grow-1">
<li class="list-group-item d-flex justify-content-between align-items-baseline gap-3">
<span class="text-body-secondary">{% trans "Type" %}</span>
<span class="badge text-bg-light border">{{ net.mode_label }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-baseline gap-3">
<span class="text-body-secondary">{% trans "Device" %}</span>
<span class="font-monospace text-break text-end">{{ net.device|default:"–" }}</span>
</li>
{% if net.external %}
<li class="list-group-item d-flex justify-content-between align-items-baseline gap-3">
<span class="text-body-secondary">{% trans "Addresses" %}</span>
<span class="text-body-secondary text-end">{% trans "from the outside network" %}</span>
</li>
{% else %}
{% for ip in net.ips %}
<li class="list-group-item d-flex justify-content-between align-items-baseline gap-3">
<span class="text-body-secondary">{% if ip.family == "ipv6" %}IPv6{% else %}IPv4{% endif %}</span>
<span class="text-end">
<span class="font-monospace text-break">{{ ip.subnet|default:ip.address }}</span>
<span class="d-block small text-body-secondary">
{% if ip.range %}{% blocktrans with start=ip.range.0 end=ip.range.1 %}DHCP {{ start }} – {{ end }}{% endblocktrans %}
{% elif ip.dhcp %}{% trans "DHCP: fixed addresses only" %}
{% else %}{% trans "No DHCP" %}{% endif %}
</span>
</span>
</li>
{% empty %}
<li class="list-group-item d-flex justify-content-between align-items-baseline gap-3">
<span class="text-body-secondary">{% trans "Addresses" %}</span>
<span class="text-body-secondary text-end">{% trans "none" %}</span>
</li>
{% endfor %}
{% endif %}
</ul>
</div>
</div>
{% else %}
{% for pool in networks %}
<div class="col-6 col-md-4">
<div class="mb-3 card {% if pool.status %}border-success{% else %}border-danger{% endif %} shadow-sm">
<div class="card-header {% if pool.status %}bg-success-subtle{% else %}bg-danger-subtle{% endif %}">
<h6 class="my-0 card-title">
<a class="card-link {% if pool.status %}text-success-emphasis{% else %}text-danger-emphasis{% endif %}" href="{% url 'network' compute.id pool.name %}">{{ pool.name }}</a></h6>
</div>
<div class="px-1 card-body">
<dl class="row">
<dt class="col-6">{% trans "Device" %}</dt>
<dd class="col-6">{{ pool.device }}</dd>
<dt class="col-6">{% trans "Forward" %}</dt>
<dd class="col-6">{{ pool.forward|upper }}</dd>
</dl>
</div>
</div>
</div>
{% endfor %}
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% endblock %}
{% block script %}
<script>
$(document).ready(function () {
// a hidden field is disabled: the browser neither checks nor sends it
function show(rows, on) {
$(rows).toggle(on).find(':input').prop('disabled', !on);
}
function update() {
var forward = $('#forward_select').val();
var bridged = forward == 'bridge' || forward == 'macvtap';
show('.bridge_name_form_group', bridged);
show('.openvswitch', forward == 'bridge');
show('.bridge_name_form_group_dhcp', !bridged);
show('.ipv6_group', !bridged && $('#enable_ipv6').is(':checked'));
$('#bridge_label').text(forward == 'macvtap' ? "{% trans 'Dev Name'|escapejs %}" : "{% trans 'Bridge Name'|escapejs %}");
$('#bridge_name').attr('placeholder', forward == 'macvtap' ? 'eth0' : 'br0');
}
$('#forward_select, #enable_ipv6').change(update);
update();
});
</script>
{% endblock %}

View file

@ -96,5 +96,5 @@ class NetworkRowTests(TestCase):
"dhcp4": "on"})
self.client.post(url, {"create": "", "name": "bad name!", "forward": "nat"}) # invalid form
response = self.client.post(url, {"create": "", "name": "lab", "forward": "nat", "subnet": "203.0.113.0/24"})
self.assertIn("* This host has a network of that name", [str(m) for m in get_messages(response.wsgi_request)])
self.assertContains(response, "This host has a network of that name") # in the dialog, open again
self.assertEqual(rows(), [("Network audit-net: add", True), ("Network lab: add: name already in use", False)])

View file

@ -1,31 +1,46 @@
"""The fixed-address dialogs take real MAC addresses and host names: the
browser refuses what their pattern attributes do not match."""
"""The network page's fixed-address dialog takes real MAC addresses and host
names: the browser refuses what its pattern attributes do not match. Runs
on a private libvirt test driver."""
import re
from types import SimpleNamespace
import tempfile
from unittest.mock import patch
from django.template.loader import render_to_string
from django.test import SimpleTestCase
import libvirt
from computes.models import Compute
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from lxml import html
from networks.tests.driver import without_inactive_flag
NODE = """<node><network><name>lab</name><bridge name='virbr9'/>
<ip address='192.0.2.1' netmask='255.255.255.0'><dhcp><range start='192.0.2.10' end='192.0.2.20'/></dhcp></ip>
</network></node>"""
def pattern(template, name):
superuser = SimpleNamespace(user=SimpleNamespace(is_superuser=True))
page = html.fromstring(render_to_string(template, {"request": superuser}))
return re.compile(page.xpath(f"//input[@name='{name}']/@pattern")[0])
class FixedAddressFormTests(SimpleTestCase):
class FixedAddressFormTests(TestCase):
def test_macs_and_host_names_are_accepted(self):
mac = pattern("modify_ipv4_fixed_address.html", "mac")
for value in ("52:54:00:ab:cd:ef", "52:54:00:AB:CD:EF"):
node = tempfile.NamedTemporaryFile("w", suffix=".xml")
self.addCleanup(node.close)
node.write(NODE)
node.flush()
conn = libvirt.open(f"test://{node.name}")
self.addCleanup(conn.close)
without_inactive_flag(self)
compute = Compute.objects.create(name="fixed", hostname="localhost", login="", password="", type=4)
self.client.force_login(get_user_model().objects.create_superuser("fixed", "fixed@example.com", "pw"))
with patch("vrtManager.connection.connection_manager.get_connection", return_value=conn):
page = html.fromstring(self.client.get(reverse("network", args=[compute.id, "lab"])).content)
dialog = page.get_element_by_id("FixedAddress")
mac = re.compile(dialog.xpath(".//input[@name='mac']/@pattern")[0])
name = re.compile(dialog.xpath(".//input[@name='name']/@pattern")[0])
for value in ("52:54:00:ab:cd:ef", "52:54:00:AB:CD:EF", "0:1:0:1:2a:3b"):
with self.subTest(mac=value):
self.assertTrue(mac.fullmatch(value))
for template in ("modify_ipv4_fixed_address.html", "modify_ipv6_fixed_address.html"):
name = pattern(template, "name")
for value in ("web-01", "db.lan", "vm_1"):
with self.subTest(template=template, name=value):
self.assertTrue(name.fullmatch(value))
for value in ("a b", 'x"<'):
with self.subTest(template=template, refused=value):
self.assertFalse(name.fullmatch(value))
for value in ("web-01", "db.lan", "vm_1"):
with self.subTest(name=value):
self.assertTrue(name.fullmatch(value))
for value in ("a b", 'x"<'):
with self.subTest(refused=value):
self.assertFalse(name.fullmatch(value))

View file

@ -1,10 +1,10 @@
"""A network's addresses are read from its XML however libvirt writes it,
each <ip> by its own index; a change starts from the saved definition, so
two in a row keep each other; a fixed address is found by the address it
had and checked against its subnet; the XML editor defines only this
network; a network that does not start is not left defined. Runs on a
private libvirt test driver: "test:///default" is shared by the whole
process."""
"""A network's addresses are read from its XML however libvirt writes it, each
<ip> by its own index; a page reads each definition once; a change starts
from the saved definition, so two in a row keep each other; a fixed address
is found by the address it had and checked against its subnet; the XML
editor defines only this network; a network that does not start is not left
defined. Runs on a private libvirt test driver: "test:///default" is shared
by the whole process."""
import tempfile
from unittest.mock import MagicMock, patch
@ -61,12 +61,13 @@ class NetworkChangeTests(SimpleTestCase):
def saved(self):
return etree.fromstring(self.conn.networkLookupByName("lab").XMLDesc(libvirt.VIR_NETWORK_XML_INACTIVE))
def test_the_page_reads_the_xml_once(self):
def test_the_page_reads_each_definition_once(self):
# the running one for its addresses, the saved one for its limits and the XML editor
net = self.network()
with patch.object(net.net, "XMLDesc", wraps=net.net.XMLDesc) as xml:
net.get_ip_networks(), net.get_network_forward(), net.get_network_mac(), net.get_qos()
net.get_bridge_device()
self.assertEqual(xml.call_count, 1)
net.get_bridge_device(), net.get_mode(), net.saved_xml
self.assertEqual(xml.call_count, 2)
def test_the_ipv4_address_after_an_ipv6_one_keeps_its_index(self):
ipv6, ipv4 = self.network().get_ip_networks()
@ -151,7 +152,7 @@ class NetworkChangeTests(SimpleTestCase):
def test_the_xml_editor_takes_an_encoding_declaration(self):
self.conn.networkLookupByName("lab").destroy()
net = self.network()
net.edit_network('<?xml version="1.0" encoding="UTF-8"?>\n' + net.config_xml().replace("virbr9", "virbr6"))
net.edit_network('<?xml version="1.0" encoding="UTF-8"?>\n' + net.saved_xml.replace("virbr9", "virbr6"))
self.assertEqual(self.saved().xpath("bridge/@name"), ["virbr6"])
def test_a_fixed_address_is_of_the_subnet_and_its_own(self):
@ -165,7 +166,7 @@ class NetworkChangeTests(SimpleTestCase):
def test_the_xml_editor_defines_this_network_only(self):
self.conn.networkLookupByName("lab").destroy() # its definition read back at once
net = self.network()
xml = net.config_xml()
xml = net.saved_xml
for other, why in ((xml.replace("<name>lab</name>", "<name>other</name>"), "must define network lab"),
(xml.replace(net.net.UUIDString(), "11111111-2222-3333-4444-555555555555"), "keep the network's UUID"),
("<network>", "not valid")):

View file

@ -0,0 +1,104 @@
"""The network pages: a card per network with its mode, device and
addresses (none of its own for a bridge); a network's page with an address
card per <ip>, its fixed addresses in one table, the VMs on it, bandwidth
limits only where libvirt sets them; the new network dialog opens again
with what was entered and why. Runs on a private libvirt test driver."""
import tempfile
from unittest.mock import patch
import libvirt
from computes.models import Compute
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 networks.tests.driver import without_inactive_flag
NODE = """<node>
<network><name>lab</name><forward mode='nat'/><bridge name='virbr9'/>
<ip address='192.0.2.1' netmask='255.255.255.0'><dhcp><range start='192.0.2.10' end='192.0.2.20'/>
<host mac='52:54:00:00:00:31' name='web' ip='192.0.2.31'/></dhcp></ip>
<ip family='ipv6' address='2001:db8:1::1' prefix='64'><dhcp><host id='0:1:0:1' ip='2001:db8:1::31'/></dhcp></ip>
</network>
<network><name>lan</name><forward mode='bridge'/><bridge name='br0'/></network>
<network><name>direct</name><forward mode='bridge'><interface dev='eth0'/></forward></network>
<domain type='test'><name>web</name><memory>1024</memory><os><type>hvm</type></os>
<devices><interface type='network'><source network='lab'/></interface></devices></domain>
</node>"""
class NetworkPageTests(TestCase):
def setUp(self):
node = tempfile.NamedTemporaryFile("w", suffix=".xml")
self.addCleanup(node.close)
node.write(NODE)
node.flush()
self.conn = libvirt.open(f"test://{node.name}")
self.addCleanup(self.conn.close)
patcher = patch("vrtManager.connection.connection_manager.get_connection", return_value=self.conn)
patcher.start()
self.addCleanup(patcher.stop)
without_inactive_flag(self)
self.compute = Compute.objects.create(name="np", hostname="localhost", login="", password="", type=4)
self.client.force_login(get_user_model().objects.create_superuser("np", "np@example.com", "pw"))
def page(self, response):
self.assertEqual(response.status_code, 200)
return html.fromstring(response.content)
def text(self, element):
return " ".join(" ".join(element.itertext()).split())
def test_the_list_tells_each_network_mode_and_addresses(self):
page = self.page(self.client.get(reverse("networks", args=[self.compute.id])))
cards = {card.xpath("string(.//div[contains(@class,'card-header')]//a)"): self.text(card)
for card in page.xpath("//div[@class='card h-100']")}
self.assertIn("Type NAT Device virbr9 IPv4 192.0.2.0/24 DHCP 192.0.2.10 – 192.0.2.20 "
"IPv6 2001:db8:1::/64 DHCP: fixed addresses only", cards["lab"])
self.assertIn("Type Bridge Device br0 Addresses from the outside network", cards["lan"])
self.assertIn("Type Macvtap Device eth0 Addresses from the outside network", cards["direct"])
def test_a_network_page(self):
vm = Instance.objects.create(compute=self.compute, name="web", uuid=self.conn.lookupByName("web").UUIDString())
page = self.page(self.client.get(reverse("network", args=[self.compute.id, "lab"])))
# an address card per <ip>, the fixed addresses of both in one table
self.assertEqual(len(page.xpath("//button[@data-bs-target='#DhcpRange']")), 1)
rows = [self.text(row) for row in page.xpath("//tbody[@class='searchable']/tr")]
self.assertEqual([row.split(" Edit")[0] for row in rows], ["192.0.2.31 52:54:00:00:00:31 web", "2001:db8:1::31 0:1:0:1 –"])
self.assertEqual(page.xpath("//tbody/tr/td//input[@name='ip_index']/@value"), ["0", "1"])
# the VMs on it, linked
self.assertEqual(page.xpath(f"//a[@href='{reverse('instances:instance', args=[vm.id])}']/text()"), ["web"])
self.assertTrue(page.xpath("//div[@id='QosLimit']"))
def test_a_bridge_or_macvtap_network_has_no_addresses_of_libvirt(self):
for name, qos in (("lan", True), ("direct", False)): # libvirt limits a host bridge, not macvtap
with self.subTest(name=name):
page = self.page(self.client.get(reverse("network", args=[self.compute.id, name])))
self.assertIn("take their addresses from the outside network", self.text(page))
self.assertFalse(page.xpath("//div[@id='FixedAddress']"))
self.assertEqual(bool(page.xpath("//div[@id='QosLimit']")), qos)
def test_a_vm_undefined_meanwhile_is_left_out(self):
error = libvirt.libvirtError("Domain not found")
error.get_error_code = lambda: libvirt.VIR_ERR_NO_DOMAIN
with patch.object(libvirt.virDomain, "isActive", side_effect=error):
page = self.page(self.client.get(reverse("network", args=[self.compute.id, "lab"])))
self.assertIn("No VM is defined on this network.", self.text(page))
def test_an_action_does_not_read_the_page(self):
# every action redirects: the VMs on the network are not scanned for it
with patch("vrtManager.network.wvmNetwork.get_vms") as get_vms:
response = self.client.post(reverse("network", args=[self.compute.id, "lab"]), {"set_autostart": ""})
self.assertEqual(response.status_code, 302)
get_vms.assert_not_called()
def test_a_refused_network_opens_the_dialog_again(self):
page = self.page(self.client.post(reverse("networks", args=[self.compute.id]), {
"create": "", "name": "lab2", "forward": "route", "subnet": "192.0.2.0/25"}))
dialog = page.get_element_by_id("AddNetPool")
self.assertIsNotNone(dialog.get("data-open"))
self.assertEqual(page.xpath("//input[@name='forward'][@checked]/@value"), ["route"])
self.assertEqual(page.get_element_by_id("net_subnet").get("aria-invalid"), "true")
self.assertIn("Network lab has 192.0.2.0/24", page.get_element_by_id("net_subnet_error").text_content())

View file

@ -1,5 +1,6 @@
from admin.decorators import superuser_only
from computes.models import Compute
from instances.models import Instance
from django.contrib import messages
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
@ -13,6 +14,20 @@ from vrtManager.util import OperationError
from networks.forms import AddNetPool
# a network's mode (vrtManager.network.network_mode): its name, and what it does
MODES = {
"isolated": (_("Isolated"), _("VMs reach each other and the host only.")),
"nat": (_("NAT"), _("VMs reach out through the host's address; IPv6, when set, is routed.")),
"route": (_("Routed"), _("The host routes the subnet; the upstream router needs a route back to it.")),
"open": (_("Open"), _("The host routes the subnet and adds no firewall rules for it.")),
"bridge": (_("Bridge"), _("VMs join a bridge of the host and take their addresses from its network.")),
"macvtap": (_("Macvtap"), _("VMs share an interface of the host; the host cannot reach them through it.")),
"hostdev": (_("SR-IOV"), _("VMs get a virtual function of the host's network card.")),
}
# the modes libvirt gives no addresses of its own: the outside network does
EXTERNAL = ("bridge", "macvtap", "hostdev")
@superuser_only
def networks(request, compute_id):
"""
@ -22,6 +37,9 @@ def networks(request, compute_id):
"""
compute = get_object_or_404(Compute, pk=compute_id)
form = AddNetPool()
# the new network dialog's mode: the one sent back after a refusal
net_mode = request.POST.get("forward", "nat")
try:
conn = wvmNetworks(
@ -31,6 +49,12 @@ def networks(request, compute_id):
compute.type,
)
networks = conn.get_networks_info()
for net in networks:
net["mode_label"] = MODES.get(net["mode"], (net["mode"], ""))[0]
net["external"] = net["mode"] in EXTERNAL
# suggestions for a bridge or macvtap network: not the bridges libvirt made for its own networks
taken = {net["device"] for net in networks if not net["external"]}
host_ifaces = [iface for iface in conn.get_host_interfaces() if iface["name"] not in taken]
if request.method == "POST" and "create" in request.POST:
form = AddNetPool(request.POST, networks=networks)
@ -41,18 +65,20 @@ def networks(request, compute_id):
gateway4, netmask4, dhcp4 = network_size(data["subnet"], data["dhcp4"])
if data["subnet6"]:
gateway6, prefix6, dhcp6 = network_size(data["subnet6"], data["dhcp6"])
with audit(request, compute, f"Network {data['name']}", "add"):
conn.create_network(
data["name"], data["forward"],
bool(data["subnet"]), gateway4, netmask4, dhcp4,
bool(data["subnet6"]), gateway6, prefix6, dhcp6,
data["bridge_name"], data["openvswitch"],
)
return HttpResponseRedirect(reverse("network", args=[compute_id, data["name"]]))
if form.has_error("name", "taken"):
try:
with audit(request, compute, f"Network {data['name']}", "add"):
conn.create_network(
data["name"], data["forward"],
bool(data["subnet"]), gateway4, netmask4, dhcp4,
bool(data["subnet6"]), gateway6, prefix6, dhcp6,
data["bridge_name"], data["openvswitch"],
)
except libvirtError as err: # shown in the dialog, which opens again
form.add_error(None, str(err))
else:
return HttpResponseRedirect(reverse("network", args=[compute_id, data["name"]]))
elif form.has_error("name", "taken"):
failed(request, compute, f"Network {form.data['name'].strip()}", "add", "name already in use")
for msg_err in form.errors.values():
messages.error(request, msg_err.as_text())
conn.close()
except libvirtError as lib_err:
messages.error(request, lib_err)
@ -79,18 +105,6 @@ def network(request, compute_id, pool):
compute.type,
pool,
)
networks = conn.get_networks()
state = conn.is_active()
device = conn.get_bridge_device()
autostart = conn.get_autostart()
net_mac = conn.get_network_mac()
net_forward = conn.get_network_forward()
qos = conn.get_qos()
ip_networks = conn.get_ip_networks()
# the page shows the first address of each family
ipv4 = next((ip for ip in ip_networks if ip["family"] == "ipv4"), None)
ipv6 = next((ip for ip in ip_networks if ip["family"] == "ipv6"), None)
xml = conn.config_xml()
except libvirtError as lib_err:
messages.error(request, lib_err)
return HttpResponseRedirect(reverse("networks", args=[compute_id]))
@ -239,6 +253,32 @@ def network(request, compute_id, pool):
_("%(qos_dir)s QoS is deleted") % {"qos_dir": qos_dir.capitalize()},
)
return HttpResponseRedirect(here)
# read only for the page: every action above redirects
try:
state = conn.is_active()
device = conn.get_bridge_device()
autostart = conn.get_autostart()
net_mac = conn.get_network_mac()
qos = conn.get_qos()
ip_networks = conn.get_ip_networks()
mode = conn.get_mode()
mode_label, mode_help = MODES.get(mode, (mode, ""))
external = mode in EXTERNAL
# libvirt limits the bandwidth of routed, isolated and host bridge networks, not of macvtap or SR-IOV ones
qos_supported = mode in ("isolated", "nat", "route", "open", "bridge")
families = {ip["family"] for ip in ip_networks}
# every fixed address, with the address of the network it belongs to
fixed_addresses = [{**host, "ip_index": ip["index"], "subnet": ip["subnet"]}
for ip in ip_networks for host in ip["hosts"]]
dhcp_networks = [ip for ip in ip_networks if ip["dhcp"]]
vms = conn.get_vms()
vm_ids = dict(Instance.objects.filter(compute=compute, name__in=[vm["name"] for vm in vms]).values_list("name", "id"))
for vm in vms:
vm["id"] = vm_ids.get(vm["name"])
xml = conn.saved_xml
except libvirtError as lib_err:
messages.error(request, lib_err)
return HttpResponseRedirect(reverse("networks", args=[compute_id]))
conn.close()
return render(request, "network.html", locals())

View file

@ -0,0 +1,38 @@
// A create dialog of one form whose radio cards choose what is made (the
// New pool and New network dialogs): a fieldset shows for the choices in
// its data-kinds, the others are hidden and disabled, so neither checked
// nor sent; onChoose(value) does what else a choice changes. A form the
// server refused (data-open on the dialog) opens again with what was
// entered: the choice sent back, or the first when it is offered no more,
// and focus on the first refused field shown, or on the reasons.
function choiceDialog(dialog, radioName, onChoose) {
var form = dialog.querySelector("form");
var radios = form.querySelectorAll("input[name=" + radioName + "]");
function choose(value) {
form.querySelectorAll("fieldset[data-kinds]").forEach(function (fields) {
fields.hidden = fields.disabled = !fields.dataset.kinds.split(" ").includes(value);
});
onChoose(value);
}
radios.forEach(function (radio) {
radio.addEventListener("change", function () { choose(radio.value); });
});
var chosen = form.querySelector("input[name=" + radioName + "]:checked") || radios[0];
chosen.checked = true;
choose(chosen.value);
if (dialog.hasAttribute("data-open")) {
dialog.addEventListener("shown.bs.modal", function () {
var refused = Array.from(form.querySelectorAll("[aria-invalid=true]")).find(function (field) {
return !field.closest("fieldset[hidden]");
});
var reasons = form.querySelector(".form-errors");
if (refused) {
refused.focus();
} else if (reasons) {
reasons.tabIndex = -1;
reasons.focus();
}
}, {once: true});
bootstrap.Modal.getOrCreateInstance(dialog).show();
}
}

View file

@ -1,4 +1,5 @@
{% load i18n %}
{% load static %}
{% load bootstrap_icons %}
{% load form_fields %}
{% if request.user.is_superuser %}
@ -26,13 +27,13 @@
<legend class="form-label fs-6">{% trans "Storage type" %}</legend>
<div class="row row-cols-1 row-cols-sm-2 g-2">
{% trans "Directory" as title %}{% trans "Image files in a directory of the host." as help %}
{% include "storages/pool_kind.html" with kind="dir" %}
{% include "radio_card.html" with name="pool_kind" value="dir" chosen=pool_kind %}
{% trans "LVM" as title %}{% trans "Logical volumes of a volume group made on a device." as help %}
{% include "storages/pool_kind.html" with kind="logical" %}
{% include "radio_card.html" with name="pool_kind" value="logical" chosen=pool_kind %}
{% trans "Ceph RBD" as title %}{% trans "Images of a pool of an existing Ceph cluster." as help %}
{% include "storages/pool_kind.html" with kind="rbd" %}
{% include "radio_card.html" with name="pool_kind" value="rbd" chosen=pool_kind %}
{% trans "Network filesystem" as title %}{% trans "Image files on a server's share, mounted on the host." as help %}
{% include "storages/pool_kind.html" with kind="netfs" %}
{% include "radio_card.html" with name="pool_kind" value="netfs" chosen=pool_kind %}
{% if iso_pool %}
{# the upload button is on the page of the pool named iso only #}
<div class="col">
@ -47,7 +48,7 @@
</div>
{% else %}
{% trans "ISO images" as title %}{% trans "The directory pool named iso, for uploaded ISO images." as help %}
{% include "storages/pool_kind.html" with kind="iso" %}
{% include "radio_card.html" with name="pool_kind" value="iso" chosen=pool_kind %}
{% endif %}
</div>
</fieldset>
@ -60,7 +61,7 @@
{% field_error form.name "pool_name" %}
</div>
<fieldset data-kind="dir">
<fieldset data-kinds="dir">
<legend class="visually-hidden">{% trans "Directory" %}</legend>
<div class="mb-3">
<label for="pool_dir_target" class="form-label">{% trans "Directory" %}</label>
@ -71,7 +72,7 @@
</div>
</fieldset>
<fieldset data-kind="logical">
<fieldset data-kinds="logical">
<legend class="visually-hidden">{% trans "LVM" %}</legend>
<div class="mb-3">
<label for="pool_lvm_source" class="form-label">{% trans "Device" %}</label>
@ -89,7 +90,7 @@
</div>
</fieldset>
<fieldset data-kind="rbd">
<fieldset data-kinds="rbd">
<legend class="visually-hidden">{% trans "Ceph RBD" %}</legend>
{% if ceph_secrets %}
<div class="row g-3 mb-3">
@ -131,7 +132,7 @@
{% endif %}
</fieldset>
<fieldset data-kind="netfs">
<fieldset data-kinds="netfs">
<legend class="visually-hidden">{% trans "Network filesystem" %}</legend>
<div class="row g-3 mb-3">
<div class="col-sm-6">
@ -165,7 +166,7 @@
</fieldset>
{% if not iso_pool %}
<fieldset data-kind="iso">
<fieldset data-kinds="iso">
<legend class="visually-hidden">{% trans "ISO images" %}</legend>
<div class="mb-3">
<label for="pool_iso_target" class="form-label">{% trans "Directory" %}</label>
@ -185,64 +186,37 @@
</form>
</div>
</div>
<script src="{% static 'js/choice_dialog.js' %}"></script>
<script>
// the chosen type's fields show; the ISO pool is a directory named iso;
// a path follows the pool's name until it is edited (after the page's
// scripts, Bootstrap's among them, have loaded)
// the ISO pool is a directory named iso; a path follows the pool's name
// until it is edited (after the page's scripts, Bootstrap's among them)
document.addEventListener('DOMContentLoaded', function () {
var form = document.getElementById('pool-form');
var name = form.elements.namedItem('name');
var otherName = name.value;
var hasCephSecrets = {{ ceph_secrets|yesno:"true,false" }};
function choose(kind) {
var iso = kind === 'iso';
if (!name.readOnly) otherName = name.value;
name.value = iso ? 'iso' : otherName;
name.readOnly = iso;
form.elements.namedItem('stg_type').value = iso ? 'dir' : kind;
form.querySelectorAll('fieldset[data-kind]').forEach(function (fields) {
fields.hidden = fields.disabled = fields.dataset.kind !== kind;
});
form.querySelector('button[name=create]').disabled = kind === 'rbd' && !hasCephSecrets;
}
function follow() {
form.querySelectorAll('[data-follow-name]').forEach(function (path) {
path.dataset.derived = name.value ? path.dataset.followName + name.value : '';
});
name.addEventListener('input', function () {
form.querySelectorAll('[data-follow-name]').forEach(function (path) {
var derived = name.value ? path.dataset.followName + name.value : '';
if (!path.value || path.value === path.dataset.derived) path.value = derived;
path.dataset.derived = derived;
});
}
form.querySelectorAll('input[name=pool_kind]').forEach(function (radio) {
radio.addEventListener('change', function () { choose(radio.value); });
});
name.addEventListener('input', follow);
// a new device or name: the erasing is agreed to again
form.querySelectorAll('#pool_lvm_source, #pool_name').forEach(function (field) {
field.addEventListener('input', function () { document.getElementById('pool_lvm_erase').checked = false; });
});
form.querySelectorAll('[data-follow-name]').forEach(function (path) {
path.dataset.derived = name.value ? path.dataset.followName + name.value : '';
choiceDialog(document.getElementById('AddStgPool'), 'pool_kind', function (kind) {
var iso = kind === 'iso';
if (!name.readOnly) otherName = name.value;
name.value = iso ? 'iso' : otherName;
name.readOnly = iso;
form.elements.namedItem('stg_type').value = iso ? 'dir' : kind;
form.querySelector('button[name=create]').disabled = kind === 'rbd' && !hasCephSecrets;
});
// the type sent back, or the first when it is offered no more (an iso pool made meanwhile)
var chosen = form.querySelector('input[name=pool_kind]:checked') || form.querySelector('input[name=pool_kind]');
chosen.checked = true;
choose(chosen.value);
// a refused pool: the dialog opens again with what was entered and why,
// the first refused field focused, or the reasons when none is shown
var dialog = document.getElementById('AddStgPool');
if (dialog.hasAttribute('data-open')) {
dialog.addEventListener('shown.bs.modal', function () {
var refused = form.querySelector('fieldset:not([hidden]) [aria-invalid=true], #pool_name[aria-invalid=true]');
var reasons = form.querySelector('.form-errors');
if (refused) {
refused.focus();
} else if (reasons) {
reasons.tabIndex = -1;
reasons.focus();
}
}, {once: true});
bootstrap.Modal.getOrCreateInstance(dialog).show();
}
});
</script>
{% endif %}

View file

@ -1,7 +1,7 @@
{# a new pool's type as a radio card: kind, title, help; pool_kind is the chosen one #}
{# a choice as a card around its radio button (.source-option): name, value, title, help; chosen, the value that is checked #}
<div class="col">
<label class="source-option border rounded p-2 h-100 d-flex gap-2">
<input class="form-check-input flex-shrink-0 mt-1" type="radio" name="pool_kind" value="{{ kind }}"{% if kind == pool_kind %} checked{% endif %}>
<input class="form-check-input flex-shrink-0 mt-1" type="radio" name="{{ name }}" value="{{ value }}"{% if value == chosen %} checked{% endif %}>
<span><span class="d-block fw-semibold">{{ title }}</span><span class="d-block small text-body-secondary">{{ help }}</span></span>
</label>
</div>

View file

@ -3,6 +3,8 @@ import ipaddress
from functools import cached_property
from libvirt import (
VIR_DOMAIN_XML_INACTIVE,
VIR_ERR_NO_DOMAIN,
VIR_NETWORK_SECTION_IP_DHCP_HOST,
VIR_NETWORK_UPDATE_AFFECT_CONFIG,
VIR_NETWORK_UPDATE_AFFECT_LIVE,
@ -68,6 +70,17 @@ def ip_networks(tree):
return ips
def network_mode(tree):
"""What kind of network the XML defines: isolated, nat, route, open,
bridge (a bridge of the host), macvtap (a host interface, its bridge,
private, VEPA or passthrough mode) or hostdev (SR-IOV functions)."""
forward = tree.find("forward")
mode = forward.get("mode", "nat") if forward is not None else "none"
if mode in ("private", "vepa", "passthrough") or (mode == "bridge" and tree.find("bridge") is None):
return "macvtap"
return "isolated" if mode == "none" else mode
class wvmNetworks(wvmConnect):
def get_networks_info(self):
"""Each network's state, autostart, device (its bridge, or a macvtap
@ -84,10 +97,26 @@ class wvmNetworks(wvmConnect):
"autostart": net.autostart(),
"device": tree.xpath("string(bridge/@name)") or tree.xpath("string(forward/interface/@dev)"),
"forward": forward.get("mode") if forward is not None else None,
"mode": network_mode(tree),
"ips": ip_networks(tree),
})
return networks
def get_host_interfaces(self):
"""The host's interfaces a bridge or macvtap network can use: name and
type (bridge, ethernet, bond, vlan...), not the loopback; empty when
the host's libvirt does not list interfaces. One listing and one XML
read per interface."""
try:
ifaces = self.wvm.listAllInterfaces(0)
except libvirtError:
return []
found = []
for iface in ifaces:
if iface.name() != "lo":
found.append({"name": iface.name(), "type": etree.fromstring(iface.XMLDesc(0)).get("type")})
return sorted(found, key=lambda iface: iface["name"])
def create_network(
self,
name,
@ -159,9 +188,10 @@ class wvmNetworks(wvmConnect):
class wvmNetwork(wvmConnect):
"""A network of the host. A page reads its running definition once (tree);
a change starts from its saved definition read anew (config), so that two
changes in a row do not undo each other, and forgets what the page read."""
"""A network of the host. A page reads its running definition (tree) and
its saved one (saved) once each; a change starts from the saved
definition read anew (config), so that two changes in a row do not undo
each other, and forgets what the page read."""
def __init__(self, host, login, passwd, conn, net):
wvmConnect.__init__(self, host, login, passwd, conn)
@ -171,16 +201,21 @@ class wvmNetwork(wvmConnect):
def tree(self):
return etree.fromstring(self.net.XMLDesc(0))
def config(self):
return etree.fromstring(self.net.XMLDesc(VIR_NETWORK_XML_INACTIVE))
def config_xml(self):
@cached_property
def saved_xml(self):
"""The saved definition, the one the XML editor changes."""
return self.net.XMLDesc(VIR_NETWORK_XML_INACTIVE)
def config(self):
return etree.fromstring(self.net.XMLDesc(VIR_NETWORK_XML_INACTIVE))
def _forget(self):
for read in ("tree", "saved_xml"):
self.__dict__.pop(read, None)
def _define(self, tree):
self.wvm.networkDefineXML(etree.tostring(tree).decode())
self.__dict__.pop("tree", None)
self._forget()
def get_name(self):
return self.net.name()
@ -201,6 +236,29 @@ class wvmNetwork(wvmConnect):
def start(self):
self.net.create()
def get_mode(self):
return network_mode(self.tree)
def get_vms(self):
"""The VMs whose definition names this network, by name, and whether
each is running on it now: one listing, and one XML read per stopped
VM, two per running one (its running and its saved definition)."""
name = self.net.name()
def on_it(xml):
return etree.fromstring(xml).xpath("devices/interface[@type='network']/source[@network=$name]", name=name)
vms = []
for dom in self.wvm.listAllDomains():
try:
connected = dom.isActive() and bool(on_it(dom.XMLDesc(0)))
if connected or on_it(dom.XMLDesc(VIR_DOMAIN_XML_INACTIVE)):
vms.append({"name": dom.name(), "connected": connected})
except libvirtError as err: # a VM undefined meanwhile is not on it
if err.get_error_code() != VIR_ERR_NO_DOMAIN:
raise
return sorted(vms, key=lambda vm: vm["name"])
def _vms_on_it(self, running_only):
name = self.net.name()
return self.domains_using(
@ -232,7 +290,7 @@ class wvmNetwork(wvmConnect):
"stop and start it first")
flags = VIR_NETWORK_UPDATE_AFFECT_CONFIG | (VIR_NETWORK_UPDATE_AFFECT_LIVE if active else 0)
self.net.update(command, section, parentIndex, xml, flags)
self.__dict__.pop("tree", None)
self._forget()
@staticmethod
def _addresses(tree):
@ -347,9 +405,11 @@ class wvmNetwork(wvmConnect):
return True
def get_qos(self):
"""The saved bandwidth limits: a running network takes them when it is started again."""
qos_values = {}
saved = etree.fromstring(self.saved_xml)
for direction in ("inbound", "outbound"):
limit = self.tree.find(f"bandwidth/{direction}")
limit = saved.find(f"bandwidth/{direction}")
if limit is not None:
qos_values[direction] = {"average": limit.get("average"), "peak": limit.get("peak"),
"burst": limit.get("burst")}
@ -393,4 +453,4 @@ class wvmNetwork(wvmConnect):
if tree.findtext("uuid") not in (None, self.net.UUIDString()):
raise util.OperationError("The XML must keep the network's UUID")
self.wvm.networkDefineXML(new_xml)
self.__dict__.pop("tree", None)
self._forget()

View file

@ -1,7 +1,7 @@
"""Status colours keep their contrast on every theme: a pool, network or
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: 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)."""
from unittest.mock import PropertyMock, patch
@ -9,7 +9,6 @@ import libvirt
from computes.models import Compute
from django.contrib.auth import get_user_model
from django.test import TestCase
from networks.tests.driver import without_inactive_flag
from django.urls import reverse
from lxml import html
@ -18,7 +17,6 @@ class StatusContrastTests(TestCase):
def setUp(self):
self.conn = libvirt.open("test:///default")
self.addCleanup(self.conn.close)
without_inactive_flag(self)
self.compute = Compute.objects.create(name="contrast", hostname="localhost", login="", password="", type=4)
self.client.force_login(get_user_model().objects.create_superuser("contrast", "c@example.com", "pw"))
@ -29,7 +27,7 @@ class StatusContrastTests(TestCase):
return html.fromstring(response.content)
def test_card_headers_are_pale_with_dark_text(self):
for name in ("networks", "interfaces"):
for name in ("interfaces",):
with self.subTest(page=name):
headers = self.page(name).xpath("//div[contains(@class, 'card-header')]")
self.assertTrue(headers)
@ -40,11 +38,10 @@ class StatusContrastTests(TestCase):
self.assertTrue({"text-success-emphasis", "text-danger-emphasis"} & set(link), link)
def test_status_words_are_pale_badges_with_dark_text(self):
status = self.page("network", "default").xpath("//span[normalize-space()='ON' or normalize-space()='OFF']")
# the test driver has no node memory statistics for the host row
with patch.object(Compute, "ram_usage", new_callable=PropertyMock, return_value=1):
status += self.page("instances:index", compute=False).xpath("//span[normalize-space()='Connected']")
self.assertEqual(len(status), 3)
status = self.page("instances:index", compute=False).xpath("//span[normalize-space()='Connected']")
self.assertEqual(len(status), 1)
for span in status:
self.assertIn(span.get("class"), ("badge bg-success-subtle text-success-emphasis",
"badge bg-danger-subtle text-danger-emphasis"))