1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-11 12:46:49 +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 i18n %}
{% load static %}
{% load bootstrap_icons %} {% load bootstrap_icons %}
{% load form_fields %}
{% if request.user.is_superuser %} {% 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"> <button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#AddNetPool">
<span aria-hidden="true">{% bs_icon 'plus' %} </span> <span aria-hidden="true">{% bs_icon 'plus-lg' %}</span> {% trans "New network" %}
</a> </button>
<!-- Modal pool --> {# 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" role="dialog" aria-labelledby="AddNetPoolLabel" aria-hidden="true"> <div class="modal fade" id="AddNetPool" tabindex="-1" aria-labelledby="AddNetPoolLabel" aria-hidden="true"
<div class="modal-dialog"> {% if form.errors %}data-open{% endif %}>
<div class="modal-content"> <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"> <div class="modal-header">
<h5 class="modal-title">{% trans "Add New Network" %}</h5> <div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <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> </div>
<form method="post" action="" role="form" aria-label="Add New Network form" novalidate>{% csrf_token %} <div class="modal-body">
<div class="modal-body"> {% include "instances/form_errors.html" %}
<div class="row">
<label for="net_name" class="col-sm-4 col-form-label">{% trans "Name" %}</label> <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"> <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> </div>
<div class="row"> <div class="form-check mb-3">
<label for="forward_select" class="col-sm-4 col-form-label">{% trans "Type forwarding" %}</label> <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"> <div class="col-sm-6">
<select id="forward_select" class="form-select" name="forward"> <label for="net_subnet6" class="form-label">{% trans "IPv6 subnet" %}</label>
<option value="nat">{% trans "NAT" %}</option> <input id="net_subnet6" type="text" class="form-control font-monospace{{ form.subnet6|invalid }}" name="subnet6" value="{{ form.subnet6.value|default:"" }}"
<option value="route">{% trans "ROUTE" %}</option> placeholder="fd00:dead:baba:1::/64" {% error_attrs form.subnet6 "net_subnet6" "net_subnet6_help" %}>
<option value="none">{% trans "ISOLATE" %}</option> <div class="form-text" id="net_subnet6_help">{% trans "A /64." %}</div>
<option value="bridge">{% trans "BRIDGE" %}</option> {% field_error form.subnet6 "net_subnet6" %}
<option value="macvtap">{% trans "MACVTAP" %}</option> </div>
</select> <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> </div>
<div class="row bridge_name_form_group_dhcp"> </fieldset>
<label for="net_subnet" class="col-sm-4 col-form-label">{% trans "IPv4 Subnet pool" %}</label>
<div class="col-sm-6"> <fieldset data-kinds="bridge">
<input id="net_subnet" type="text" class="form-control" name="subnet" value="" placeholder="192.168.100.0/24" required pattern="[0-9\/\.]+"> <legend class="visually-hidden">{% trans "Bridge" %}</legend>
</div> <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>
<div class="row bridge_name_form_group_dhcp"> <div class="form-check mb-3">
<label for="net_dhcp4" class="col-sm-4 col-form-label">{% trans "DHCPv4" %}</label> <input class="form-check-input" type="checkbox" id="net_ovs" name="openvswitch" value="true"{% if form.openvswitch.value %} checked{% endif %}>
<div class="col-sm-6"> <label class="form-check-label" for="net_ovs">{% trans "It is an Open vSwitch bridge" %}</label>
<input id="net_dhcp4" type="checkbox" name="dhcp4" value="true" checked>
</div>
</div> </div>
<div class="row bridge_name_form_group_dhcp"> </fieldset>
<label for="enable_ipv6" class="col-sm-4 col-form-label">{% trans "Enable IPv6" %}</label>
<div class="col-sm-6"> <fieldset data-kinds="macvtap">
<input type="checkbox" id="enable_ipv6" name="enable_ipv6" value="true"> <legend class="visually-hidden">{% trans "Macvtap" %}</legend>
</div> <div class="mb-3">
</div> <label for="net_iface" class="form-label">{% trans "Host interface" %}</label>
<div class="row ipv6_group"> <input id="net_iface" type="text" class="form-control font-monospace{% if net_mode == "macvtap" %}{{ form.bridge_name|invalid }}{% endif %}" name="bridge_name"
<label for="net_subnet6" class="col-sm-4 col-form-label">{% trans "IPv6 Subnet pool" %}</label> value="{% if net_mode == "macvtap" %}{{ form.bridge_name.value|default:"" }}{% endif %}" list="host-ifaces" placeholder="eth0" maxlength="20" required
<div class="col-sm-6"> {% if net_mode == "macvtap" %}{% error_attrs form.bridge_name "net_iface" "net_iface_help" %}{% else %}aria-describedby="net_iface_help"{% endif %}>
<input id="net_subnet6" type="text" class="form-control" name="subnet6" value="" placeholder="fd00:dead:baba:1::/64" required pattern="[0-9a-fA-F:\/]+"> <datalist id="host-ifaces">
</div> {% for iface in host_ifaces %}{% if iface.type != "bridge" %}<option value="{{ iface.name }}">{% endif %}{% endfor %}
</div> </datalist>
<div class="row ipv6_group"> <div class="form-text" id="net_iface_help">{% trans "The interface the VMs share, such as the host's uplink." %}</div>
<label for="net_dhcp6" class="col-sm-4 col-form-label">{% trans "DHCPv6" %}</label> {% if net_mode == "macvtap" %}{% field_error form.bridge_name "net_iface" %}{% endif %}
<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>
</div> </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>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Close" %}</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" %}</button> <button type="submit" class="btn btn-primary" name="create">{% trans "Create network" %}</button>
</div> </div>
</form> </form>
</div> <!-- /.modal-content --> </div>
</div> <!-- /.modal-dialog --> </div>
</div> <!-- /.modal --> <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 %} {% 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 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 %} {% block content %}
<div class="row"> {% include "computes/host_nav.html" with section="networks" detail=True %}
<div class="col-lg-12">
{% 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>
<div class="row"> {% if external %}
<div class="col-12"> <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>
<dl class="ms-3 row"> {% else %}
<dt class="col-6">{% trans "Network name" %}</dt> <div class="row row-cols-1 row-cols-md-2 g-3 mb-3">
<dd class="col-6">{{ pool }}</dd> {% for ip in ip_networks %}
<dt class="col-6">{% trans "Device" %}</dt> <div class="col">
<dd class="col-6">{{ device }}</dd> <div class="card h-100">
<dt class="col-6">{% trans "State" %}</dt> <div class="card-header d-flex flex-wrap align-items-center gap-2">
<dd class="col-6"> <span class="me-auto">{% if ip.family == "ipv6" %}IPv6{% else %}IPv4{% endif %}</span>
<form action="" method="post" role="form" aria-label="Network start/stop/delete form">{% csrf_token %} {% if ip.range %}
{% if state == 0 %} {# a running network keeps its range: libvirt changes it while it is stopped #}
<input type="submit" class="btn btn-sm btn-success" name="start" value="{% trans "Start" %}"> <span {% if state %}title="{% trans "Stop the network to change its range" %}"{% endif %}>
<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)"> <button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#DhcpRange"
{% else %} data-ip-index="{{ ip.index }}" data-start="{{ ip.range.0 }}" data-end="{{ ip.range.1 }}" data-subnet="{{ ip.subnet }}"
<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 state %}disabled{% endif %}>{% bs_icon 'pencil' %} {% trans "DHCP range" %}</button>
</span>
{% endif %} {% 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> </div>
<button type="submit" class="btn btn-primary float-end" name="edit_network"> <ul class="list-group list-group-flush flex-grow-1">
{% trans "Edit" %} <li class="list-group-item d-flex justify-content-between align-items-baseline gap-3">
</button> <span class="text-body-secondary">{% trans "Subnet" %}</span><span class="font-monospace text-break text-end">{{ ip.subnet|default:"–" }}</span>
</form> </li>
</div> <li class="list-group-item d-flex justify-content-between align-items-baseline gap-3">
</div> <span class="text-body-secondary">{% trans "Host's address" %}</span><span class="font-monospace text-break text-end">{{ ip.address }}</span>
</div> </li>
<li class="list-group-item d-flex justify-content-between align-items-baseline gap-3">
<h5 class="mb-3">{% trans "IPv4 Configuration" %}</h5> <span class="text-body-secondary">{% trans "DHCP" %}</span>
<span class="text-end">
<dl class="ms-3 row"> {% if ip.range %}<span class="font-monospace text-break">{{ ip.range.0 }} – {{ ip.range.1 }}</span>
<dt class="col-6">{% trans "IPv4 Forwarding" %}</dt> {% elif ip.dhcp %}{% trans "Fixed addresses only" %}
<dd class="col-6"> {% else %}<span class="badge text-bg-secondary">{% trans "Off" %}</span>{% endif %}
{% if net_forward.0 == 'nat' %} </span>
{% trans "NAT" %} </li>
{% endif %} </ul>
{% 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>
</div> </div>
</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> </div>
{% endif %}
{% if net_forward.0 == 'route' or net_forward.0 == 'nat' or not net_forward.0 %}{# an isolated network has no forward #} <div class="card mb-3">
{% if state == 0 and qos.items|length != 2%} <div class="card-header d-flex flex-wrap align-items-center gap-2">
<form method="post" name="set_qos" role="form" aria-label="Create network qos form">{% csrf_token %} <span class="me-auto">{% trans "Fixed addresses" %}</span>
{% include 'add_network_qos.html' %} {% if fixed_addresses %}
</form> <input id="filter" class="form-control form-control-sm w-auto" type="search" placeholder="{% trans "Search" %}" aria-label="{% trans "Search fixed addresses" %}">
{% endif %} {% endif %}
<h5 class="mb-3">{% trans "QoS Configuration" %}</h5> {% if dhcp_networks %}
<button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#FixedAddress">
<div class="col-sm-12"> {% bs_icon 'plus-lg' %} {% trans "Add" %}
</button>
{% endif %}
</div>
{% if fixed_addresses %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle"> <table class="table table-hover align-middle mb-0" data-sortable>
<thead> <thead>
<tr> <tr>
<th scope="col">{% trans "Direction" %}</th> <th scope="col">{% trans "Address" %}</th>
<th scope="col">{% trans "Average" %}</th> <th scope="col">{% trans "MAC / DUID" %}</th>
<th scope="col">{% trans "Peak" %}</th> <th scope="col">{% trans "Name" %}</th>
<th scope="col">{% trans "Burst" %}</th> <th scope="col" data-sort-method="none"><span class="visually-hidden">{% trans "Actions" %}</span></th>
<th scope="col">{% trans "Actions" %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody class="searchable">
{% for q, att in qos.items %} {% for host in fixed_addresses %}
<form method="post" role="form" aria-label="QoS configuration form">{% csrf_token %} <tr data-search="{{ host.ip }} {{ host.mac|default:"" }}{{ host.id|default:"" }} {{ host.name }}">
<tr> <td class="font-monospace text-nowrap">{{ host.ip }}</td>
<td><label class="col-form-label">{{ q | capfirst }}</label></td> <td class="font-monospace text-nowrap">{{ host.mac|default:host.id|default:"–" }}</td>
<td><input id="qos_average" class="form-control" name="qos_average" <td class="text-break">{{ host.name|default:"–" }}</td>
value="{{ att.average|default:'' }}"/> <td class="text-end text-nowrap">
</td> <div class="btn-group" role="group" aria-label="{% blocktrans with address=host.ip %}Actions for {{ address }}{% endblocktrans %}">
<td><input id="qos_peak" class="form-control" name="qos_peak" <button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#FixedAddress"
value="{{ att.peak|default:'' }}"/> data-ip-index="{{ host.ip_index }}" data-original="{{ host.ip }}" data-address="{{ host.ip }}"
</td> 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>
<td><input id="qos_burst" class="form-control" name="qos_burst" <div class="btn-group">
value="{{ att.burst|default:'' }}"/> <button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="dropdown" aria-expanded="false"
</td> data-bs-popper-config='{"strategy": "fixed"}' aria-label="{% trans "More actions" %}">{% bs_icon 'three-dots' %}</button>
<td class="text-nowrap"> <ul class="dropdown-menu dropdown-menu-end shadow">
<input name="qos_direction" value="{{ q }}" hidden/> <li><form method="post">{% csrf_token %}
<button type="submit" class="btn btn-sm btn-primary" <input type="hidden" name="ip_index" value="{{ host.ip_index }}">
name="set_qos" <input type="hidden" name="original" value="{{ host.ip }}">
title="{% trans "Edit QoS" %}"> <button type="submit" class="dropdown-item text-danger" name="delete_fixed_address"
{% bs_icon 'save' %} data-confirm="{% blocktrans with address=host.ip %}Delete the fixed address {{ address }}? The VM may get another address when its lease ends.{% endblocktrans %}"
</button> onclick="return confirm(this.dataset.confirm)">{% bs_icon 'trash' %} {% trans "Delete" %}</button>
<button type="submit" class="btn btn-sm btn-danger" </form></li>
name="unset_qos" </ul>
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)"> </div>
{% bs_icon 'trash' %} </div>
</button>
</td> </td>
</tr> </tr>
</form>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </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> </div>
{% endif %} {% 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 %} {% endblock %}
{% block script %} {% block script %}
<script> {% if fixed_addresses %}<script src="{% static 'js/table_filter.js' %}"></script>{% endif %}
$.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>
<script src="{% static "js/ace/ace.js" %}"></script> <script src="{% static "js/ace/ace.js" %}"></script>
<script> <script>
var editor = ace.edit("edit_editor"); var editor = ace.edit("edit_editor");
editor.getSession().setMode("ace/mode/xml"); editor.getSession().setMode("ace/mode/xml");
var editInput = $('input[name="edit_xml"]');
var edit_input = $('input[name="edit_xml"]');
editor.getSession().on("change", function () { 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> </script>
{% endblock %} {% endblock %}

View file

@ -9,60 +9,65 @@
{% block page_heading_extra %}{% include 'create_net_block.html' %}{% endblock page_heading_extra %} {% block page_heading_extra %}{% include 'create_net_block.html' %}{% endblock page_heading_extra %}
{% block content %} {% block content %}
<div class="row"> {% include "computes/host_nav.html" with section="networks" %}
<div class="col-lg-12">
{% include "computes/host_nav.html" with section="networks" %}
</div>
</div>
<div class="row"> {% if not networks %}
{% if not networks %} <div class="alert alert-warning shadow-sm">
<div class="col-lg-12"> {% bs_icon 'exclamation-triangle' %} {% trans "This host has no networks." %}
<div class="alert alert-warning shadow-sm"> </div>
{% bs_icon 'exclamation-triangle' %} <strong>{% trans "Warning" %} :</strong> {% trans "Hypervisor doesn't have any Network" %} {% 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>
</div> </div>
{% else %} {% endfor %}
{% for pool in networks %} </div>
<div class="col-6 col-md-4"> {% endif %}
<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>
{% endblock %} {% 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"}) "dhcp4": "on"})
self.client.post(url, {"create": "", "name": "bad name!", "forward": "nat"}) # invalid form 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"}) 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)]) 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 """The network page's fixed-address dialog takes real MAC addresses and host
browser refuses what their pattern attributes do not match.""" names: the browser refuses what its pattern attributes do not match. Runs
on a private libvirt test driver."""
import re import re
from types import SimpleNamespace import tempfile
from unittest.mock import patch
from django.template.loader import render_to_string import libvirt
from django.test import SimpleTestCase 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 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): class FixedAddressFormTests(TestCase):
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):
def test_macs_and_host_names_are_accepted(self): def test_macs_and_host_names_are_accepted(self):
mac = pattern("modify_ipv4_fixed_address.html", "mac") node = tempfile.NamedTemporaryFile("w", suffix=".xml")
for value in ("52:54:00:ab:cd:ef", "52:54:00:AB:CD:EF"): 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): with self.subTest(mac=value):
self.assertTrue(mac.fullmatch(value)) self.assertTrue(mac.fullmatch(value))
for template in ("modify_ipv4_fixed_address.html", "modify_ipv6_fixed_address.html"): for value in ("web-01", "db.lan", "vm_1"):
name = pattern(template, "name") with self.subTest(name=value):
for value in ("web-01", "db.lan", "vm_1"): self.assertTrue(name.fullmatch(value))
with self.subTest(template=template, name=value): for value in ("a b", 'x"<'):
self.assertTrue(name.fullmatch(value)) with self.subTest(refused=value):
for value in ("a b", 'x"<'): self.assertFalse(name.fullmatch(value))
with self.subTest(template=template, 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, """A network's addresses are read from its XML however libvirt writes it, each
each <ip> by its own index; a change starts from the saved definition, so <ip> by its own index; a page reads each definition once; a change starts
two in a row keep each other; a fixed address is found by the address it from the saved definition, so two in a row keep each other; a fixed address
had and checked against its subnet; the XML editor defines only this is found by the address it had and checked against its subnet; the XML
network; a network that does not start is not left defined. Runs on a editor defines only this network; a network that does not start is not left
private libvirt test driver: "test:///default" is shared by the whole defined. Runs on a private libvirt test driver: "test:///default" is shared
process.""" by the whole process."""
import tempfile import tempfile
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@ -61,12 +61,13 @@ class NetworkChangeTests(SimpleTestCase):
def saved(self): def saved(self):
return etree.fromstring(self.conn.networkLookupByName("lab").XMLDesc(libvirt.VIR_NETWORK_XML_INACTIVE)) 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() net = self.network()
with patch.object(net.net, "XMLDesc", wraps=net.net.XMLDesc) as xml: 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_ip_networks(), net.get_network_forward(), net.get_network_mac(), net.get_qos()
net.get_bridge_device() net.get_bridge_device(), net.get_mode(), net.saved_xml
self.assertEqual(xml.call_count, 1) self.assertEqual(xml.call_count, 2)
def test_the_ipv4_address_after_an_ipv6_one_keeps_its_index(self): def test_the_ipv4_address_after_an_ipv6_one_keeps_its_index(self):
ipv6, ipv4 = self.network().get_ip_networks() ipv6, ipv4 = self.network().get_ip_networks()
@ -151,7 +152,7 @@ class NetworkChangeTests(SimpleTestCase):
def test_the_xml_editor_takes_an_encoding_declaration(self): def test_the_xml_editor_takes_an_encoding_declaration(self):
self.conn.networkLookupByName("lab").destroy() self.conn.networkLookupByName("lab").destroy()
net = self.network() 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"]) self.assertEqual(self.saved().xpath("bridge/@name"), ["virbr6"])
def test_a_fixed_address_is_of_the_subnet_and_its_own(self): 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): def test_the_xml_editor_defines_this_network_only(self):
self.conn.networkLookupByName("lab").destroy() # its definition read back at once self.conn.networkLookupByName("lab").destroy() # its definition read back at once
net = self.network() 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"), 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"), (xml.replace(net.net.UUIDString(), "11111111-2222-3333-4444-555555555555"), "keep the network's UUID"),
("<network>", "not valid")): ("<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 admin.decorators import superuser_only
from computes.models import Compute from computes.models import Compute
from instances.models import Instance
from django.contrib import messages from django.contrib import messages
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, render from django.shortcuts import get_object_or_404, render
@ -13,6 +14,20 @@ from vrtManager.util import OperationError
from networks.forms import AddNetPool 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 @superuser_only
def networks(request, compute_id): def networks(request, compute_id):
""" """
@ -22,6 +37,9 @@ def networks(request, compute_id):
""" """
compute = get_object_or_404(Compute, pk=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: try:
conn = wvmNetworks( conn = wvmNetworks(
@ -31,6 +49,12 @@ def networks(request, compute_id):
compute.type, compute.type,
) )
networks = conn.get_networks_info() 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: if request.method == "POST" and "create" in request.POST:
form = AddNetPool(request.POST, networks=networks) form = AddNetPool(request.POST, networks=networks)
@ -41,18 +65,20 @@ def networks(request, compute_id):
gateway4, netmask4, dhcp4 = network_size(data["subnet"], data["dhcp4"]) gateway4, netmask4, dhcp4 = network_size(data["subnet"], data["dhcp4"])
if data["subnet6"]: if data["subnet6"]:
gateway6, prefix6, dhcp6 = network_size(data["subnet6"], data["dhcp6"]) gateway6, prefix6, dhcp6 = network_size(data["subnet6"], data["dhcp6"])
with audit(request, compute, f"Network {data['name']}", "add"): try:
conn.create_network( with audit(request, compute, f"Network {data['name']}", "add"):
data["name"], data["forward"], conn.create_network(
bool(data["subnet"]), gateway4, netmask4, dhcp4, data["name"], data["forward"],
bool(data["subnet6"]), gateway6, prefix6, dhcp6, bool(data["subnet"]), gateway4, netmask4, dhcp4,
data["bridge_name"], data["openvswitch"], 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"): 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") 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() conn.close()
except libvirtError as lib_err: except libvirtError as lib_err:
messages.error(request, lib_err) messages.error(request, lib_err)
@ -79,18 +105,6 @@ def network(request, compute_id, pool):
compute.type, compute.type,
pool, 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: except libvirtError as lib_err:
messages.error(request, lib_err) messages.error(request, lib_err)
return HttpResponseRedirect(reverse("networks", args=[compute_id])) 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()}, _("%(qos_dir)s QoS is deleted") % {"qos_dir": qos_dir.capitalize()},
) )
return HttpResponseRedirect(here) 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() conn.close()
return render(request, "network.html", locals()) 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 i18n %}
{% load static %}
{% load bootstrap_icons %} {% load bootstrap_icons %}
{% load form_fields %} {% load form_fields %}
{% if request.user.is_superuser %} {% if request.user.is_superuser %}
@ -26,13 +27,13 @@
<legend class="form-label fs-6">{% trans "Storage type" %}</legend> <legend class="form-label fs-6">{% trans "Storage type" %}</legend>
<div class="row row-cols-1 row-cols-sm-2 g-2"> <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 %} {% 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 %} {% 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 %} {% 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 %} {% 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 %} {% if iso_pool %}
{# the upload button is on the page of the pool named iso only #} {# the upload button is on the page of the pool named iso only #}
<div class="col"> <div class="col">
@ -47,7 +48,7 @@
</div> </div>
{% else %} {% else %}
{% trans "ISO images" as title %}{% trans "The directory pool named iso, for uploaded ISO images." as help %} {% 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 %} {% endif %}
</div> </div>
</fieldset> </fieldset>
@ -60,7 +61,7 @@
{% field_error form.name "pool_name" %} {% field_error form.name "pool_name" %}
</div> </div>
<fieldset data-kind="dir"> <fieldset data-kinds="dir">
<legend class="visually-hidden">{% trans "Directory" %}</legend> <legend class="visually-hidden">{% trans "Directory" %}</legend>
<div class="mb-3"> <div class="mb-3">
<label for="pool_dir_target" class="form-label">{% trans "Directory" %}</label> <label for="pool_dir_target" class="form-label">{% trans "Directory" %}</label>
@ -71,7 +72,7 @@
</div> </div>
</fieldset> </fieldset>
<fieldset data-kind="logical"> <fieldset data-kinds="logical">
<legend class="visually-hidden">{% trans "LVM" %}</legend> <legend class="visually-hidden">{% trans "LVM" %}</legend>
<div class="mb-3"> <div class="mb-3">
<label for="pool_lvm_source" class="form-label">{% trans "Device" %}</label> <label for="pool_lvm_source" class="form-label">{% trans "Device" %}</label>
@ -89,7 +90,7 @@
</div> </div>
</fieldset> </fieldset>
<fieldset data-kind="rbd"> <fieldset data-kinds="rbd">
<legend class="visually-hidden">{% trans "Ceph RBD" %}</legend> <legend class="visually-hidden">{% trans "Ceph RBD" %}</legend>
{% if ceph_secrets %} {% if ceph_secrets %}
<div class="row g-3 mb-3"> <div class="row g-3 mb-3">
@ -131,7 +132,7 @@
{% endif %} {% endif %}
</fieldset> </fieldset>
<fieldset data-kind="netfs"> <fieldset data-kinds="netfs">
<legend class="visually-hidden">{% trans "Network filesystem" %}</legend> <legend class="visually-hidden">{% trans "Network filesystem" %}</legend>
<div class="row g-3 mb-3"> <div class="row g-3 mb-3">
<div class="col-sm-6"> <div class="col-sm-6">
@ -165,7 +166,7 @@
</fieldset> </fieldset>
{% if not iso_pool %} {% if not iso_pool %}
<fieldset data-kind="iso"> <fieldset data-kinds="iso">
<legend class="visually-hidden">{% trans "ISO images" %}</legend> <legend class="visually-hidden">{% trans "ISO images" %}</legend>
<div class="mb-3"> <div class="mb-3">
<label for="pool_iso_target" class="form-label">{% trans "Directory" %}</label> <label for="pool_iso_target" class="form-label">{% trans "Directory" %}</label>
@ -185,64 +186,37 @@
</form> </form>
</div> </div>
</div> </div>
<script src="{% static 'js/choice_dialog.js' %}"></script>
<script> <script>
// the chosen type's fields show; the ISO pool is a directory named iso; // the ISO pool is a directory named iso; a path follows the pool's name
// a path follows the pool's name until it is edited (after the page's // until it is edited (after the page's scripts, Bootstrap's among them)
// scripts, Bootstrap's among them, have loaded)
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
var form = document.getElementById('pool-form'); var form = document.getElementById('pool-form');
var name = form.elements.namedItem('name'); var name = form.elements.namedItem('name');
var otherName = name.value; var otherName = name.value;
var hasCephSecrets = {{ ceph_secrets|yesno:"true,false" }}; var hasCephSecrets = {{ ceph_secrets|yesno:"true,false" }};
function choose(kind) { form.querySelectorAll('[data-follow-name]').forEach(function (path) {
var iso = kind === 'iso'; path.dataset.derived = name.value ? path.dataset.followName + name.value : '';
if (!name.readOnly) otherName = name.value; });
name.value = iso ? 'iso' : otherName; name.addEventListener('input', function () {
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) { form.querySelectorAll('[data-follow-name]').forEach(function (path) {
var derived = name.value ? path.dataset.followName + name.value : ''; var derived = name.value ? path.dataset.followName + name.value : '';
if (!path.value || path.value === path.dataset.derived) path.value = derived; if (!path.value || path.value === path.dataset.derived) path.value = derived;
path.dataset.derived = 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 // a new device or name: the erasing is agreed to again
form.querySelectorAll('#pool_lvm_source, #pool_name').forEach(function (field) { form.querySelectorAll('#pool_lvm_source, #pool_name').forEach(function (field) {
field.addEventListener('input', function () { document.getElementById('pool_lvm_erase').checked = false; }); field.addEventListener('input', function () { document.getElementById('pool_lvm_erase').checked = false; });
}); });
form.querySelectorAll('[data-follow-name]').forEach(function (path) { choiceDialog(document.getElementById('AddStgPool'), 'pool_kind', function (kind) {
path.dataset.derived = name.value ? path.dataset.followName + name.value : ''; 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> </script>
{% endif %} {% 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"> <div class="col">
<label class="source-option border rounded p-2 h-100 d-flex gap-2"> <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> <span><span class="d-block fw-semibold">{{ title }}</span><span class="d-block small text-body-secondary">{{ help }}</span></span>
</label> </label>
</div> </div>

View file

@ -3,6 +3,8 @@ import ipaddress
from functools import cached_property from functools import cached_property
from libvirt import ( from libvirt import (
VIR_DOMAIN_XML_INACTIVE,
VIR_ERR_NO_DOMAIN,
VIR_NETWORK_SECTION_IP_DHCP_HOST, VIR_NETWORK_SECTION_IP_DHCP_HOST,
VIR_NETWORK_UPDATE_AFFECT_CONFIG, VIR_NETWORK_UPDATE_AFFECT_CONFIG,
VIR_NETWORK_UPDATE_AFFECT_LIVE, VIR_NETWORK_UPDATE_AFFECT_LIVE,
@ -68,6 +70,17 @@ def ip_networks(tree):
return ips 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): class wvmNetworks(wvmConnect):
def get_networks_info(self): def get_networks_info(self):
"""Each network's state, autostart, device (its bridge, or a macvtap """Each network's state, autostart, device (its bridge, or a macvtap
@ -84,10 +97,26 @@ class wvmNetworks(wvmConnect):
"autostart": net.autostart(), "autostart": net.autostart(),
"device": tree.xpath("string(bridge/@name)") or tree.xpath("string(forward/interface/@dev)"), "device": tree.xpath("string(bridge/@name)") or tree.xpath("string(forward/interface/@dev)"),
"forward": forward.get("mode") if forward is not None else None, "forward": forward.get("mode") if forward is not None else None,
"mode": network_mode(tree),
"ips": ip_networks(tree), "ips": ip_networks(tree),
}) })
return networks 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( def create_network(
self, self,
name, name,
@ -159,9 +188,10 @@ class wvmNetworks(wvmConnect):
class wvmNetwork(wvmConnect): class wvmNetwork(wvmConnect):
"""A network of the host. A page reads its running definition once (tree); """A network of the host. A page reads its running definition (tree) and
a change starts from its saved definition read anew (config), so that two its saved one (saved) once each; a change starts from the saved
changes in a row do not undo each other, and forgets what the page read.""" 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): def __init__(self, host, login, passwd, conn, net):
wvmConnect.__init__(self, host, login, passwd, conn) wvmConnect.__init__(self, host, login, passwd, conn)
@ -171,16 +201,21 @@ class wvmNetwork(wvmConnect):
def tree(self): def tree(self):
return etree.fromstring(self.net.XMLDesc(0)) return etree.fromstring(self.net.XMLDesc(0))
def config(self): @cached_property
return etree.fromstring(self.net.XMLDesc(VIR_NETWORK_XML_INACTIVE)) def saved_xml(self):
def config_xml(self):
"""The saved definition, the one the XML editor changes.""" """The saved definition, the one the XML editor changes."""
return self.net.XMLDesc(VIR_NETWORK_XML_INACTIVE) 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): def _define(self, tree):
self.wvm.networkDefineXML(etree.tostring(tree).decode()) self.wvm.networkDefineXML(etree.tostring(tree).decode())
self.__dict__.pop("tree", None) self._forget()
def get_name(self): def get_name(self):
return self.net.name() return self.net.name()
@ -201,6 +236,29 @@ class wvmNetwork(wvmConnect):
def start(self): def start(self):
self.net.create() 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): def _vms_on_it(self, running_only):
name = self.net.name() name = self.net.name()
return self.domains_using( return self.domains_using(
@ -232,7 +290,7 @@ class wvmNetwork(wvmConnect):
"stop and start it first") "stop and start it first")
flags = VIR_NETWORK_UPDATE_AFFECT_CONFIG | (VIR_NETWORK_UPDATE_AFFECT_LIVE if active else 0) flags = VIR_NETWORK_UPDATE_AFFECT_CONFIG | (VIR_NETWORK_UPDATE_AFFECT_LIVE if active else 0)
self.net.update(command, section, parentIndex, xml, flags) self.net.update(command, section, parentIndex, xml, flags)
self.__dict__.pop("tree", None) self._forget()
@staticmethod @staticmethod
def _addresses(tree): def _addresses(tree):
@ -347,9 +405,11 @@ class wvmNetwork(wvmConnect):
return True return True
def get_qos(self): def get_qos(self):
"""The saved bandwidth limits: a running network takes them when it is started again."""
qos_values = {} qos_values = {}
saved = etree.fromstring(self.saved_xml)
for direction in ("inbound", "outbound"): for direction in ("inbound", "outbound"):
limit = self.tree.find(f"bandwidth/{direction}") limit = saved.find(f"bandwidth/{direction}")
if limit is not None: if limit is not None:
qos_values[direction] = {"average": limit.get("average"), "peak": limit.get("peak"), qos_values[direction] = {"average": limit.get("average"), "peak": limit.get("peak"),
"burst": limit.get("burst")} "burst": limit.get("burst")}
@ -393,4 +453,4 @@ class wvmNetwork(wvmConnect):
if tree.findtext("uuid") not in (None, self.net.UUIDString()): if tree.findtext("uuid") not in (None, self.net.UUIDString()):
raise util.OperationError("The XML must keep the network's UUID") raise util.OperationError("The XML must keep the network's UUID")
self.wvm.networkDefineXML(new_xml) 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 """Status colours keep their contrast on every theme: an interface card's
interface card's header, and a status word, is the theme's pale status header, and a status word, is the theme's pale status background with its
background with its dark status text, a pair that does not depend on the dark status text, a pair that does not depend on the page's background
page's background (dark themes keep light-mode emphasis colours).""" (dark themes keep light-mode emphasis colours)."""
from unittest.mock import PropertyMock, patch from unittest.mock import PropertyMock, patch
@ -9,7 +9,6 @@ import libvirt
from computes.models import Compute from computes.models import Compute
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import TestCase from django.test import TestCase
from networks.tests.driver import without_inactive_flag
from django.urls import reverse from django.urls import reverse
from lxml import html from lxml import html
@ -18,7 +17,6 @@ class StatusContrastTests(TestCase):
def setUp(self): def setUp(self):
self.conn = libvirt.open("test:///default") self.conn = libvirt.open("test:///default")
self.addCleanup(self.conn.close) self.addCleanup(self.conn.close)
without_inactive_flag(self)
self.compute = Compute.objects.create(name="contrast", hostname="localhost", login="", password="", type=4) 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")) 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) return html.fromstring(response.content)
def test_card_headers_are_pale_with_dark_text(self): def test_card_headers_are_pale_with_dark_text(self):
for name in ("networks", "interfaces"): for name in ("interfaces",):
with self.subTest(page=name): with self.subTest(page=name):
headers = self.page(name).xpath("//div[contains(@class, 'card-header')]") headers = self.page(name).xpath("//div[contains(@class, 'card-header')]")
self.assertTrue(headers) self.assertTrue(headers)
@ -40,11 +38,10 @@ class StatusContrastTests(TestCase):
self.assertTrue({"text-success-emphasis", "text-danger-emphasis"} & set(link), link) self.assertTrue({"text-success-emphasis", "text-danger-emphasis"} & set(link), link)
def test_status_words_are_pale_badges_with_dark_text(self): 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 # the test driver has no node memory statistics for the host row
with patch.object(Compute, "ram_usage", new_callable=PropertyMock, return_value=1): with patch.object(Compute, "ram_usage", new_callable=PropertyMock, return_value=1):
status += self.page("instances:index", compute=False).xpath("//span[normalize-space()='Connected']") status = self.page("instances:index", compute=False).xpath("//span[normalize-space()='Connected']")
self.assertEqual(len(status), 3) self.assertEqual(len(status), 1)
for span in status: for span in status:
self.assertIn(span.get("class"), ("badge bg-success-subtle text-success-emphasis", self.assertIn(span.get("class"), ("badge bg-success-subtle text-success-emphasis",
"badge bg-danger-subtle text-danger-emphasis")) "badge bg-danger-subtle text-danger-emphasis"))